Host power-off for the household (R-998): POST /host/poweroff
gates / gates (push) Successful in 34s
gates / gates (push) Successful in 34s
The guest's controller asks; the agent refuses in the first 10 min after boot, a second time in one boot (persisted, keyed by /proc/stat btime), and while a backup, restore test, OS step, controller update, drive format, NAS verify, escrow ceremony or vzdump runs (each a named 409 reason). On acceptance: the record first, a host report carrying deliberate_off accepted by the hub (bounded 60 s), then pct shutdown --timeout 120, then systemctl poweroff. Sudoers: two exact vectors (pct ^shutdown N --timeout N$, systemctl poweroff) in FELHOM_POWEROFF; nine injection cases. New TestEveryAliasIsGranted: a defined alias missing from the grant line passed the coverage check before. Every new test red-proofed.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"time"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -104,6 +105,26 @@ func (b *GuestBinder) RebootGuest(ctx context.Context, vmid int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShutdownGuest stops the guest cleanly with a time limit (R-998 host power-off, step 2). `pct shutdown`
|
||||
// blocks until the guest is down or the limit passes; then it fails and the guest is still running.
|
||||
func (b *GuestBinder) ShutdownGuest(ctx context.Context, vmid int, timeout time.Duration) error {
|
||||
secs := int(timeout / time.Second)
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
b.logger.Warn("host-poweroff: shutting the guest down", "vmid", vmid, "timeout_s", secs)
|
||||
if err := b.run(ctx, "pct", "shutdown", strconv.Itoa(vmid), "--timeout", strconv.Itoa(secs)); err != nil {
|
||||
return fmt.Errorf("host-poweroff: pct shutdown %d: %w", vmid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PowerOffHost powers the Proxmox host off (R-998, step 3). The one fixed vector `/usr/bin/systemctl poweroff`.
|
||||
func (b *GuestBinder) PowerOffHost(ctx context.Context) error {
|
||||
b.logger.Warn("host-poweroff: powering the host off")
|
||||
return b.run(ctx, "/usr/bin/systemctl", "poweroff")
|
||||
}
|
||||
|
||||
func (b *GuestBinder) run(ctx context.Context, name string, args ...string) error {
|
||||
_, stderr, err := b.runner.Run(ctx, name, args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/poweroff"
|
||||
)
|
||||
|
||||
// ── POST /host/poweroff (R-998, documentation/architecture/14-host-power-off.md) ──────────────────────
|
||||
//
|
||||
// The household's „A gép kikapcsolása": shut THIS guest down cleanly, then power the whole host off. The
|
||||
// first local-API route whose effect reaches beyond the caller's own guest — a compromised controller can
|
||||
// now switch the box off, and nobody can switch it on remotely. The mitigations are the limits below:
|
||||
//
|
||||
// - refused in the first 10 minutes after boot (no power-off loop);
|
||||
// - at most ONE accepted request per boot (poweroff.State, persisted, keyed by the boot);
|
||||
// - refused while a backup, restore test, OS update step, controller update, drive format, NAS verify
|
||||
// or escrow ceremony runs, or a vzdump of the guest is in flight — each named in the 409.
|
||||
//
|
||||
// On acceptance the record is written FIRST, the hub is given a report carrying it (bounded wait), and
|
||||
// only then: guest shutdown (time-limited), host power-off. Pinned by hostpoweroff_test.go.
|
||||
|
||||
// HostPowerer is the privileged half: both run as fixed sudo vectors (configs/felhom-agent.sudoers).
|
||||
type HostPowerer interface {
|
||||
ShutdownGuest(ctx context.Context, vmid int, timeout time.Duration) error
|
||||
PowerOffHost(ctx context.Context) error
|
||||
}
|
||||
|
||||
// HostPowerOffConfig wires the route. A nil config leaves it answering 503.
|
||||
type HostPowerOffConfig struct {
|
||||
State *poweroff.State
|
||||
Power HostPowerer
|
||||
ReportNow func(ctx context.Context) bool // push a report carrying the record; true = the hub accepted it
|
||||
Uptime func() (time.Duration, bool) // time since host boot; false = unknown (refuse)
|
||||
MinUptime time.Duration // default 10 min
|
||||
GuestStop time.Duration // pct shutdown --timeout; default 120 s
|
||||
ReportMax time.Duration // longest wait for the hub; default 60 s
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Refusal codes — the controller maps each to a household sentence. Stable wire values.
|
||||
const (
|
||||
PowerOffTooSoon = "too_soon_after_boot"
|
||||
PowerOffAlreadyAsked = "already_requested_this_boot"
|
||||
PowerOffBackup = "backup_running"
|
||||
PowerOffRestoreTest = "restore_test_running"
|
||||
PowerOffOSUpdate = "os_update_running"
|
||||
PowerOffCtrlUpdate = "controller_update_running"
|
||||
PowerOffDrive = "drive_operation_running"
|
||||
PowerOffEscrow = "escrow_ceremony_running"
|
||||
PowerOffBusy = "host_busy"
|
||||
PowerOffUnknown = "state_unknown"
|
||||
)
|
||||
|
||||
// SetHostPowerOff enables POST /host/poweroff.
|
||||
func (s *Server) SetHostPowerOff(c HostPowerOffConfig) {
|
||||
if c.MinUptime == 0 {
|
||||
c.MinUptime = 10 * time.Minute
|
||||
}
|
||||
if c.GuestStop == 0 {
|
||||
c.GuestStop = 120 * time.Second
|
||||
}
|
||||
if c.ReportMax == 0 {
|
||||
c.ReportMax = 60 * time.Second
|
||||
}
|
||||
if c.Now == nil {
|
||||
c.Now = time.Now
|
||||
}
|
||||
s.powerOff = &c
|
||||
}
|
||||
|
||||
type hostPowerOffRequest struct {
|
||||
VMID int `json:"vmid"`
|
||||
}
|
||||
|
||||
func (s *Server) handleHostPowerOff(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
c := s.powerOff
|
||||
if c == nil || c.State == nil || c.Power == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "host power-off not configured on this host")
|
||||
return
|
||||
}
|
||||
if r.ContentLength != 0 {
|
||||
var req hostPowerOffRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// One decision at a time: two requests racing must not both pass the guards before either marks.
|
||||
s.powerOffMu.Lock()
|
||||
defer s.powerOffMu.Unlock()
|
||||
if code, detail := s.powerOffBlocker(r.Context(), vmid); code != "" {
|
||||
s.logger.Info("local-api: host power-off refused", "vmid", vmid, "reason", code, "detail", detail)
|
||||
writeStatus(w, http.StatusConflict, false, map[string]any{"reason": code, "detail": detail}, "host power-off refused: "+code)
|
||||
return
|
||||
}
|
||||
rec, err := c.State.Mark(c.Now(), vmid)
|
||||
if err != nil {
|
||||
code := PowerOffUnknown
|
||||
if errors.Is(err, poweroff.ErrAlreadyThisBoot) {
|
||||
code = PowerOffAlreadyAsked
|
||||
}
|
||||
s.logger.Warn("local-api: host power-off refused (record)", "vmid", vmid, "reason", code, "err", err)
|
||||
writeStatus(w, http.StatusConflict, false, map[string]any{"reason": code, "detail": err.Error()}, "host power-off refused: "+code)
|
||||
return
|
||||
}
|
||||
s.logger.Warn("local-api: HOST POWER-OFF accepted (household request)", "vmid", vmid,
|
||||
"requested_at", rec.RequestedAt.Format(time.RFC3339))
|
||||
base := s.baseCtx
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
go s.runHostPowerOff(base, vmid)
|
||||
writeStatus(w, http.StatusAccepted, true, map[string]any{
|
||||
"vmid": vmid, "powering_off": true, "requested_at": rec.RequestedAt.Format(time.RFC3339),
|
||||
}, "")
|
||||
}
|
||||
|
||||
// runHostPowerOff: hub first (bounded), then the guest (time-limited), then the host. A failed guest
|
||||
// shutdown does not stop the power-off — the household asked for it, and Proxmox's own shutdown stops
|
||||
// any guest still running (pve-guests) before the host goes down.
|
||||
func (s *Server) runHostPowerOff(base context.Context, vmid int) {
|
||||
c := s.powerOff
|
||||
if c.ReportNow != nil {
|
||||
ctx, cancel := context.WithTimeout(base, c.ReportMax)
|
||||
ok := c.ReportNow(ctx)
|
||||
cancel()
|
||||
s.logger.Warn("host-poweroff: step 1/3 — hub told", "vmid", vmid, "hub_accepted", ok)
|
||||
}
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(base, c.GuestStop+60*time.Second)
|
||||
err := c.Power.ShutdownGuest(ctx, vmid, c.GuestStop)
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.logger.Error("host-poweroff: step 2/3 — guest shutdown failed; powering off anyway", "vmid", vmid,
|
||||
"took", time.Since(start).Round(time.Second), "err", err)
|
||||
} else {
|
||||
s.logger.Warn("host-poweroff: step 2/3 — guest stopped", "vmid", vmid, "took", time.Since(start).Round(time.Second))
|
||||
}
|
||||
s.logger.Warn("host-poweroff: step 3/3 — powering the host off")
|
||||
ctx, cancel = context.WithTimeout(base, 60*time.Second)
|
||||
defer cancel()
|
||||
if err := c.Power.PowerOffHost(ctx); err != nil {
|
||||
s.logger.Error("host-poweroff: host power-off command failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// powerOffBlocker returns the first reason to refuse ("" = none) and a detail for the log/operator.
|
||||
func (s *Server) powerOffBlocker(ctx context.Context, vmid int) (string, string) {
|
||||
c := s.powerOff
|
||||
if c.State.Current() != nil {
|
||||
return PowerOffAlreadyAsked, "a power-off was already accepted in this boot"
|
||||
}
|
||||
if c.Uptime == nil {
|
||||
return PowerOffUnknown, "uptime not wired"
|
||||
}
|
||||
up, ok := c.Uptime()
|
||||
if !ok {
|
||||
return PowerOffUnknown, "cannot read the host uptime"
|
||||
}
|
||||
if up < c.MinUptime {
|
||||
return PowerOffTooSoon, "host up " + up.Round(time.Second).String() + ", minimum " + c.MinUptime.String()
|
||||
}
|
||||
if busy := s.inFlight.Busy(); busy != "" {
|
||||
switch {
|
||||
case strings.HasPrefix(busy, "backup:"):
|
||||
return PowerOffBackup, busy
|
||||
case busy == "restore-test":
|
||||
return PowerOffRestoreTest, busy
|
||||
case strings.HasPrefix(busy, "os-"):
|
||||
return PowerOffOSUpdate, busy
|
||||
default:
|
||||
return PowerOffBusy, busy
|
||||
}
|
||||
}
|
||||
s.jobsMu.Lock()
|
||||
for k, j := range s.jobs {
|
||||
if j != nil && backupInFlight(j.Phase) {
|
||||
s.jobsMu.Unlock()
|
||||
return PowerOffBackup, "backup job " + j.JobID + " on target " + k.target
|
||||
}
|
||||
}
|
||||
s.jobsMu.Unlock()
|
||||
s.swapMu.Lock()
|
||||
for v, on := range s.swapInFlight {
|
||||
if on {
|
||||
s.swapMu.Unlock()
|
||||
return PowerOffCtrlUpdate, "controller swap in flight for guest " + strconv.Itoa(v)
|
||||
}
|
||||
}
|
||||
s.swapMu.Unlock()
|
||||
if s.formatJobs != nil {
|
||||
if j := s.formatJobs.get(); j != nil && j.Phase == formatPhaseRunning {
|
||||
return PowerOffDrive, "drive format running"
|
||||
}
|
||||
}
|
||||
s.netVerifyMu.Lock()
|
||||
nv := s.netVerifyCur != nil && s.netVerifyCur.Phase == netVerifyPhaseRunning
|
||||
s.netVerifyMu.Unlock()
|
||||
if nv {
|
||||
return PowerOffDrive, "network storage verify running"
|
||||
}
|
||||
s.escrowMu.Lock()
|
||||
esc := s.escrowJob != nil && s.escrowJob.Phase == escrowPhaseRunning
|
||||
s.escrowMu.Unlock()
|
||||
if esc {
|
||||
return PowerOffEscrow, "escrow ceremony running"
|
||||
}
|
||||
if s.staleLock != nil {
|
||||
running, err := s.staleLock.BackupRunning(ctx, vmid)
|
||||
if err != nil {
|
||||
return PowerOffUnknown, "cannot list running Proxmox tasks: " + err.Error()
|
||||
}
|
||||
if running {
|
||||
return PowerOffBackup, "vzdump running for guest " + strconv.Itoa(vmid)
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/poweroff"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
)
|
||||
|
||||
// fakePower records the power-off steps in order.
|
||||
type fakePower struct {
|
||||
mu sync.Mutex
|
||||
steps []string
|
||||
}
|
||||
|
||||
func (f *fakePower) ShutdownGuest(_ context.Context, vmid int, timeout time.Duration) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.steps = append(f.steps, "shutdown:"+itoaT(vmid)+":"+timeout.String())
|
||||
return nil
|
||||
}
|
||||
func (f *fakePower) PowerOffHost(context.Context) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.steps = append(f.steps, "poweroff")
|
||||
return nil
|
||||
}
|
||||
func (f *fakePower) got() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.steps...)
|
||||
}
|
||||
|
||||
func itoaT(n int) string { b, _ := json.Marshal(n); return string(b) }
|
||||
|
||||
type fakeVzdump struct{ running bool }
|
||||
|
||||
func (f fakeVzdump) Guests(context.Context) ([]proxmox.Guest, error) { return nil, nil }
|
||||
func (f fakeVzdump) Lock(context.Context, int) (string, bool, error) { return "", true, nil }
|
||||
func (f fakeVzdump) BackupRunning(context.Context, int) (bool, error) { return f.running, nil }
|
||||
func (f fakeVzdump) Unlock(context.Context, int) error { return nil }
|
||||
func (f fakeVzdump) Start(context.Context, int) error { return nil }
|
||||
func (f fakeVzdump) HasVzdumpSnapshot(context.Context, int) (bool, error) { return false, nil }
|
||||
func (f fakeVzdump) DeleteVzdumpSnapshot(context.Context, int) error { return nil }
|
||||
|
||||
type powerRig struct {
|
||||
srv *Server
|
||||
h http.Handler
|
||||
power *fakePower
|
||||
state *poweroff.State
|
||||
report *[]string
|
||||
}
|
||||
|
||||
func newPowerRig(t *testing.T, uptime time.Duration) *powerRig {
|
||||
t.Helper()
|
||||
srv := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil)
|
||||
srv.inFlight = &backup.InFlight{}
|
||||
p := &fakePower{}
|
||||
st := poweroff.New(filepath.Join(t.TempDir(), "poweroff.json"), func() string { return "1700000000" })
|
||||
var reports []string
|
||||
srv.SetHostPowerOff(HostPowerOffConfig{
|
||||
State: st,
|
||||
Power: p,
|
||||
ReportNow: func(context.Context) bool {
|
||||
// The record must already exist when the hub is told — that is the point of telling it.
|
||||
if st.Current() == nil {
|
||||
reports = append(reports, "report-without-record")
|
||||
} else if len(p.got()) != 0 {
|
||||
reports = append(reports, "report-after-"+p.got()[0])
|
||||
} else {
|
||||
reports = append(reports, "report")
|
||||
}
|
||||
return true
|
||||
},
|
||||
Uptime: func() (time.Duration, bool) { return uptime, true },
|
||||
Now: func() time.Time { return testNow },
|
||||
})
|
||||
return &powerRig{srv: srv, h: srv.Handler(), power: p, state: st, report: &reports}
|
||||
}
|
||||
|
||||
func (r *powerRig) waitSteps(t *testing.T, n int) []string {
|
||||
t.Helper()
|
||||
for i := 0; i < 200 && len(r.power.got()) < n; i++ {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return r.power.got()
|
||||
}
|
||||
|
||||
func reasonOf(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
t.Fatalf("body %s: %v", body, err)
|
||||
}
|
||||
return resp.Data.Reason
|
||||
}
|
||||
|
||||
// The whole path: 202, the record written, the hub told FIRST, then guest shutdown (120 s), then host off.
|
||||
func TestHostPowerOff_AcceptedOrder(t *testing.T) {
|
||||
r := newPowerRig(t, time.Hour)
|
||||
w := do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("got %d want 202 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
steps := r.waitSteps(t, 2)
|
||||
want := []string{"shutdown:8200:2m0s", "poweroff"}
|
||||
if len(steps) != 2 || steps[0] != want[0] || steps[1] != want[1] {
|
||||
t.Fatalf("steps = %v, want %v", steps, want)
|
||||
}
|
||||
if len(*r.report) != 1 || (*r.report)[0] != "report" {
|
||||
t.Fatalf("hub report = %v, want one report carrying the record", *r.report)
|
||||
}
|
||||
if rec := r.state.Current(); rec == nil || rec.VMID != 8200 {
|
||||
t.Fatalf("record = %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// Refused during a backup — the shared heavy-operation gate held by a backup tier.
|
||||
func TestHostPowerOff_RefusedDuringBackup(t *testing.T) {
|
||||
r := newPowerRig(t, time.Hour)
|
||||
release, _, ok := r.srv.inFlight.TryAcquire("backup:primary")
|
||||
if !ok {
|
||||
t.Fatal("could not hold the gate")
|
||||
}
|
||||
defer release()
|
||||
w := do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffBackup {
|
||||
t.Fatalf("got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffBackup)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if s := r.power.got(); len(s) != 0 {
|
||||
t.Fatalf("power steps ran during a backup: %v", s)
|
||||
}
|
||||
if r.state.Current() != nil {
|
||||
t.Fatal("a refused request must not use up the boot's one request")
|
||||
}
|
||||
// The same guard names a restore test and an OS step.
|
||||
release()
|
||||
for gate, want := range map[string]string{"restore-test": PowerOffRestoreTest, "os-pve-step": PowerOffOSUpdate} {
|
||||
rel, _, _ := r.srv.inFlight.TryAcquire(gate)
|
||||
w := do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
rel()
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != want {
|
||||
t.Fatalf("%s: got %d %s, want 409 %s", gate, w.Code, w.Body.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A vzdump of the guest in flight (a Proxmox task, not the agent's own gate) refuses too.
|
||||
func TestHostPowerOff_RefusedDuringVzdump(t *testing.T) {
|
||||
r := newPowerRig(t, time.Hour)
|
||||
r.srv.staleLock = fakeVzdump{running: true}
|
||||
w := do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffBackup {
|
||||
t.Fatalf("got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffBackup)
|
||||
}
|
||||
}
|
||||
|
||||
// One accepted request per boot: the second is refused and nothing runs twice.
|
||||
func TestHostPowerOff_SecondRequestSameBootRefused(t *testing.T) {
|
||||
r := newPowerRig(t, time.Hour)
|
||||
if w := do(t, r.h, "POST", "/host/poweroff", "A", ""); w.Code != http.StatusAccepted {
|
||||
t.Fatalf("first: %d", w.Code)
|
||||
}
|
||||
r.waitSteps(t, 2)
|
||||
w := do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffAlreadyAsked {
|
||||
t.Fatalf("second: got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffAlreadyAsked)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if s := r.power.got(); len(s) != 2 {
|
||||
t.Fatalf("steps = %v, want exactly one run", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Refused in the first 10 minutes after boot; an unknown uptime refuses too (fail closed).
|
||||
func TestHostPowerOff_TooSoonAfterBoot(t *testing.T) {
|
||||
r := newPowerRig(t, 9*time.Minute)
|
||||
w := do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffTooSoon {
|
||||
t.Fatalf("got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffTooSoon)
|
||||
}
|
||||
r.srv.powerOff.Uptime = func() (time.Duration, bool) { return 0, false }
|
||||
w = do(t, r.h, "POST", "/host/poweroff", "A", "")
|
||||
if w.Code != http.StatusConflict || reasonOf(t, w.Body.Bytes()) != PowerOffUnknown {
|
||||
t.Fatalf("unknown uptime: got %d %s, want 409 %s", w.Code, w.Body.String(), PowerOffUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
// Not configured → 503; another guest's vmid in the body → 403; no token → 401.
|
||||
func TestHostPowerOff_ScopeAndConfig(t *testing.T) {
|
||||
plain := newTestServer(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil)
|
||||
if w := do(t, plain, "POST", "/host/poweroff", "A", ""); w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("unconfigured: %d", w.Code)
|
||||
}
|
||||
r := newPowerRig(t, time.Hour)
|
||||
if w := do(t, r.h, "POST", "/host/poweroff", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-guest: %d", w.Code)
|
||||
}
|
||||
if w := do(t, r.h, "POST", "/host/poweroff", "", ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no token: %d", w.Code)
|
||||
}
|
||||
if r.state.Current() != nil || len(r.power.got()) != 0 {
|
||||
t.Fatal("a refused request changed state")
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,10 @@ type Server struct {
|
||||
// detached pipeline runs through (tests inject; production defaults set in NewServer).
|
||||
netVerifyMu sync.Mutex
|
||||
netVerifyCur *netVerifyJob
|
||||
|
||||
// R-998: POST /host/poweroff (nil = not configured → 503); one guard-and-mark decision at a time.
|
||||
powerOff *HostPowerOffConfig
|
||||
powerOffMu sync.Mutex
|
||||
// netTrigger performs the mount-waking directory read through the automount path (Q1).
|
||||
netTrigger func(where string) error
|
||||
// netMounted judges mount success from /proc/mounts — the §8 truth source (never readability).
|
||||
@@ -550,6 +554,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /disks/guest-attach", s.withGuest(s.handleDiskGuestAttach))
|
||||
// Guest reboot (slice 10 P2 activation): user-triggered restart to activate pending drive binds.
|
||||
mux.HandleFunc("POST /guest/reboot", s.withGuest(s.handleGuestReboot))
|
||||
// Host power-off (R-998, doc 14): the household's „A gép kikapcsolása". Guarded in hostpoweroff.go.
|
||||
mux.HandleFunc("POST /host/poweroff", s.withGuest(s.handleHostPowerOff))
|
||||
// Guest RAM resize (v0.90.0, R-24): read current allocation + bounds, and apply a bounded resize
|
||||
// (live cgroup apply, no reboot). Self-scoped; the agent enforces every bound fresh per request.
|
||||
mux.HandleFunc("GET /guest/memory", s.withGuest(s.handleGuestMemory))
|
||||
|
||||
Reference in New Issue
Block a user