Files
felhom.eu/hub/internal/web/opactions.go
T
admin 87af859fc3 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
2026-10-08 15:17:06 +02:00

83 lines
3.0 KiB
Go

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
}