102 lines
3.5 KiB
Go
102 lines
3.5 KiB
Go
// Package modulecontrol lets core restart this process or flip it into
|
|
// maintenance mode — the module side of core's internal/registry
|
|
// Restart/SetMaintenance. Authenticated with a shared secret (X-Control-Token,
|
|
// the CORE_CONTROL_TOKEN env var issued once when this module was
|
|
// registered with core), never a staff JWT — core is the only caller.
|
|
//
|
|
// Both actions are self-contained to this process on purpose: Restart exits
|
|
// the process and relies on docker-compose's own "restart: unless-stopped"
|
|
// policy to bring it back, rather than this module (or core) touching
|
|
// Docker directly. Maintenance mode is an in-memory flag, not persisted —
|
|
// reset to off on every process start, so a crash or an unrelated restart
|
|
// can never leave the module stuck refusing traffic with nobody around to
|
|
// remember why.
|
|
package modulecontrol
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type Handler struct {
|
|
controlToken string
|
|
maintenance atomic.Bool
|
|
}
|
|
|
|
// NewHandler reads CORE_CONTROL_TOKEN itself — an empty token means every
|
|
// call to Restart/SetMaintenance is rejected (authOK never succeeds against
|
|
// an empty stored secret), so control is off by default until an owner
|
|
// actually sets one via core's module registration.
|
|
func NewHandler() *Handler {
|
|
return &Handler{controlToken: os.Getenv("CORE_CONTROL_TOKEN")}
|
|
}
|
|
|
|
func (h *Handler) authOK(c *fiber.Ctx) bool {
|
|
if h.controlToken == "" {
|
|
return false
|
|
}
|
|
given := c.Get("X-Control-Token")
|
|
return subtle.ConstantTimeCompare([]byte(given), []byte(h.controlToken)) == 1
|
|
}
|
|
|
|
// Restart responds first, then exits after a short delay so the response
|
|
// actually reaches core before the process is gone.
|
|
func (h *Handler) Restart(c *fiber.Ctx) error {
|
|
if !h.authOK(c) {
|
|
return c.Status(401).JSON(fiber.Map{"error": "invalid control token"})
|
|
}
|
|
log.Printf("modulecontrol: restart requested by core")
|
|
go func() {
|
|
time.Sleep(200 * time.Millisecond)
|
|
os.Exit(0)
|
|
}()
|
|
return c.JSON(fiber.Map{"ok": true, "restarting": true})
|
|
}
|
|
|
|
func (h *Handler) SetMaintenance(c *fiber.Ctx) error {
|
|
if !h.authOK(c) {
|
|
return c.Status(401).JSON(fiber.Map{"error": "invalid control token"})
|
|
}
|
|
var body struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
h.maintenance.Store(body.Enabled)
|
|
log.Printf("modulecontrol: maintenance mode set to %v by core", body.Enabled)
|
|
return c.JSON(fiber.Map{"ok": true, "maintenance": body.Enabled})
|
|
}
|
|
|
|
// Maintenance reports the current in-memory flag — read by both the
|
|
// blocking middleware below and coreclient's heartbeat sender (so the
|
|
// dashboard shows "maintenance" instead of a misleading "healthy" while
|
|
// it's on).
|
|
func (h *Handler) Maintenance() bool {
|
|
return h.maintenance.Load()
|
|
}
|
|
|
|
// Middleware blocks every route with 503 while maintenance mode is on,
|
|
// except /health (so core/an operator can still see the process is alive,
|
|
// just deliberately not serving) and the control routes themselves (so
|
|
// maintenance mode can always be turned back off — a maintenance flag that
|
|
// blocks its own toggle would be a one-way door).
|
|
func (h *Handler) Middleware() fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
if !h.maintenance.Load() {
|
|
return c.Next()
|
|
}
|
|
path := c.Path()
|
|
if path == "/health" || strings.HasPrefix(path, "/api/module-control/") {
|
|
return c.Next()
|
|
}
|
|
return c.Status(503).JSON(fiber.Map{"error": "module is in maintenance mode"})
|
|
}
|
|
}
|