Files
aura-crm/core/backend/internal/registry/handler.go
T

394 lines
14 KiB
Go

// Package registry is core's module directory: other services (production,
// site, online-store, ...) are registered here by an owner, and report their
// own health back via heartbeat — that direction stays push-based, not
// polled, since a module may run on a network core can't reach at all (e.g.
// a different host, like online-store).
//
// Restart/SetMaintenance are the one place core DOES reach out to a module
// (an HTTP call to its own base_url) — only for modules actually reachable
// from core's own network (production/site, on the shared platform_net
// Docker network). Every such call is short-timeout and its failure maps to
// a clean 4xx/5xx response; an unreachable or slow module must never hang
// or crash core's own request handling.
package registry
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// controlClient is reused across Restart/SetMaintenance — a short timeout so
// one unreachable module (network partition, a module still booting, the
// placeholder online-store URL that resolves nowhere) can't tie up a core
// request goroutine any longer than this.
var controlClient = &http.Client{Timeout: 5 * time.Second}
type Handler struct {
db *pgxpool.Pool
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{db: db}
}
// validatePublicURL only checks scheme+host (unlike production's
// validateTGAPIBaseURL, there's no fixed host allowlist to check against —
// public_url is meant to hold whatever real domain the owner is pointing
// this module at, which by definition core can't know in advance).
func validatePublicURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return errInvalidPublicURL
}
if u.Scheme != "http" && u.Scheme != "https" {
return errInvalidPublicURL
}
return nil
}
var errInvalidPublicURL = fmt.Errorf("public_url must be a valid absolute http(s) URL")
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// Create registers a new module and returns two tokens, both shown once —
// the heartbeat token (never recoverable afterwards, only its sha256 hash
// is stored — module -> core direction) and the control token (stored
// as-is, since core must present it again on every Restart/SetMaintenance
// call — core -> module direction; see migrations/003_module_control.sql
// for why storing this one in plaintext is an accepted, narrowly-scoped
// tradeoff). Owner-only.
func (h *Handler) Create(c *fiber.Ctx) error {
var body struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
HealthPath string `json:"health_path"`
Metadata json.RawMessage `json:"metadata"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.Name == "" || body.BaseURL == "" {
return c.Status(400).JSON(fiber.Map{"error": "name and base_url are required"})
}
if body.HealthPath == "" {
body.HealthPath = "/health"
}
if len(body.Metadata) == 0 {
body.Metadata = json.RawMessage("{}")
}
token, err := generateToken()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
controlToken, err := generateToken()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
var id string
err = h.db.QueryRow(context.Background(),
`INSERT INTO modules (name, base_url, health_path, token_hash, control_token, metadata)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
body.Name, body.BaseURL, body.HealthPath, hashToken(token), controlToken, body.Metadata,
).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
return c.Status(409).JSON(fiber.Map{"error": "module with this name already registered"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.Status(201).JSON(fiber.Map{
"id": id,
"name": body.Name,
"token": token, // put it in the module's MODULE_TOKEN env var
"control_token": controlToken, // put it in the module's CORE_CONTROL_TOKEN env var
})
}
// Update sets a module's public_url — the address the "Перейти" button on
// the Модули page opens, separate from base_url (heartbeat/restart/
// maintenance, never browser-facing). The only field editable after
// registration for now; base_url/health_path changes go through
// delete-and-re-register like every other field already did before this
// endpoint existed. Owner-only.
func (h *Handler) Update(c *fiber.Ctx) error {
name := c.Params("name")
var body struct {
PublicURL *string `json:"public_url"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.PublicURL != nil && *body.PublicURL != "" {
if err := validatePublicURL(*body.PublicURL); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
}
// COALESCE semantics match production/internal/settings.Update: an
// omitted field (Go nil, SQL NULL) leaves the column untouched; an
// explicit "" writes the empty string, which is how the frontend clears
// it back to unset.
tag, err := h.db.Exec(context.Background(),
`UPDATE modules SET public_url = COALESCE($1, public_url) WHERE name = $2`,
body.PublicURL, name)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if tag.RowsAffected() == 0 {
return c.Status(404).JSON(fiber.Map{"error": "module not found"})
}
return c.JSON(fiber.Map{"ok": true})
}
// List returns all registered modules with their last known status —
// neither token is included, matching Create's own "shown once" convention.
// Owner-only.
func (h *Handler) List(c *fiber.Ctx) error {
rows, err := h.db.Query(context.Background(),
`SELECT id, name, base_url, health_path, public_url, status, metadata, registered_at, last_heartbeat_at
FROM modules ORDER BY name`)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
type moduleRow struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
HealthPath string `json:"health_path"`
PublicURL *string `json:"public_url"`
Status string `json:"status"`
Metadata json.RawMessage `json:"metadata"`
RegisteredAt time.Time `json:"registered_at"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
}
var out []moduleRow
for rows.Next() {
var r moduleRow
if err := rows.Scan(&r.ID, &r.Name, &r.BaseURL, &r.HealthPath, &r.PublicURL, &r.Status, &r.Metadata, &r.RegisteredAt, &r.LastHeartbeatAt); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, r)
}
return c.JSON(out)
}
// Delete revokes a module's registration. Owner-only.
func (h *Handler) Delete(c *fiber.Ctx) error {
name := c.Params("name")
tag, err := h.db.Exec(context.Background(), `DELETE FROM modules WHERE name = $1`, name)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if tag.RowsAffected() == 0 {
return c.Status(404).JSON(fiber.Map{"error": "module not found"})
}
return c.JSON(fiber.Map{"ok": true})
}
// Heartbeat lets a module report its own health. Authenticated with the
// module's own token (X-Module-Token), not a staff JWT.
func (h *Handler) Heartbeat(c *fiber.Ctx) error {
name := c.Params("name")
token := c.Get("X-Module-Token")
if token == "" {
return c.Status(401).JSON(fiber.Map{"error": "missing X-Module-Token header"})
}
var storedHash string
err := h.db.QueryRow(context.Background(), `SELECT token_hash FROM modules WHERE name = $1`, name).Scan(&storedHash)
if err != nil {
if err == pgx.ErrNoRows {
return c.Status(404).JSON(fiber.Map{"error": "module not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if subtle.ConstantTimeCompare([]byte(hashToken(token)), []byte(storedHash)) != 1 {
return c.Status(401).JSON(fiber.Map{"error": "invalid module token"})
}
var body struct {
Status string `json:"status"`
}
_ = c.BodyParser(&body)
switch body.Status {
case "healthy", "unhealthy", "maintenance":
default:
body.Status = "healthy"
}
_, err = h.db.Exec(context.Background(),
`UPDATE modules SET status = $1, last_heartbeat_at = NOW() WHERE name = $2`,
body.Status, name,
)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{"ok": true})
}
// moduleTarget fetches what Restart/SetMaintenance need to call out to a
// module — both return the same shape of error (404 vs 500) their two
// callers already need.
func (h *Handler) moduleTarget(ctx context.Context, name string) (baseURL, controlToken string, err error) {
// control_token is NULL for any module registered before this feature
// existed — pgx panics scanning SQL NULL into a plain (non-pointer) Go
// string, same gotcha this codebase has hit before (see
// production's internal/settings.Fetch) — COALESCE keeps this a normal
// empty string, which the callers below already treat as "needs
// re-registration" rather than a scan failure.
err = h.db.QueryRow(ctx, `SELECT base_url, COALESCE(control_token, '') FROM modules WHERE name = $1`, name).
Scan(&baseURL, &controlToken)
return baseURL, controlToken, err
}
// callModule POSTs an empty-or-JSON body to path on the module and returns
// its parsed JSON response — the one helper Restart/SetMaintenance share,
// since both are "authenticate with X-Control-Token, POST, report what came
// back" with nothing else different between them.
func callModule(ctx context.Context, baseURL, controlToken, path string, body []byte) (int, map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Control-Token", controlToken)
resp, err := controlClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var parsed map[string]any
_ = json.Unmarshal(data, &parsed)
return resp.StatusCode, parsed, nil
}
// moduleErrorResponse maps a lookup/network failure from moduleTarget/
// callModule to the right client-facing status — 404 for an unregistered
// module, 502 for anything else (unreachable module, timeout, no
// control_token yet on a module registered before this feature existed).
func moduleErrorResponse(c *fiber.Ctx, err error) error {
if err == pgx.ErrNoRows {
return c.Status(404).JSON(fiber.Map{"error": "module not found"})
}
return c.Status(502).JSON(fiber.Map{"error": "could not reach module: " + err.Error()})
}
// Restart tells a module to restart its own process — the module exits
// itself (os.Exit) and relies on its own docker-compose "restart:
// unless-stopped" policy to come back up; core never touches Docker
// directly, only this one HTTP call. Owner-only.
func (h *Handler) Restart(c *fiber.Ctx) error {
name := c.Params("name")
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
baseURL, controlToken, err := h.moduleTarget(ctx, name)
if err != nil {
return moduleErrorResponse(c, err)
}
if controlToken == "" {
return c.Status(409).JSON(fiber.Map{"error": "this module was registered before restart/maintenance support existed — delete and re-register it to get a control token"})
}
status, _, err := callModule(ctx, baseURL, controlToken, "/api/module-control/restart", nil)
if err != nil {
return moduleErrorResponse(c, err)
}
if status >= 300 {
return c.Status(502).JSON(fiber.Map{"error": "module rejected the restart request"})
}
return c.JSON(fiber.Map{"ok": true, "restarting": true})
}
// SetMaintenance flips a module's maintenance-mode flag — while on, the
// module itself answers every route except /health with 503, so the
// module's actual behavior (not just its registry entry) reflects the
// toggle. Owner-only.
func (h *Handler) SetMaintenance(c *fiber.Ctx) error {
name := c.Params("name")
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"})
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
baseURL, controlToken, err := h.moduleTarget(ctx, name)
if err != nil {
return moduleErrorResponse(c, err)
}
if controlToken == "" {
return c.Status(409).JSON(fiber.Map{"error": "this module was registered before restart/maintenance support existed — delete and re-register it to get a control token"})
}
reqBody, _ := json.Marshal(map[string]bool{"enabled": body.Enabled})
status, _, err := callModule(ctx, baseURL, controlToken, "/api/module-control/maintenance", reqBody)
if err != nil {
return moduleErrorResponse(c, err)
}
if status >= 300 {
return c.Status(502).JSON(fiber.Map{"error": "module rejected the maintenance-mode request"})
}
// The module's own next heartbeat will also report "maintenance", but
// updating here too means the dashboard reflects the change immediately
// instead of waiting up to one heartbeat interval.
newStatus := "healthy"
if body.Enabled {
newStatus = "maintenance"
}
_, _ = h.db.Exec(ctx, `UPDATE modules SET status = $1 WHERE name = $2`, newStatus, name)
return c.JSON(fiber.Map{"ok": true, "maintenance": body.Enabled})
}
func isUniqueViolation(err error) bool {
return err != nil && contains(err.Error(), "duplicate key value")
}
func contains(s, substr string) bool {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}