mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
22c1dd382c
- 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.
137 lines
4.3 KiB
Go
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))
|
|
}
|