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.
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"ferrum/internal/auth"
|
|
)
|
|
|
|
// createAPIKeyRequest is the body for POST /auth/apikeys. ExpiresInDays of 0
|
|
// (or omitted) means the key never expires. Scope defaults to "api" — pass
|
|
// "mcp" only when the key is meant for an MCP client (Claude, etc.); it will
|
|
// then work ONLY against /mcp, never the general REST API, and only when an
|
|
// admin has enabled MCP (see agent_settings / GET /admin/settings/agent).
|
|
type createAPIKeyRequest struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope,omitempty"`
|
|
ExpiresInDays int `json:"expiresInDays,omitempty"`
|
|
}
|
|
|
|
type createAPIKeyResponse struct {
|
|
auth.APIKey
|
|
Key string `json:"key"` // plaintext — shown once, never retrievable again
|
|
}
|
|
|
|
func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|
u := userFromContext(r)
|
|
keys, err := s.auth.ListAPIKeys(r.Context(), u.ID)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, keys)
|
|
}
|
|
|
|
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
var req createAPIKeyRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if req.ExpiresInDays < 0 {
|
|
writeErrorMsg(w, http.StatusBadRequest, "expiresInDays must not be negative")
|
|
return
|
|
}
|
|
scope := req.Scope
|
|
if scope == "" {
|
|
scope = auth.ScopeAPI
|
|
}
|
|
if scope != auth.ScopeAPI && scope != auth.ScopeMCP {
|
|
writeErrorMsg(w, http.StatusBadRequest, `scope must be "api" or "mcp"`)
|
|
return
|
|
}
|
|
if scope == auth.ScopeMCP || scope == auth.ScopeAPI {
|
|
agentSettings, err := s.loadAgentSettings(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if scope == auth.ScopeMCP && !agentSettings.mcpEnabled {
|
|
writeErrorCode(w, http.StatusForbidden, "mcp_disabled", "MCP is disabled for this Ferrum instance — ask an admin to enable it in Settings before creating an MCP token")
|
|
return
|
|
}
|
|
if scope == auth.ScopeAPI && !agentSettings.apiEnabled {
|
|
writeErrorCode(w, http.StatusForbidden, "api_disabled", "The REST API is disabled for this Ferrum instance — ask an admin to enable it in Settings before creating an API token")
|
|
return
|
|
}
|
|
}
|
|
|
|
u := userFromContext(r)
|
|
plainKey, key, err := s.auth.CreateAPIKey(r.Context(), u.ID, req.Name, scope, req.ExpiresInDays)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
s.audit(r, "apikey.create", "security", key.Name+" ("+scope+")")
|
|
writeJSON(w, http.StatusCreated, createAPIKeyResponse{APIKey: *key, Key: plainKey})
|
|
}
|
|
|
|
func (s *Server) revokeAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
u := userFromContext(r)
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.auth.RevokeAPIKey(r.Context(), u.ID, id); err != nil {
|
|
if err == auth.ErrAPIKeyNotFound {
|
|
writeErrorMsg(w, http.StatusNotFound, "api key not found")
|
|
return
|
|
}
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
s.audit(r, "apikey.revoke", "security", id)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"revoked": true})
|
|
}
|