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

160 lines
4.7 KiB
Go

package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
)
// HA groups (PVE 8) — create/update/delete. Listing is haGroups in cluster.go.
type haGroupRequest struct {
Group string `json:"group"`
Nodes string `json:"nodes"`
Restricted bool `json:"restricted,omitempty"`
Nofailback bool `json:"nofailback,omitempty"`
Comment string `json:"comment,omitempty"`
}
func (s *Server) createHAGroup(w http.ResponseWriter, r *http.Request) {
var req haGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Group == "" || req.Nodes == "" {
writeErrorMsg(w, http.StatusBadRequest, "group and nodes are required")
return
}
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
if err := client.CreateHAGroup(r.Context(), req.Group, req.Nodes, req.Restricted, req.Nofailback, req.Comment); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ha.group.create", "ha", req.Group)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) updateHAGroup(w http.ResponseWriter, r *http.Request) {
var req struct {
Options map[string]string `json:"options"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
group := chi.URLParam(r, "group")
if err := client.UpdateHAGroup(r.Context(), group, req.Options); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ha.group.update", "ha", group)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) deleteHAGroup(w http.ResponseWriter, r *http.Request) {
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
group := chi.URLParam(r, "group")
if err := client.DeleteHAGroup(r.Context(), group); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ha.group.delete", "ha", group)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// HA rules (PVE 9) — full CRUD, /cluster/ha/rules.
func (s *Server) listHARules(w http.ResponseWriter, r *http.Request) {
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
rules, err := client.HARules(r.Context())
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, rules)
}
type haRuleRequest struct {
Rule string `json:"rule"`
Options map[string]string `json:"options"`
}
func (s *Server) createHARule(w http.ResponseWriter, r *http.Request) {
var req haRuleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Rule == "" || req.Options["type"] == "" {
writeErrorMsg(w, http.StatusBadRequest, "rule and options.type are required")
return
}
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
if err := client.CreateHARule(r.Context(), req.Rule, req.Options); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ha.rule.create", "ha", req.Rule)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) updateHARule(w http.ResponseWriter, r *http.Request) {
var req struct {
Options map[string]string `json:"options"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
id := chi.URLParam(r, "ruleId")
if err := client.UpdateHARule(r.Context(), id, req.Options); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ha.rule.update", "ha", id)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) deleteHARule(w http.ResponseWriter, r *http.Request) {
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
id := chi.URLParam(r, "ruleId")
if err := client.DeleteHARule(r.Context(), id); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ha.rule.delete", "ha", id)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}