394 lines
14 KiB
Go
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
|
|
}
|