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 }