Files
ferrum/internal/api/settings_system.go
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

137 lines
4.3 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"time"
)
// systemRow is the single (id=1) system_settings row — general operational
// knobs that used to be hardcoded Go constants (a rebuild away from
// changing), same single-row pattern as security_settings/agent_settings.
type systemRow struct {
alertPollSeconds int
corsAllowedOrigins string // comma-separated; empty means CORS stays off
}
func defaultSystemRow() systemRow {
return systemRow{alertPollSeconds: 60}
}
func (s *Server) loadSystemRow(ctx context.Context) (systemRow, error) {
row := defaultSystemRow()
err := s.db.QueryRowContext(ctx, `SELECT alert_poll_seconds, cors_allowed_origins FROM system_settings WHERE id = 1`).
Scan(&row.alertPollSeconds, &row.corsAllowedOrigins)
if err == sql.ErrNoRows {
return defaultSystemRow(), nil
}
if err != nil {
return systemRow{}, err
}
return row, nil
}
func (s *Server) saveSystemRow(ctx context.Context, row systemRow) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO system_settings (id, alert_poll_seconds, cors_allowed_origins, updated_at)
VALUES (1, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET alert_poll_seconds = excluded.alert_poll_seconds, cors_allowed_origins = excluded.cors_allowed_origins, updated_at = excluded.updated_at`,
row.alertPollSeconds, row.corsAllowedOrigins, time.Now().UTC().Format(time.RFC3339))
return err
}
// applySystemRow pushes row into the running alert poller and the live CORS
// origin allow-list. The evaluator push is a no-op if the evaluator hasn't
// started its ticker yet (e.g. mid-bootstrap) — main.go reads
// AlertPollInterval directly to seed that first Run call instead.
func (s *Server) applySystemRow(row systemRow) {
if s.evaluator != nil {
s.evaluator.SetInterval(time.Duration(row.alertPollSeconds) * time.Second)
}
s.SetCORSOrigins(parseCORSOrigins(row.corsAllowedOrigins))
}
func parseCORSOrigins(csv string) []string {
if csv == "" {
return nil
}
parts := strings.Split(csv, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// AlertPollInterval is read once at startup (main.go) to seed the alert
// evaluator's initial ticker, before BootstrapSettings' applySystemRow has
// anything running yet to apply it to.
func (s *Server) AlertPollInterval(ctx context.Context) time.Duration {
row, err := s.loadSystemRow(ctx)
if err != nil {
return time.Duration(defaultSystemRow().alertPollSeconds) * time.Second
}
return time.Duration(row.alertPollSeconds) * time.Second
}
type systemSettingsResponse struct {
AlertPollSeconds int `json:"alertPollSeconds"`
CorsAllowedOrigins string `json:"corsAllowedOrigins"`
}
func toSystemSettingsResponse(row systemRow) systemSettingsResponse {
return systemSettingsResponse{AlertPollSeconds: row.alertPollSeconds, CorsAllowedOrigins: row.corsAllowedOrigins}
}
func (s *Server) getSystemSettings(w http.ResponseWriter, r *http.Request) {
row, err := s.loadSystemRow(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, toSystemSettingsResponse(row))
}
type systemSettingsPatch struct {
AlertPollSeconds *int `json:"alertPollSeconds"`
CorsAllowedOrigins *string `json:"corsAllowedOrigins"`
}
func (s *Server) putSystemSettings(w http.ResponseWriter, r *http.Request) {
var patch systemSettingsPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
return
}
row, err := s.loadSystemRow(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
if patch.AlertPollSeconds != nil {
if *patch.AlertPollSeconds < 10 || *patch.AlertPollSeconds > 3600 {
writeErrorMsg(w, http.StatusBadRequest, "alert poll interval must be between 10 and 3600 seconds")
return
}
row.alertPollSeconds = *patch.AlertPollSeconds
}
if patch.CorsAllowedOrigins != nil {
row.corsAllowedOrigins = strings.TrimSpace(*patch.CorsAllowedOrigins)
}
if err := s.saveSystemRow(r.Context(), row); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.applySystemRow(row)
s.audit(r, "settings.system", "settings", "updated")
writeJSON(w, http.StatusOK, toSystemSettingsResponse(row))
}