hub: operator actions for a box (D1, R-314/R-279/R-177, decision 185)

Host page "Operator Actions" card: run off-site backup now, run a check now
(fixed job list), stop / extend (1-30 days) a deletion countdown. POST
/hosts/{id}/operator-action validates against the CLOSED list before
storing (unknown -> 400, no row), stores operator_actions(id, customer_id,
action, arg, requested_at, requested_by, done_at, outcome, message), logs
who pressed (channel + address) and bumps the box's intent. The report ACK
lists pending rows as operator_actions until the box's
operator_action_results closes them (matched on id AND reporting
customer); each closed row becomes a hub-minted operator_action event
(stored, never dispatched). Unanswered after 24 h: expired. A customer
RESET cancels pending rows. Wire gate: new root + field-by-field mirror
(controller report.OperatorAction) — needs the controller commit first.

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:
2026-10-08 14:38:53 +02:00
parent d604e624a3
commit 87af859fc3
14 changed files with 850 additions and 2 deletions
+16
View File
@@ -481,6 +481,12 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
}
}
// Operator actions (R-314/R-279/R-177, `09` §3 decision 185): the box's results for the actions
// the ACK listed. Each closes its row — matched on the id AND this report's customer, so a result
// naming another customer's action changes nothing — and becomes a hub-minted event (stored only,
// never dispatched: an operator record, not a customer mail). Old controllers never send this.
h.ingestOperatorActionResults(payload.CustomerID, body)
// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
var drPayload struct {
@@ -609,6 +615,16 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
resp["controller_log_requested"] = true
}
// Decision 185: the operator's pending actions for this box, listed until each result arrives.
// Read AFTER the results above were recorded, so an action answered in this report is not
// listed back in the same reply. Omitted when none (an old controller ignores the field).
if acts, err := h.store.PendingOperatorActions(payload.CustomerID); err != nil {
h.logger.Printf("[WARN] operator actions for %s could not be read (not listed this cycle): %v", payload.CustomerID, err)
} else if len(acts) > 0 {
resp["operator_actions"] = acts
h.logger.Printf("[DEBUG] operator actions listed for %s: %d", payload.CustomerID, len(acts))
}
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
// else global default) and the latest available version. The controller compares its current
// version against the floor and auto-updates when below it (latest stays the customer's opt-in
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"encoding/json"
"fmt"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// operatorActionResultsPayload is the controller report's operator_action_results (decision 185;
// controller internal/report OperatorActionResult).
type operatorActionResultsPayload struct {
OperatorActionResults []struct {
ID int64 `json:"id"`
Outcome string `json:"outcome"`
Message string `json:"message"`
} `json:"operator_action_results"`
}
// ingestOperatorActionResults closes each answered row and saves one hub-minted event per closed row.
// The box re-sends a result until the ACK stops listing its id; a re-send finds the row closed and
// does nothing (RecordOperatorActionResult matches only open rows of THIS customer).
func (h *Handler) ingestOperatorActionResults(customerID string, body []byte) {
var p operatorActionResultsPayload
if err := json.Unmarshal(body, &p); err != nil || len(p.OperatorActionResults) == 0 {
return
}
for _, res := range p.OperatorActionResults {
row, err := h.store.RecordOperatorActionResult(customerID, res.ID, res.Outcome, res.Message)
switch {
case err != nil:
h.logger.Printf("[WARN] operator action #%d result from %s not recorded: %v", res.ID, customerID, err)
continue
case row == nil:
h.logger.Printf("[DEBUG] operator action #%d result from %s ignored (already closed, or not this customer's)", res.ID, customerID)
continue
}
h.logger.Printf("[INFO] operator action #%d %s%s for %s (pressed by %s): %s — %s",
row.ID, row.Action, argSuffix(row.Arg), customerID, row.RequestedBy, row.Outcome, row.Message)
severity := "info"
if row.Outcome != store.OperatorActionDone {
severity = "warning"
}
if _, eerr := h.store.SaveEvent(customerID, "operator_action", severity,
fmt.Sprintf("Operator action %s%s (pressed by %s): %s — %s", row.Action, argSuffix(row.Arg), row.RequestedBy, row.Outcome, row.Message),
"", "hub"); eerr != nil {
h.logger.Printf("[WARN] operator action #%d recorded, but its event could not be saved: %v", row.ID, eerr)
}
}
}
func argSuffix(arg string) string {
if arg == "" {
return ""
}
return " " + arg
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// `09` §3 decision 185 (D1), the design's red test (4), wire half: the report reply lists a pending
// operator action until a result arrives, then not; a result naming another customer's id is ignored;
// a closed result becomes a hub-minted event.
func ackActions(t *testing.T, h *Handler, customerID, extra string) []store.OperatorActionDirective {
t.Helper()
body := `{"customer_id":"` + customerID + `"` + extra + `}`
rr := do(h, http.MethodPost, "/report", globalKey, body)
if rr.Code != http.StatusOK {
t.Fatalf("report: %d %s", rr.Code, rr.Body.String())
}
var ack struct {
OperatorActions []store.OperatorActionDirective `json:"operator_actions"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
t.Fatal(err)
}
return ack.OperatorActions
}
func TestReportACK_OperatorActionsUntilResult(t *testing.T) {
h, st, _ := newTestHandler(t)
id, err := st.CreateOperatorAction("c1", "offsite_backup_now", "", "operator browser session from 10.0.0.1")
if err != nil {
t.Fatal(err)
}
// Listed to c1, not to c2.
if got := ackActions(t, h, "c1", ""); len(got) != 1 || got[0].ID != id || got[0].Action != "offsite_backup_now" {
t.Fatalf("c1 ACK = %+v", got)
}
if got := ackActions(t, h, "c2", ""); len(got) != 0 {
t.Fatalf("c2 sees c1's action: %+v", got)
}
// c2 reports a result for c1's id: ignored — c1's ACK still lists it, and no event is saved.
res := fmt.Sprintf(`,"operator_action_results":[{"id":%d,"outcome":"done","message":"forged"}]`, id)
ackActions(t, h, "c2", res)
if got := ackActions(t, h, "c1", ""); len(got) != 1 {
t.Fatalf("a cross-customer result closed c1's action: %+v", got)
}
if evs, _ := st.GetRecentEvents("c2", 10); len(evs) != 0 {
t.Fatalf("a cross-customer result minted an event: %+v", evs)
}
// c1's own result: closed, and THE SAME reply no longer lists it.
res = fmt.Sprintf(`,"operator_action_results":[{"id":%d,"outcome":"done","message":"the off-site backup finished"}]`, id)
if got := ackActions(t, h, "c1", res); len(got) != 0 {
t.Fatalf("still listed in the reply to its own result: %+v", got)
}
evs, _ := st.GetRecentEvents("c1", 10)
if len(evs) != 1 || evs[0].EventType != "operator_action" || evs[0].Source != "hub" ||
!strings.Contains(evs[0].Message, "offsite_backup_now") || !strings.Contains(evs[0].Message, "10.0.0.1") {
t.Fatalf("events = %+v", evs)
}
// The box re-sends until it sees the id gone: a no-op, no second event.
ackActions(t, h, "c1", res)
if evs, _ := st.GetRecentEvents("c1", 10); len(evs) != 1 {
t.Fatalf("a re-sent result minted %d events", len(evs))
}
}
+7
View File
@@ -169,6 +169,13 @@ func (s *Store) PurgeCustomerResetDBState(customerID string, escrowAcked bool) e
return fmt.Errorf("reset purge %q: %w", q, err)
}
}
// Decision 185: an operator action still pending for the OLD box must never reach the box that
// takes this customer id next. Closed, not deleted — the row is the record of who pressed what.
if _, err := tx.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ?
WHERE customer_id = ? AND done_at IS NULL`,
time.Now().UTC(), OperatorActionCancelled, "cancelled by the customer RESET", customerID); err != nil {
return fmt.Errorf("reset: cancel pending operator actions: %w", err)
}
return tx.Commit()
}
+247
View File
@@ -0,0 +1,247 @@
package store
import (
"database/sql"
"fmt"
"sort"
"strconv"
"time"
)
// Operator actions (R-314 / R-279 / R-177, `09` §3 decision 185 — D1, design option A of
// documentation/audits/day-2026-10-08/design-R-314-279-177.md).
//
// The operator presses a button on the host page; a row is stored here and the box's intent generation
// is bumped, so its wait channel wakes; the report ACK lists every pending row as
// `operator_actions: [{id, action, arg}]`; the controller acts once per id and sends
// `operator_action_results: [{id, outcome, message}]` on its next report; the row is closed and stops
// being listed. The hub never connects into the box.
//
// THE LIST IS CLOSED, and the hub refuses an unknown action, job or argument BEFORE storing anything —
// the controller refuses them again on its side. Nothing on the list deletes data, starts a countdown or
// shortens one; `03` §4 asks for a signing key only to destroy or overwrite the only copy. The list is
// pinned on both sides (TestOperatorActions_ClosedList here, TestOpActions_ClosedList in the controller)
// so a new entry is an operator decision, not an edit.
const (
OperatorActionOffsiteBackupNow = "offsite_backup_now"
OperatorActionAbandonStop = "abandon_stop"
OperatorActionAbandonExtend = "abandon_extend"
OperatorActionRunJob = "run_job"
// Outcomes: the box's three, plus two the hub sets itself.
OperatorActionDone = "done"
OperatorActionRefused = "refused"
OperatorActionFailed = "failed"
OperatorActionExpired = "expired" // the box did not answer within OperatorActionTTL
OperatorActionCancelled = "cancelled" // the customer was RESET while it was pending
OperatorActionExtendMinDays = 1
OperatorActionExtendMaxDays = 30
// OperatorActionTTL: a pending action the box has not answered in a day is closed as expired and
// no longer listed — an off-site run pressed for a box that was off for a week must not start the
// moment it comes back, unasked.
OperatorActionTTL = 24 * time.Hour
// operatorActionMessageMax bounds the box's message stored per row.
operatorActionMessageMax = 500
// operatorActionsListed caps how many pending rows one ACK carries.
operatorActionsListed = 10
)
var operatorActionNames = []string{OperatorActionOffsiteBackupNow, OperatorActionAbandonStop, OperatorActionAbandonExtend, OperatorActionRunJob}
// operatorJobNames is the fixed set run_job may name — the controller's scheduler job names.
var operatorJobNames = []string{"fill-watch", "offsite-integrity", "offsite-proof", "disk-health-check"}
// OperatorActionNames returns the closed action list (sorted copy).
func OperatorActionNames() []string { return sortedStrings(operatorActionNames) }
// OperatorJobNames returns the fixed run_job set (sorted copy).
func OperatorJobNames() []string { return sortedStrings(operatorJobNames) }
func sortedStrings(in []string) []string {
out := append([]string(nil), in...)
sort.Strings(out)
return out
}
func inList(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// ValidateOperatorAction refuses anything outside the closed list. A nil error is the only way a row
// is stored.
func ValidateOperatorAction(action, arg string) error {
switch action {
case OperatorActionOffsiteBackupNow, OperatorActionAbandonStop:
if arg != "" {
return fmt.Errorf("%s takes no argument", action)
}
case OperatorActionAbandonExtend:
d, err := strconv.Atoi(arg)
if err != nil || d < OperatorActionExtendMinDays || d > OperatorActionExtendMaxDays {
return fmt.Errorf("the number of days must be %d-%d", OperatorActionExtendMinDays, OperatorActionExtendMaxDays)
}
case OperatorActionRunJob:
if !inList(operatorJobNames, arg) {
return fmt.Errorf("unknown job %q", arg)
}
default:
return fmt.Errorf("unknown action %q", action)
}
return nil
}
// OperatorActionDirective is ONE entry of the report ACK's operator_actions list — the wire type,
// named so scripts/wire_contract_gate.py can check it field by field against the controller's
// report.OperatorAction.
type OperatorActionDirective struct {
ID int64 `json:"id"`
Action string `json:"action"`
Arg string `json:"arg,omitempty"`
}
// OperatorActionRow is one stored row, for the host page.
type OperatorActionRow struct {
ID int64
CustomerID string
Action string
Arg string
RequestedAt time.Time
RequestedBy string
DoneAt *time.Time
Outcome string
Message string
}
func (s *Store) migrateOperatorActions() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS operator_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
action TEXT NOT NULL,
arg TEXT NOT NULL DEFAULT '',
requested_at DATETIME NOT NULL,
requested_by TEXT NOT NULL DEFAULT '',
done_at DATETIME,
outcome TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_operator_actions_customer ON operator_actions(customer_id, done_at);`)
return err
}
// CreateOperatorAction validates against the closed list and stores a pending row. by names who
// pressed (the operator's channel and address — never a credential).
func (s *Store) CreateOperatorAction(customerID, action, arg, by string) (int64, error) {
if customerID == "" {
return 0, fmt.Errorf("operator action: empty customer id")
}
if err := ValidateOperatorAction(action, arg); err != nil {
return 0, err
}
res, err := s.db.Exec(`INSERT INTO operator_actions (customer_id, action, arg, requested_at, requested_by)
VALUES (?, ?, ?, ?, ?)`, customerID, action, arg, time.Now().UTC(), by)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// PendingOperatorActions returns what the next ACK lists for this customer, oldest first. Rows older
// than OperatorActionTTL are closed as expired first and are not listed.
func (s *Store) PendingOperatorActions(customerID string) ([]OperatorActionDirective, error) {
now := time.Now().UTC()
if _, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ?
WHERE customer_id = ? AND done_at IS NULL AND requested_at < ?`,
now, OperatorActionExpired, "the box did not answer within a day", customerID, now.Add(-OperatorActionTTL)); err != nil {
return nil, err
}
rows, err := s.db.Query(`SELECT id, action, arg FROM operator_actions
WHERE customer_id = ? AND done_at IS NULL ORDER BY id LIMIT ?`, customerID, operatorActionsListed)
if err != nil {
return nil, err
}
defer rows.Close()
var out []OperatorActionDirective
for rows.Next() {
var d OperatorActionDirective
if err := rows.Scan(&d.ID, &d.Action, &d.Arg); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// RecordOperatorActionResult closes a pending row with the box's result. It matches on BOTH the id and
// the reporting customer, so a result naming another customer's action changes nothing (recorded=false).
// An outcome outside the box's three is refused. Returns the closed row when recorded.
func (s *Store) RecordOperatorActionResult(customerID string, id int64, outcome, message string) (*OperatorActionRow, error) {
switch outcome {
case OperatorActionDone, OperatorActionRefused, OperatorActionFailed:
default:
return nil, fmt.Errorf("unknown outcome %q", outcome)
}
if r := []rune(message); len(r) > operatorActionMessageMax {
message = string(r[:operatorActionMessageMax])
}
res, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ?
WHERE id = ? AND customer_id = ? AND done_at IS NULL`, time.Now().UTC(), outcome, message, id, customerID)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, nil
}
return s.getOperatorAction(id)
}
func (s *Store) getOperatorAction(id int64) (*OperatorActionRow, error) {
rows, err := s.db.Query(`SELECT id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message
FROM operator_actions WHERE id = ?`, id)
if err != nil {
return nil, err
}
defer rows.Close()
list, err := scanOperatorActions(rows)
if err != nil || len(list) == 0 {
return nil, err
}
return &list[0], nil
}
// ListOperatorActions returns the customer's newest rows (pending and closed), newest first.
func (s *Store) ListOperatorActions(customerID string, limit int) ([]OperatorActionRow, error) {
rows, err := s.db.Query(`SELECT id, customer_id, action, arg, requested_at, requested_by, done_at, outcome, message
FROM operator_actions WHERE customer_id = ? ORDER BY id DESC LIMIT ?`, customerID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanOperatorActions(rows)
}
func scanOperatorActions(rows *sql.Rows) ([]OperatorActionRow, error) {
var out []OperatorActionRow
for rows.Next() {
var r OperatorActionRow
var done sql.NullTime
if err := rows.Scan(&r.ID, &r.CustomerID, &r.Action, &r.Arg, &r.RequestedAt, &r.RequestedBy, &done, &r.Outcome, &r.Message); err != nil {
return nil, err
}
if done.Valid {
t := done.Time
r.DoneAt = &t
}
out = append(out, r)
}
return out, rows.Err()
}
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"reflect"
"testing"
"time"
)
// `09` §3 decision 185 (D1). See opactions.go.
// The list is CLOSED, on the hub side too — this fails when an action or a job is added, by design. The
// controller pins the same four (internal/report TestOpActions_ClosedList).
func TestOperatorActions_ClosedList(t *testing.T) {
if got, want := OperatorActionNames(), []string{"abandon_extend", "abandon_stop", "offsite_backup_now", "run_job"}; !reflect.DeepEqual(got, want) {
t.Fatalf("operator actions = %v, want exactly %v — a new action needs the operator's word (decision 185)", got, want)
}
if got, want := OperatorJobNames(), []string{"disk-health-check", "fill-watch", "offsite-integrity", "offsite-proof"}; !reflect.DeepEqual(got, want) {
t.Fatalf("run_job names = %v, want exactly %v", got, want)
}
}
func TestOperatorActions_RefusedBeforeStoring(t *testing.T) {
s := newTestStore(t)
bad := []struct{ action, arg string }{
{"delete_everything", ""},
{"run_job", "offsite-abandon-sweep"},
{"run_job", ""},
{"abandon_extend", "0"},
{"abandon_extend", "31"},
{"abandon_extend", "x"},
{"abandon_stop", "1"},
{"offsite_backup_now", "now"},
}
for _, b := range bad {
if _, err := s.CreateOperatorAction("c1", b.action, b.arg, "operator"); err == nil {
t.Errorf("%s(%q) was accepted", b.action, b.arg)
}
}
if rows, _ := s.ListOperatorActions("c1", 50); len(rows) != 0 {
t.Fatalf("a refused action stored %d row(s)", len(rows))
}
for _, ok := range []struct{ action, arg string }{{"offsite_backup_now", ""}, {"abandon_stop", ""}, {"abandon_extend", "1"}, {"abandon_extend", "30"}, {"run_job", "fill-watch"}} {
if _, err := s.CreateOperatorAction("c1", ok.action, ok.arg, "operator"); err != nil {
t.Errorf("%s(%q) refused: %v", ok.action, ok.arg, err)
}
}
}
// Red test (4), store half: listed until a result arrives, then not; a result naming another
// customer's id changes nothing.
func TestOperatorActions_ListedUntilResult_OtherCustomerIgnored(t *testing.T) {
s := newTestStore(t)
id, err := s.CreateOperatorAction("c1", "run_job", "fill-watch", "operator session from 10.0.0.1")
if err != nil {
t.Fatal(err)
}
p, _ := s.PendingOperatorActions("c1")
if len(p) != 1 || p[0] != (OperatorActionDirective{ID: id, Action: "run_job", Arg: "fill-watch"}) {
t.Fatalf("pending = %+v", p)
}
if p2, _ := s.PendingOperatorActions("c2"); len(p2) != 0 {
t.Fatalf("another customer sees c1's action: %+v", p2)
}
// c2 reports a result for c1's id → ignored.
if row, err := s.RecordOperatorActionResult("c2", id, "done", "x"); err != nil || row != nil {
t.Fatalf("cross-customer result recorded: row=%+v err=%v", row, err)
}
if p, _ := s.PendingOperatorActions("c1"); len(p) != 1 {
t.Fatal("a cross-customer result closed the row")
}
if _, err := s.RecordOperatorActionResult("c1", id, "exploded", "x"); err == nil {
t.Fatal("an unknown outcome was accepted")
}
row, err := s.RecordOperatorActionResult("c1", id, "done", "the job fill-watch ran")
if err != nil || row == nil || row.Outcome != "done" || row.DoneAt == nil || row.RequestedBy != "operator session from 10.0.0.1" {
t.Fatalf("record: row=%+v err=%v", row, err)
}
if p, _ := s.PendingOperatorActions("c1"); len(p) != 0 {
t.Fatalf("still listed after its result: %+v", p)
}
// A repeated result (the box re-sends until it sees the id gone) is a no-op.
if row, _ := s.RecordOperatorActionResult("c1", id, "failed", "late"); row != nil {
t.Fatal("a second result overwrote the first")
}
}
func TestOperatorActions_ExpireAndResetCancel(t *testing.T) {
s := newTestStore(t)
old, _ := s.CreateOperatorAction("c1", "offsite_backup_now", "", "operator")
if _, err := s.db.Exec(`UPDATE operator_actions SET requested_at = ? WHERE id = ?`, time.Now().UTC().Add(-25*time.Hour), old); err != nil {
t.Fatal(err)
}
fresh, _ := s.CreateOperatorAction("c1", "abandon_stop", "", "operator")
p, _ := s.PendingOperatorActions("c1")
if len(p) != 1 || p[0].ID != fresh {
t.Fatalf("a day-old action is still listed: %+v", p)
}
if r, _ := s.getOperatorAction(old); r == nil || r.Outcome != OperatorActionExpired {
t.Fatalf("old row = %+v, want expired", r)
}
if err := s.PurgeCustomerResetDBState("c1", false); err != nil {
t.Fatal(err)
}
if p, _ := s.PendingOperatorActions("c1"); len(p) != 0 {
t.Fatalf("a RESET left a pending action for the next box: %+v", p)
}
if r, _ := s.getOperatorAction(fresh); r == nil || r.Outcome != OperatorActionCancelled {
t.Fatalf("fresh row after reset = %+v, want cancelled (kept as a record)", r)
}
}
+4
View File
@@ -878,6 +878,10 @@ func (s *Store) migrate() error {
if err := s.backfillAPIKeyHashes(); err != nil && s.logger != nil {
s.logger.Printf("[ERROR] api_key_hash backfill: %v (unhashed rows still match on their plaintext key)", err)
}
// R-314/R-279/R-177 (`09` §3 decision 185): the operator's actions for a box (opactions.go).
if err := s.migrateOperatorActions(); err != nil {
return fmt.Errorf("operator_actions: %w", err)
}
return nil
}
+4
View File
@@ -865,6 +865,10 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
"LogBundles": s.hostLogBundleRows(host),
"CSRFToken": s.getCSRFToken(r),
// Decision 185 (D1): the operator's closed list of actions for this host's controller.
"OperatorActions": s.hostOperatorActionRows(host),
"OperatorJobNames": store.OperatorJobNames(),
"OperatorExtendMaxDay": store.OperatorActionExtendMaxDays,
// v0.47.0 stale host removal: the danger-zone card renders ONLY for non-online
// hosts — an ONLINE host is never deletable (no override exists).
// R-30 slice 2 (D2): an online-by-report host whose box has been unreachable for
+8 -2
View File
@@ -171,8 +171,14 @@ func TestHandleHostDetail(t *testing.T) {
// v0.47.0: this fixture host is ONLINE (report just saved), so the stale-host
// danger-zone card must NOT render for it — this pin now doubles as the
// "delete hidden for online hosts" proof (the stale case: TestHostDetail_DangerCardForStaleOnly).
if got := strings.Count(strings.ToLower(body), "<button"); got != 2 {
t.Errorf("host detail has %d buttons, want exactly the 2 log-request buttons", got)
// Decision 185 (2026-10-08): plus the four operator-action buttons — a closed list, none of which
// deletes or mutates the host (TestOperatorActions_* pin what they may do). Every OTHER button
// remains absent: 2 log requests + 4 operator actions, and the 4 all post to /operator-action.
if got := strings.Count(strings.ToLower(body), "<button"); got != 6 {
t.Errorf("host detail has %d buttons, want exactly the 2 log-request + 4 operator-action buttons", got)
}
if got := strings.Count(body, `action="/hosts/demo-felhom-01/operator-action"`); got != 4 {
t.Errorf("operator-action forms = %d, want 4", got)
}
if strings.Count(body, `action="/hosts/demo-felhom-01/request-logs"`) != 2 {
t.Error("the request-logs forms are missing — every button must be a log-bundle request")
+82
View File
@@ -0,0 +1,82 @@
package web
import (
"net/http"
"strings"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Operator actions (R-314/R-279/R-177, `09` §3 decision 185 — D1). The host page's buttons store a
// pending row (store/opactions.go) and wake the box's wait channel; the box acts on its next report
// reply and answers on the report after that. The list is CLOSED and validated here, before anything
// is stored — an unknown action, job or argument is a 400 and no row.
// opActionsShown is how many rows the host page lists.
const opActionsShown = 10
// operatorActor names who pressed, for the log and the row: the channel and the address. The hub has
// one operator password and no user names, so this is the most it can say — never a credential.
func operatorActor(r *http.Request) string {
channel := "operator CLI (basic auth)"
if _, err := r.Cookie(SessionCookieName); err == nil {
channel = "operator browser session"
}
return channel + " from " + bindClientIP(r)
}
// handleOperatorAction — POST /hosts/{id}/operator-action (form: action, arg).
func (s *Server) handleOperatorAction(w http.ResponseWriter, r *http.Request, hostID string) {
if !s.validateCSRF(r) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
host, err := s.store.GetHost(hostID)
if err != nil || host == nil {
http.NotFound(w, r)
return
}
if host.CustomerID == "" {
http.Error(w, "This host has no customer — there is no controller to act", http.StatusBadRequest)
return
}
action := strings.TrimSpace(r.FormValue("action"))
arg := strings.TrimSpace(r.FormValue("arg"))
if err := store.ValidateOperatorAction(action, arg); err != nil {
s.logger.Printf("[INFO] operator action refused for host %s: %v", hostID, err)
http.Error(w, "Refused: "+err.Error(), http.StatusBadRequest)
return
}
by := operatorActor(r)
id, err := s.store.CreateOperatorAction(host.CustomerID, action, arg, by)
if err != nil {
s.logger.Printf("[ERROR] operator action %s for %s: %v", action, host.CustomerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] operator action #%d %s%s requested for %s (host %s) by %s — the box acts on its next report",
id, action, opArgSuffix(arg), host.CustomerID, hostID, by)
// Direction-2: wake the controller's wait channel so the reply carrying the action comes in seconds.
s.bumpIntent(host.CustomerID)
http.Redirect(w, r, "/hosts/"+hostID+"#operator-actions", http.StatusSeeOther)
}
func opArgSuffix(arg string) string {
if arg == "" {
return ""
}
return " " + arg
}
// hostOperatorActionRows is the host page's list (newest first). nil for a host with no customer.
func (s *Server) hostOperatorActionRows(host *store.Host) []store.OperatorActionRow {
if host.CustomerID == "" {
return nil
}
rows, err := s.store.ListOperatorActions(host.CustomerID, opActionsShown)
if err != nil {
s.logger.Printf("[WARN] operator actions for %s: %v", host.CustomerID, err)
return nil
}
return rows
}
+158
View File
@@ -0,0 +1,158 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// `09` §3 decision 185 (D1). Red test (4) of the design, web half: a host-page POST stores a row and
// bumps the box's intent; an unknown action is refused with no row. Plus a render test per branch of
// the card's template gate (the seam-built-but-never-wired trap covers templates).
func postOperatorAction(t *testing.T, s *Server, hostID string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/hosts/"+hostID+"/operator-action", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Forwarded-For", "10.9.8.7")
// The CLI channel's CSRF pass (validateCSRF): Basic auth + the operator header.
req.SetBasicAuth("", "pw")
req.Header.Set(OperatorCLIHeader, "confirm")
rr := httptest.NewRecorder()
s.handleOperatorAction(rr, req, hostID)
return rr
}
func TestOperatorAction_PostStoresAndBumpsIntent(t *testing.T) {
s, st := newTestServer(t)
hub := intent.New()
s.SetIntentHub(hub)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
before := hub.Generation("c1")
rr := postOperatorAction(t, s, "h1", url.Values{"action": {"abandon_extend"}, "arg": {"14"}})
if rr.Code != http.StatusSeeOther {
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
}
rows, _ := st.ListOperatorActions("c1", 10)
if len(rows) != 1 || rows[0].Action != "abandon_extend" || rows[0].Arg != "14" || rows[0].DoneAt != nil {
t.Fatalf("rows = %+v", rows)
}
if !strings.Contains(rows[0].RequestedBy, "10.9.8.7") {
t.Errorf("requested_by = %q, want the operator's address", rows[0].RequestedBy)
}
if hub.Generation("c1") == before {
t.Error("the box's intent was not bumped — its wait channel would not wake")
}
if p, _ := st.PendingOperatorActions("c1"); len(p) != 1 {
t.Fatalf("the next ACK would list %d action(s), want 1", len(p))
}
}
func TestOperatorAction_UnknownRefusedNothingStored(t *testing.T) {
s, st := newTestServer(t)
hub := intent.New()
s.SetIntentHub(hub)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
before := hub.Generation("c1")
for _, f := range []url.Values{
{"action": {"delete_offsite"}},
{"action": {"run_job"}, "arg": {"offsite-abandon-sweep"}},
{"action": {"abandon_extend"}, "arg": {"45"}},
} {
if rr := postOperatorAction(t, s, "h1", f); rr.Code != http.StatusBadRequest {
t.Errorf("%v: status = %d, want 400", f, rr.Code)
}
}
if rows, _ := st.ListOperatorActions("c1", 10); len(rows) != 0 {
t.Fatalf("a refused press stored %+v", rows)
}
if hub.Generation("c1") != before {
t.Error("a refused press woke the box")
}
// A host with no customer has no controller to act.
if err := st.UpsertHost(&store.Host{HostID: "lonely", APIKey: "k2"}); err != nil {
t.Fatal(err)
}
if rr := postOperatorAction(t, s, "lonely", url.Values{"action": {"abandon_stop"}}); rr.Code != http.StatusBadRequest {
t.Errorf("no-customer host: status = %d, want 400", rr.Code)
}
}
func renderHost(t *testing.T, s *Server, hostID string) string {
t.Helper()
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/"+hostID, nil), hostID)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
return rr.Body.String()
}
// One render per branch: customer + no rows, customer + rows (pending and closed), no customer.
func TestOperatorAction_CardRendersPerBranch(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
body := renderHost(t, s, "h1")
if got := strings.Count(body, `action="/hosts/h1/operator-action"`); got != 4 {
t.Errorf("customer host: %d operator-action forms, want 4", got)
}
for _, want := range []string{`value="offsite_backup_now"`, `value="abandon_stop"`, `value="abandon_extend"`, `value="run_job"`, `<option value="fill-watch">`, `max="30"`, "No operator actions for this box yet."} {
if !strings.Contains(body, want) {
t.Errorf("customer host, no rows: missing %q", want)
}
}
id, _ := st.CreateOperatorAction("c1", "run_job", "offsite-proof", "operator browser session from 10.0.0.1")
_, _ = st.CreateOperatorAction("c1", "abandon_stop", "", "operator browser session from 10.0.0.1")
if _, err := st.RecordOperatorActionResult("c1", id, "refused", "the job offsite-proof was not started"); err != nil {
t.Fatal(err)
}
body = renderHost(t, s, "h1")
for _, want := range []string{"run_job offsite-proof", ">refused<", "the job offsite-proof was not started", ">pending<", "from 10.0.0.1"} {
if !strings.Contains(body, want) {
t.Errorf("customer host, rows: missing %q", want)
}
}
if strings.Contains(body, "No operator actions for this box yet.") {
t.Error("the empty line rendered beside rows")
}
if err := st.UpsertHost(&store.Host{HostID: "lonely", APIKey: "k2"}); err != nil {
t.Fatal(err)
}
body = renderHost(t, s, "lonely")
if strings.Contains(body, "/operator-action") {
t.Error("a host with no customer rendered operator-action buttons")
}
if !strings.Contains(body, "there is no controller to act") {
t.Error("the no-customer branch did not say why there are no buttons")
}
}
func TestOperatorAction_NoCSRFNoRow(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/hosts/h1/operator-action", strings.NewReader("action=abandon_stop"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleOperatorAction(rr, req, "h1")
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rr.Code)
}
if rows, _ := st.ListOperatorActions("c1", 10); len(rows) != 0 {
t.Fatal("a press without CSRF stored a row")
}
}
+8
View File
@@ -537,6 +537,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// Decision 185 (D1): the operator's actions — suffix route BEFORE the bare /hosts/ catch-all.
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/operator-action"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/operator-action")
if r.Method == http.MethodPost {
s.handleOperatorAction(w, r, hostID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
if r.Method == http.MethodPost {
@@ -283,6 +283,80 @@
{{end}}
</section>
<!-- Operator actions (decision 185, R-314/R-279/R-177): a CLOSED list. None deletes data, starts a
countdown or shortens one; the box acts on its next report reply (seconds, at most one cycle). -->
<section class="card" id="operator-actions">
<h2>Operator Actions</h2>
{{if .CustomerID}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
The box acts on its next report reply (usually seconds, at most one report interval) and answers on the
report after that. Each press runs once. Recorded in the box's own log and in Events. Unanswered after a day: expired.
</p>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0;">
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="offsite_backup_now">
<button type="submit" class="btn btn-sm">Run off-site backup now</button>
</form>
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="run_job">
<select name="arg">{{range .OperatorJobNames}}<option value="{{.}}">{{.}}</option>{{end}}</select>
<button type="submit" class="btn btn-sm">Run check now</button>
</form>
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="abandon_stop">
<button type="submit" class="btn btn-sm btn-outline">Stop deletion countdown</button>
</form>
<form method="POST" action="/hosts/{{.HostID}}/operator-action" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="abandon_extend">
<input type="number" name="arg" min="1" max="{{.OperatorExtendMaxDay}}" value="7" style="width: 4.5em; padding: 0.3em 0.5em;" aria-label="days">
<button type="submit" class="btn btn-sm btn-outline">Extend countdown (days from now)</button>
</form>
</div>
{{if .OperatorActions}}
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Action</th>
<th>Requested</th>
<th>By</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{{range .OperatorActions}}
<tr>
<td>{{.ID}}</td>
<td>{{.Action}}{{if .Arg}} {{.Arg}}{{end}}</td>
<td>{{timeAgo .RequestedAt}}</td>
<td>{{.RequestedBy}}</td>
<td>
{{if not .DoneAt}}<span class="badge badge-neutral">pending</span>
{{else if eq .Outcome "done"}}<span class="badge badge-ok">done</span>
{{else if eq .Outcome "failed"}}<span class="badge badge-error">failed</span>
{{else}}<span class="badge badge-warn">{{.Outcome}}</span>{{end}}
{{if .Message}}<span style="color: var(--text-muted); font-size: 0.85rem;">{{.Message}}</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No operator actions for this box yet.</p>
</div>
{{end}}
{{else}}
<div class="empty-state" style="border: none;">
<p>This host has no customer, so there is no controller to act.</p>
</div>
{{end}}
</section>
<!-- Network (v0.85.0): where this box actually is. Addresses come from the agent's
addresses[] (agent >= 0.119.0); the WireGuard row pairs the HUB's allocation with
whether the box confirms holding it, because an allocation alone cannot tell a live
+5
View File
@@ -90,6 +90,10 @@ ROOTS = [
# contract nothing can check.
("hub -> agent (GET /hosts/<id>/escrow/retained)",
"hub", "internal/api", "RetainedEscrowResponse", "agent"),
# Decision 185 (2026-10-08, R-314/R-279/R-177): the operator's actions, one entry of the report
# ACK's operator_actions list. Named type for the same reason as R-311's root above.
("hub -> controller (report ACK, `operator_actions` entry)",
"hub", "internal/store", "OperatorActionDirective", "controller"),
]
# R-315: a root whose receiver decodes it into ONE NAMED MIRROR TYPE gets the stronger check —
@@ -103,6 +107,7 @@ ROOTS = [
MIRRORS = {
"hub -> agent (GET /hosts/<id>/escrow/retained)": ("internal/hub", "RetainedEscrowResponse"),
"hub -> controller (report ACK, `escrow` object)": ("internal/report", "EscrowStatus"),
"hub -> controller (report ACK, `operator_actions` entry)": ("internal/report", "OperatorAction"),
}
# R-444 (2026-10-06): a SUBTREE of a name-checked root whose receiver decodes it into one named mirror type gets the