Host power-off: refuse while another guest or VM runs; hold the heavy gate; fail closed (R-998)
gates / gates (push) Successful in 33s
gates / gates (push) Successful in 33s
Security review of 3403c87: (1) any guest's controller could switch off a shared host — demo-hp
runs a scratch guest and the Tester 1 VM. Now refused (other_guests_running) when /proc shows any
other lxc-start -n N or kvm -id N; an unreadable /proc refuses. (2) the checks ran once, so a
backup could start before the power-off: the heavy-operation gate is now taken on acceptance and
kept (released only if the power-off command fails). (3) an unwired gate or task reader refused
nothing; now refuses. Each red-proofed.
This commit is contained in:
@@ -3,8 +3,12 @@ package localapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -62,6 +66,7 @@ func newPowerRig(t *testing.T, uptime time.Duration) *powerRig {
|
||||
t.Helper()
|
||||
srv := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil)
|
||||
srv.inFlight = &backup.InFlight{}
|
||||
srv.staleLock = fakeVzdump{}
|
||||
p := &fakePower{}
|
||||
st := poweroff.New(filepath.Join(t.TempDir(), "poweroff.json"), func() string { return "1700000000" })
|
||||
var reports []string
|
||||
@@ -79,8 +84,9 @@ func newPowerRig(t *testing.T, uptime time.Duration) *powerRig {
|
||||
}
|
||||
return true
|
||||
},
|
||||
Uptime: func() (time.Duration, bool) { return uptime, true },
|
||||
Now: func() time.Time { return testNow },
|
||||
Uptime: func() (time.Duration, bool) { return uptime, true },
|
||||
Running: func() ([]int, error) { return []int{8200}, nil },
|
||||
Now: func() time.Time { return testNow },
|
||||
})
|
||||
return &powerRig{srv: srv, h: srv.Handler(), power: p, state: st, report: &reports}
|
||||
}
|
||||
@@ -118,6 +124,10 @@ func TestHostPowerOff_AcceptedOrder(t *testing.T) {
|
||||
if len(steps) != 2 || steps[0] != want[0] || steps[1] != want[1] {
|
||||
t.Fatalf("steps = %v, want %v", steps, want)
|
||||
}
|
||||
// The heavy-operation gate stays taken: no backup can start between the checks and the power-off.
|
||||
if _, busy, ok := r.srv.inFlight.TryAcquire("backup:primary"); ok || busy != "host-poweroff" {
|
||||
t.Fatalf("after acceptance a backup could take the gate (ok=%v busy=%q)", ok, busy)
|
||||
}
|
||||
if len(*r.report) != 1 || (*r.report)[0] != "report" {
|
||||
t.Fatalf("hub report = %v, want one report carrying the record", *r.report)
|
||||
}
|
||||
@@ -215,3 +225,58 @@ func TestHostPowerOff_ScopeAndConfig(t *testing.T) {
|
||||
t.Fatal("a refused request changed state")
|
||||
}
|
||||
}
|
||||
|
||||
// Another guest or VM on the host refuses: one controller must not switch off someone else's guest (security
|
||||
// review 2026-10-13; demo-hp runs a scratch guest and the Tester 1 VM). An unreadable process list refuses too.
|
||||
func TestHostPowerOff_RefusedWhileOtherGuestsRun(t *testing.T) {
|
||||
r := newPowerRig(t, time.Hour)
|
||||
r.srv.powerOff.Running = func() ([]int, error) { return []int{8200, 9202, 341}, nil }
|
||||
w := do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffOtherGuests {
|
||||
t.Fatalf("got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffOtherGuests)
|
||||
}
|
||||
r.srv.powerOff.Running = func() ([]int, error) { return nil, errors.New("no /proc") }
|
||||
w = do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffUnknown {
|
||||
t.Fatalf("unreadable: got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffUnknown)
|
||||
}
|
||||
if len(r.power.got()) != 0 || r.state.Current() != nil {
|
||||
t.Fatal("a refused request changed state")
|
||||
}
|
||||
}
|
||||
|
||||
// Fail closed: an unwired vzdump reader refuses instead of skipping the check.
|
||||
func TestHostPowerOff_FailsClosedWithoutTaskReader(t *testing.T) {
|
||||
r := newPowerRig(t, time.Hour)
|
||||
r.srv.staleLock = nil
|
||||
w := do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffUnknown {
|
||||
t.Fatalf("got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
// The /proc reader finds containers and VMs by their real command lines (as measured on demo-hp).
|
||||
func TestRunningGuestsFromProc(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(pid, cmd string) {
|
||||
_ = os.MkdirAll(filepath.Join(dir, pid), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, pid, "cmdline"), []byte(strings.ReplaceAll(cmd, " ", "\x00")+"\x00"), 0o644)
|
||||
}
|
||||
write("1927", "/usr/bin/lxc-start -F -n 9201")
|
||||
write("1900", "/usr/bin/lxc-start -F -n 9202")
|
||||
write("1823", "/usr/bin/kvm -id 341 -name night1004-tester1 -no-shutdown")
|
||||
write("974", "/usr/libexec/lxc/lxc-monitord --daemon")
|
||||
write("50", "/usr/bin/bash -c echo -n 5")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "self"), 0o755)
|
||||
got, err := runningGuestsFromProc(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Ints(got)
|
||||
if len(got) != 3 || got[0] != 341 || got[1] != 9201 || got[2] != 9202 {
|
||||
t.Fatalf("got %v, want [341 9201 9202]", got)
|
||||
}
|
||||
if _, err := runningGuestsFromProc(filepath.Join(dir, "missing")); err == nil {
|
||||
t.Fatal("an unreadable /proc must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user