154 lines
6.0 KiB
Go
154 lines
6.0 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"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 {
|
|
if t := requestToken(r); t != nil { // 2026-10-29: a token press was recorded as „basic auth" — name the token
|
|
return fmt.Sprintf("access token %q from %s", t.Name, bindClientIP(r))
|
|
}
|
|
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"))
|
|
// The AGENT's closed list (2026-10-29): its own channel, the host report's reply.
|
|
if store.IsHostOperatorAction(action) {
|
|
s.handleHostOperatorAction(w, r, host, action, arg)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
// D64 = B: Clear hold only for an app the box reports held now — a stale page or a typo stores nothing.
|
|
if action == store.OperatorActionClearHold && !s.customerHoldsApp(host.CustomerID, arg) {
|
|
s.logger.Printf("[INFO] clear_hold refused for host %s: %s is not held on this box", hostID, arg)
|
|
http.Error(w, "Refused: "+arg+" is not held on this box", 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
|
|
}
|
|
|
|
// customerHoldsApp reports whether the box's held-apps mirror lists the app now.
|
|
func (s *Server) customerHoldsApp(customerID, app string) bool {
|
|
held, err := s.store.ListHeldApps(customerID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, h := range held {
|
|
if h.Stack == app {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// handleHostOperatorAction stores an AGENT row (the agent's closed list) and pokes the box, so its out-of-cycle
|
|
// report carries the action in seconds; the result comes on the report after (the agent requests it at once).
|
|
// restart_controller names a guest of THIS host — another vmid is refused before anything is stored.
|
|
func (s *Server) handleHostOperatorAction(w http.ResponseWriter, r *http.Request, host *store.Host, action, arg string) {
|
|
if err := store.ValidateHostOperatorAction(action, arg); err != nil {
|
|
s.logger.Printf("[INFO] host operator action refused for host %s: %v", host.HostID, err)
|
|
http.Error(w, "Refused: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !s.hostHasGuest(host.HostID, arg) {
|
|
s.logger.Printf("[INFO] host operator action %s refused for host %s: guest %s is not on this host", action, host.HostID, arg)
|
|
http.Error(w, "Refused: guest "+arg+" is not on this host", http.StatusBadRequest)
|
|
return
|
|
}
|
|
by := operatorActor(r)
|
|
id, err := s.store.CreateHostOperatorAction(host.CustomerID, host.HostID, action, arg, by)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] host operator action %s for %s: %v", action, host.HostID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] host operator action #%d %s%s requested for host %s by %s — the agent acts on its next report (poked now)",
|
|
id, action, opArgSuffix(arg), host.HostID, by)
|
|
s.poke.PokeHost(host.HostID) // nil-safe; without the tunnel the 15-min cycle carries it
|
|
http.Redirect(w, r, "/hosts/"+host.HostID+"#operator-actions", http.StatusSeeOther)
|
|
}
|
|
|
|
// hostHasGuest reports whether vmid is one of the host's guests as its reports name them.
|
|
func (s *Server) hostHasGuest(hostID, vmid string) bool {
|
|
guests, err := s.store.ListGuestsForHost(hostID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, g := range guests {
|
|
if strconv.Itoa(g.VMID) == vmid {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|