Files
felhom-agent/internal/poweroff/state.go
T
admin 3403c87c80
gates / gates (push) Successful in 34s
Host power-off for the household (R-998): POST /host/poweroff
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.
2026-10-13 19:29:52 +02:00

118 lines
3.3 KiB
Go

// Package poweroff keeps the box's record of a household-requested power-off (R-998, doc 14).
//
// One small JSON file in the agent's state dir. A record belongs to the boot it was made in (the
// kernel's btime from /proc/stat), so:
// - "at most one accepted request per boot": Mark refuses when this boot already has a record;
// - the host report carries the record only while the box is still in that boot (Current), and
// the next boot's reports carry nothing — that absence is how the hub learns the box is back.
//
// Pinned by state_test.go.
package poweroff
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// ErrAlreadyThisBoot is Mark's refusal: a power-off was already accepted in the current boot.
var ErrAlreadyThisBoot = errors.New("a power-off was already accepted in this boot")
// ErrBootUnknown is Mark's refusal when the boot cannot be identified — fail closed: without it the
// per-boot limit could not hold.
var ErrBootUnknown = errors.New("cannot read the host's boot time")
// Record is one accepted request.
type Record struct {
RequestedAt time.Time `json:"requested_at"`
BootBtime string `json:"boot_btime"` // /proc/stat btime of the boot it was accepted in
VMID int `json:"vmid"` // the guest whose controller asked
}
// State is the persisted record.
type State struct {
path string
btime func() string
mu sync.Mutex
}
// New returns the state at path. btime returns the current boot's /proc/stat btime ("" = unknown).
func New(path string, btime func() string) *State {
if btime == nil {
btime = ProcBtime
}
return &State{path: path, btime: btime}
}
// Current is the record made in THIS boot, or nil (none, another boot's, unreadable).
func (s *State) Current() *Record {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
r := s.load()
b := s.btime()
if r == nil || b == "" || r.BootBtime != b {
return nil
}
return r
}
// Mark records an accepted request for this boot. It refuses a second one in the same boot.
func (s *State) Mark(now time.Time, vmid int) (*Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
b := s.btime()
if b == "" {
return nil, ErrBootUnknown
}
if r := s.load(); r != nil && r.BootBtime == b {
return nil, ErrAlreadyThisBoot
}
r := &Record{RequestedAt: now.UTC().Truncate(time.Second), BootBtime: b, VMID: vmid}
data, _ := json.Marshal(r)
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
return nil, fmt.Errorf("poweroff: state dir: %w", err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return nil, fmt.Errorf("poweroff: write: %w", err)
}
if err := os.Rename(tmp, s.path); err != nil {
return nil, fmt.Errorf("poweroff: rename: %w", err)
}
return r, nil
}
func (s *State) load() *Record {
data, err := os.ReadFile(s.path)
if err != nil {
return nil
}
var r Record
if json.Unmarshal(data, &r) != nil || r.BootBtime == "" {
return nil
}
return &r
}
// ProcBtime reads the boot time (seconds since the epoch, as text) from /proc/stat; "" when unreadable.
func ProcBtime() string {
data, err := os.ReadFile("/proc/stat")
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "btime ") {
return strings.TrimSpace(strings.TrimPrefix(line, "btime "))
}
}
return ""
}