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
+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