Files
Anand 22c1dd382c Add API keys, MCP server, admin AI providers, and a built-in local LLM option
- User-scoped API keys (Profile > API Keys) for 3rd-party REST API access
  and MCP clients, each locked to one scope at creation, with expiry,
  revocation, and last-used tracking.
- A hand-rolled MCP (Model Context Protocol) server exposing the fleet
  (connections, nodes, guests, storage, pools, alerts, cluster status) as
  read tools plus one admin-gated power-action tool, so Claude Code/Desktop
  or any other MCP client can query and operate the fleet directly.
- Both the REST API and MCP are off by default and toggleable instance-wide
  from Settings > API & MCP, enforced live on every request.
- Admin-managed AI providers (any OpenAI-chat-completions-compatible
  endpoint) backing the AI Assistant's tool-calling loop, replacing the
  single hardcoded provider.
- A built-in, zero-config, no-API-key local provider backed by Needle 2
  (internal/needle) for fully offline tool-calling, wired in as a one-click
  preset. Requires the operator to separately download the Needle 2 binary
  and point FERRUM_NEEDLE_BIN at it -- Ferrum never fetches executable
  content from the network itself; see README "Built-in LLM (Needle 2)".
- System settings (CORS allow-list, instance-wide toggles) moved to the
  admin Settings UI; environment variables are now scoped to true
  bootstrap-level config only (listen address, TLS, DB connection, secret,
  optional Needle binary path).
- Fixed: node Journal tab 502'ing with "unexpected end of JSON input" on an
  empty response, and separately with a decode error on PVE versions that
  return a bare-string journal line instead of the documented {n,t} object.
- Fixed: bottom content padding disappearing on every page except the AI
  Assistant (an unconditional h-full on the content wrapper let overflowing
  content bleed through where the padding should render).
- Fixed: Profile page felt cramped despite a wide viewport (stray max-w-2xl
  cap not present on the equivalent Settings page).
- Test coverage added for the previously-untested MCP package and the new
  Needle adapter (20 new Go tests), plus a regression test for the journal
  decode fix.
2026-09-06 13:26:30 +05:30

336 lines
11 KiB
Go

package api
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// A user can keep several named dashboards (e.g. "Ops overview", "Capacity
// planning") instead of one shared layout — each is a fully independent
// widget grid, all under this same per-user table. defaultDashboardLayout
// seeds a brand-new account's first one.
// layoutVersion 2 halved the grid's row unit (64px rows -> 32px) so resizing
// snaps in finer steps; every v1 height/y is exactly double in v2. The client
// migrates any layout it reads below this version.
const layoutVersion = 2
const defaultDashboardLayout = `{
"version": 2,
"widgets": [
{"id": "fleet-overview", "type": "fleet-overview", "x": 0, "y": 0, "w": 12, "h": 8},
{"id": "fleet-trend", "type": "fleet-trend", "x": 0, "y": 8, "w": 12, "h": 10},
{"id": "cpu-by-node", "type": "cpu-by-node", "x": 0, "y": 18, "w": 6, "h": 8},
{"id": "memory-by-node", "type": "memory-by-node", "x": 6, "y": 18, "w": 6, "h": 8},
{"id": "top-consumers", "type": "top-consumers", "x": 0, "y": 26, "w": 6, "h": 8},
{"id": "running-tasks", "type": "running-tasks", "x": 6, "y": 26, "w": 6, "h": 8},
{"id": "storage-usage", "type": "storage-usage", "x": 0, "y": 34, "w": 6, "h": 8},
{"id": "alert-activity", "type": "alert-activity", "x": 6, "y": 34, "w": 6, "h": 8}
]
}`
const emptyDashboardLayout = `{"version": 2, "widgets": []}`
// Bounds how many dashboards one account can hoard — this is a personal
// workspace list, not a multi-tenant resource, so a generous flat cap is
// enough to stop runaway scripting without ever bothering a real user.
const maxDashboardsPerUser = 30
const maxDashboardNameLen = 60
type dashboardSummary struct {
ID string `json:"id"`
Name string `json:"name"`
UpdatedAt string `json:"updatedAt"`
}
type dashboardDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Layout json.RawMessage `json:"-"`
}
// MarshalJSON flattens the stored {"widgets": [...]} layout up into the
// dashboard's own object, so the client sees {id, name, widgets} instead of
// a nested layout object it would have to unwrap.
func (d dashboardDTO) MarshalJSON() ([]byte, error) {
var widgets json.RawMessage
var parsed storedLayout
if err := json.Unmarshal(d.Layout, &parsed); err == nil {
if b, err := json.Marshal(parsed.Widgets); err == nil {
widgets = b
}
}
if widgets == nil {
widgets = json.RawMessage("[]")
}
return json.Marshal(struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
Widgets json.RawMessage `json:"widgets"`
}{d.ID, d.Name, parsed.Version, widgets})
}
func (s *Server) insertDashboard(w http.ResponseWriter, r *http.Request, userID, name string, layout []byte) (dashboardSummary, bool) {
id := uuid.NewString()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.ExecContext(r.Context(),
`INSERT INTO dashboards (id, user_id, name, layout, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
id, userID, name, string(layout), now, now,
); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return dashboardSummary{}, false
}
return dashboardSummary{ID: id, Name: name, UpdatedAt: now}, true
}
// listDashboards returns every dashboard the user owns, oldest first — the
// oldest is the fallback a client uses when no active dashboard is set (or
// the stored one was deleted elsewhere). A brand-new account gets its "Main"
// dashboard lazily, here, rather than at signup.
func (s *Server) listDashboards(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r)
rows, err := s.db.QueryContext(r.Context(), `SELECT id, name, updated_at FROM dashboards WHERE user_id = ? ORDER BY created_at ASC`, u.ID)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
out := []dashboardSummary{}
for rows.Next() {
var d dashboardSummary
if err := rows.Scan(&d.ID, &d.Name, &d.UpdatedAt); err != nil {
rows.Close()
s.writeError(w, http.StatusInternalServerError, err)
return
}
out = append(out, d)
}
rows.Close()
if len(out) == 0 {
seeded, ok := s.insertDashboard(w, r, u.ID, "Main", []byte(defaultDashboardLayout))
if !ok {
return
}
out = append(out, seeded)
}
writeJSON(w, http.StatusOK, out)
}
type createDashboardRequest struct {
Name string `json:"name"`
}
func (s *Server) createDashboard(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r)
var req createDashboardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeErrorMsg(w, http.StatusBadRequest, "name is required")
return
}
if len(name) > maxDashboardNameLen {
writeErrorMsg(w, http.StatusBadRequest, fmt.Sprintf("name must be %d characters or fewer", maxDashboardNameLen))
return
}
var count int
if err := s.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM dashboards WHERE user_id = ?`, u.ID).Scan(&count); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
if count >= maxDashboardsPerUser {
writeErrorMsg(w, http.StatusBadRequest, fmt.Sprintf("you already have %d dashboards — remove one before adding another", maxDashboardsPerUser))
return
}
created, ok := s.insertDashboard(w, r, u.ID, name, []byte(emptyDashboardLayout))
if !ok {
return
}
s.audit(r, "dashboard.create", "dashboard", name)
writeJSON(w, http.StatusCreated, dashboardDTO{ID: created.ID, Name: name, Layout: json.RawMessage(emptyDashboardLayout)})
}
// dashboardOwnedByUser loads a dashboard's name + layout, scoped to the
// requesting user — every dashboard endpoint below routes through this so a
// user can never read, rename, resize or delete another account's dashboard
// by guessing an id.
func (s *Server) dashboardOwnedByUser(w http.ResponseWriter, r *http.Request) (name string, layout []byte, ok bool) {
u := userFromContext(r)
id := chi.URLParam(r, "id")
var lay string
err := s.db.QueryRowContext(r.Context(), `SELECT name, layout FROM dashboards WHERE id = ? AND user_id = ?`, id, u.ID).Scan(&name, &lay)
if errors.Is(err, sql.ErrNoRows) {
writeErrorMsg(w, http.StatusNotFound, "dashboard not found")
return "", nil, false
}
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return "", nil, false
}
return name, []byte(lay), true
}
func (s *Server) getDashboard(w http.ResponseWriter, r *http.Request) {
name, layout, ok := s.dashboardOwnedByUser(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, dashboardDTO{ID: chi.URLParam(r, "id"), Name: name, Layout: layout})
}
type updateDashboardRequest struct {
Name *string `json:"name"`
Widgets json.RawMessage `json:"widgets"`
}
func (s *Server) updateDashboard(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r)
id := chi.URLParam(r, "id")
currentName, currentLayout, ok := s.dashboardOwnedByUser(w, r)
if !ok {
return
}
var req updateDashboardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
name := currentName
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
if name == "" {
writeErrorMsg(w, http.StatusBadRequest, "name is required")
return
}
if len(name) > maxDashboardNameLen {
writeErrorMsg(w, http.StatusBadRequest, fmt.Sprintf("name must be %d characters or fewer", maxDashboardNameLen))
return
}
}
layout := currentLayout
if req.Widgets != nil {
clean, err := parseDashboardLayout([]byte(fmt.Sprintf(`{"widgets":%s}`, req.Widgets)))
if err != nil {
writeErrorMsg(w, http.StatusBadRequest, err.Error())
return
}
layout = clean
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.ExecContext(r.Context(),
`UPDATE dashboards SET name = ?, layout = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
name, string(layout), now, id, u.ID,
); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
if req.Name != nil && name != currentName {
s.audit(r, "dashboard.rename", "dashboard", currentName+" -> "+name)
}
writeJSON(w, http.StatusOK, dashboardDTO{ID: id, Name: name, Layout: layout})
}
func (s *Server) deleteDashboard(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r)
id := chi.URLParam(r, "id")
name, _, ok := s.dashboardOwnedByUser(w, r)
if !ok {
return
}
var count int
if err := s.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM dashboards WHERE user_id = ?`, u.ID).Scan(&count); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
if count <= 1 {
writeErrorMsg(w, http.StatusBadRequest, "can't delete your only dashboard")
return
}
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM dashboards WHERE id = ? AND user_id = ?`, id, u.ID); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
// If this was the account's active dashboard, clear the pointer so the
// next load falls back to the oldest remaining one instead of a 404.
if _, err := s.db.ExecContext(r.Context(),
`UPDATE user_preferences SET active_dashboard_id = NULL WHERE user_id = ? AND active_dashboard_id = ?`, u.ID, id,
); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "dashboard.delete", "dashboard", name)
w.WriteHeader(http.StatusNoContent)
}
type storedWidget struct {
ID string `json:"id"`
Type string `json:"type"`
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
// Per-widget settings (connection filter, row counts, ...) round-trip as
// an opaque string map — without this field the re-marshal below would
// silently drop them on every save.
Settings map[string]string `json:"settings,omitempty"`
}
type storedLayout struct {
// Absent (0) means a pre-versioning v1 layout; see layoutVersion.
Version int `json:"version,omitempty"`
Widgets []storedWidget `json:"widgets"`
}
// parseDashboardLayout validates a client-submitted layout: an object with a
// widgets array of bounded, non-negative grid entries. Storing raw JSON
// would let any authenticated user park arbitrary nested structures in the
// database on every save.
func parseDashboardLayout(body []byte) ([]byte, error) {
var layout storedLayout
dec := json.NewDecoder(bytes.NewReader(body))
if err := dec.Decode(&layout); err != nil {
return nil, fmt.Errorf("layout must be a JSON object with a widgets array")
}
if len(layout.Widgets) > 100 {
return nil, fmt.Errorf("too many widgets (max 100)")
}
for _, wgt := range layout.Widgets {
if wgt.ID == "" || wgt.Type == "" {
return nil, fmt.Errorf("every widget needs an id and a type")
}
if len(wgt.Settings) > 20 {
return nil, fmt.Errorf("widget %q has too many settings", wgt.ID)
}
if wgt.X < 0 || wgt.Y < 0 || wgt.W <= 0 || wgt.H <= 0 || wgt.W > 12 || wgt.H > 80 || wgt.X > 12 || wgt.Y > 1000 {
return nil, fmt.Errorf("widget %q has out-of-bounds grid coordinates", wgt.ID)
}
}
clean, err := json.Marshal(layout)
if err != nil {
return nil, err
}
return clean, nil
}