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.
344 lines
10 KiB
Go
344 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// Provisioning raw disks into usable storage (dir/LVM/LVM-thin/ZFS) and
|
|
// discovering NFS/CIFS/iSCSI/LVM/ZFS/GlusterFS resources before registering
|
|
// them — the two gaps left by disks.go (view-only) and storage.go (register
|
|
// an already-known backend). Every mutating route here is destructive
|
|
// (wipes a disk, creates a pool) and relies on requireAdminForMutations,
|
|
// already mounted on the /connections group in server.go.
|
|
|
|
// --- Disk provisioning ---
|
|
|
|
func (s *Server) nodeZFSPools(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
|
|
}
|
|
pools, err := client.NodeZFSPools(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, pools)
|
|
}
|
|
|
|
func (s *Server) nodeLVMGroups(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
|
|
}
|
|
groups, err := client.NodeLVMVolumeGroups(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, groups)
|
|
}
|
|
|
|
func (s *Server) nodeLVMThinPools(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
|
|
}
|
|
pools, err := client.NodeLVMThinPools(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, pools)
|
|
}
|
|
|
|
func (s *Server) wipeDisk(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Disk string `json:"disk"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Disk == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "disk is required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
node := chi.URLParam(r, "node")
|
|
if err := client.WipeDisk(r.Context(), node, req.Disk); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "storage.disk.wipe", "disk", node+":"+req.Disk)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) initGPT(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Disk string `json:"disk"`
|
|
UUID string `json:"uuid,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Disk == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "disk is required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
node := chi.URLParam(r, "node")
|
|
upid, err := client.InitGPT(r.Context(), node, req.Disk, req.UUID)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "storage.disk.initgpt", "disk", node+":"+req.Disk)
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
type createDiskStorageRequest struct {
|
|
Device string `json:"device"`
|
|
Name string `json:"name"`
|
|
AddStorage bool `json:"addStorage"`
|
|
}
|
|
|
|
func (s *Server) createDirectoryStorage(w http.ResponseWriter, r *http.Request) {
|
|
var req createDiskStorageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Device == "" || req.Name == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "device and name are required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
node := chi.URLParam(r, "node")
|
|
upid, err := client.CreateDirectoryStorage(r.Context(), node, req.Device, req.Name, req.AddStorage)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "storage.disk.create.directory", "disk", node+":"+req.Device)
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
func (s *Server) createLVMStorage(w http.ResponseWriter, r *http.Request) {
|
|
var req createDiskStorageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Device == "" || req.Name == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "device and name are required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
node := chi.URLParam(r, "node")
|
|
upid, err := client.CreateLVMStorage(r.Context(), node, req.Device, req.Name, req.AddStorage)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "storage.disk.create.lvm", "disk", node+":"+req.Device)
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
func (s *Server) createLVMThinStorage(w http.ResponseWriter, r *http.Request) {
|
|
var req createDiskStorageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Device == "" || req.Name == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "device and name are required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
node := chi.URLParam(r, "node")
|
|
upid, err := client.CreateLVMThinStorage(r.Context(), node, req.Device, req.Name, req.AddStorage)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "storage.disk.create.lvmthin", "disk", node+":"+req.Device)
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
func (s *Server) createZFSPool(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Devices []string `json:"devices"`
|
|
RaidLevel string `json:"raidLevel,omitempty"`
|
|
Ashift int `json:"ashift,omitempty"`
|
|
AddStorage bool `json:"addStorage"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Name == "" || len(req.Devices) == 0 {
|
|
writeErrorMsg(w, http.StatusBadRequest, "name and at least one device are required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
node := chi.URLParam(r, "node")
|
|
upid, err := client.CreateZFSPool(r.Context(), node, req.Name, req.Devices, req.RaidLevel, req.Ashift, req.AddStorage)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "storage.disk.create.zfs", "disk", node+":"+req.Name)
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
// --- Storage discovery/scan ---
|
|
|
|
func (s *Server) scanNFS(w http.ResponseWriter, r *http.Request) {
|
|
server := r.URL.Query().Get("server")
|
|
if server == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "server query parameter is required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
exports, err := client.ScanNFS(r.Context(), chi.URLParam(r, "node"), server)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, exports)
|
|
}
|
|
|
|
func (s *Server) scanCIFS(w http.ResponseWriter, r *http.Request) {
|
|
// Credentials travel in a POST body rather than the query string used by
|
|
// the other scan endpoints, since query strings are far more likely to
|
|
// end up in access/proxy logs or browser history.
|
|
var req struct {
|
|
Server string `json:"server"`
|
|
Username string `json:"username,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
Domain string `json:"domain,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Server == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "server is required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
shares, err := client.ScanCIFS(r.Context(), chi.URLParam(r, "node"), req.Server, req.Username, req.Password, req.Domain)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, shares)
|
|
}
|
|
|
|
func (s *Server) scanISCSI(w http.ResponseWriter, r *http.Request) {
|
|
portal := r.URL.Query().Get("portal")
|
|
if portal == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "portal query parameter is required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
targets, err := client.ScanISCSI(r.Context(), chi.URLParam(r, "node"), portal)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, targets)
|
|
}
|
|
|
|
func (s *Server) scanLVM(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
|
|
}
|
|
groups, err := client.ScanLVM(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, groups)
|
|
}
|
|
|
|
func (s *Server) scanZFS(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
|
|
}
|
|
pools, err := client.ScanZFS(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, pools)
|
|
}
|
|
|
|
func (s *Server) scanGlusterFS(w http.ResponseWriter, r *http.Request) {
|
|
server := r.URL.Query().Get("server")
|
|
if server == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "server query parameter is required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
volumes, err := client.ScanGlusterFS(r.Context(), chi.URLParam(r, "node"), server)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, volumes)
|
|
}
|