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.
384 lines
12 KiB
Go
384 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func (s *Server) sdnZones(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
|
|
}
|
|
zones, err := client.SDNZones(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, zones)
|
|
}
|
|
|
|
type sdnZoneRequest struct {
|
|
Zone string `json:"zone"`
|
|
Options map[string]string `json:"options"`
|
|
}
|
|
|
|
func (s *Server) createSDNZone(w http.ResponseWriter, r *http.Request) {
|
|
var req sdnZoneRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Zone == "" || req.Options["type"] == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "zone 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.CreateSDNZone(r.Context(), req.Zone, req.Options); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.zone.create", "sdn", req.Zone)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) updateSDNZone(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
|
|
}
|
|
zone := chi.URLParam(r, "zone")
|
|
if err := client.UpdateSDNZone(r.Context(), zone, req.Options); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.zone.update", "sdn", zone)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) deleteSDNZone(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
|
|
}
|
|
zone := chi.URLParam(r, "zone")
|
|
if err := client.DeleteSDNZone(r.Context(), zone); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.zone.delete", "sdn", zone)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) sdnVnets(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
|
|
}
|
|
vnets, err := client.SDNVnets(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, vnets)
|
|
}
|
|
|
|
func (s *Server) createSDNVnet(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Vnet string `json:"vnet"`
|
|
Zone string `json:"zone"`
|
|
Alias string `json:"alias,omitempty"`
|
|
Tag int `json:"tag,omitempty"`
|
|
VLANAware bool `json:"vlanAware,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Vnet == "" || req.Zone == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "vnet and zone 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.CreateSDNVnet(r.Context(), req.Vnet, req.Zone, req.Alias, req.Tag, req.VLANAware); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.vnet.create", "sdn", req.Vnet)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) deleteSDNVnet(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
|
|
}
|
|
vnet := chi.URLParam(r, "vnet")
|
|
if err := client.DeleteSDNVnet(r.Context(), vnet); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.vnet.delete", "sdn", vnet)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) sdnSubnets(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
|
|
}
|
|
subnets, err := client.SDNSubnets(r.Context(), chi.URLParam(r, "vnet"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, subnets)
|
|
}
|
|
|
|
func (s *Server) createSDNSubnet(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
CIDR string `json:"cidr"`
|
|
Gateway string `json:"gateway,omitempty"`
|
|
SNAT bool `json:"snat,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.CIDR == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "cidr is required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
vnet := chi.URLParam(r, "vnet")
|
|
if err := client.CreateSDNSubnet(r.Context(), vnet, req.CIDR, req.Gateway, req.SNAT); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.subnet.create", "sdn", vnet+"/"+req.CIDR)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// deleteSDNSubnet takes the subnet CIDR as a query parameter rather than a
|
|
// path segment — a CIDR contains "/", which a path segment can't hold
|
|
// without percent-encoding tripping over net/http's path unescaping.
|
|
func (s *Server) deleteSDNSubnet(w http.ResponseWriter, r *http.Request) {
|
|
subnet := r.URL.Query().Get("subnet")
|
|
if subnet == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "subnet 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
|
|
}
|
|
vnet := chi.URLParam(r, "vnet")
|
|
if err := client.DeleteSDNSubnet(r.Context(), vnet, subnet); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.subnet.delete", "sdn", vnet+"/"+subnet)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) sdnControllers(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
|
|
}
|
|
controllers, err := client.SDNControllers(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, controllers)
|
|
}
|
|
|
|
type sdnControllerRequest struct {
|
|
Controller string `json:"controller"`
|
|
Options map[string]string `json:"options"`
|
|
}
|
|
|
|
func (s *Server) createSDNController(w http.ResponseWriter, r *http.Request) {
|
|
var req sdnControllerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Controller == "" || req.Options["type"] == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "controller 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.CreateSDNController(r.Context(), req.Controller, req.Options); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.controller.create", "sdn", req.Controller)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) updateSDNController(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
|
|
}
|
|
controller := chi.URLParam(r, "controller")
|
|
if err := client.UpdateSDNController(r.Context(), controller, req.Options); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.controller.update", "sdn", controller)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) deleteSDNController(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
|
|
}
|
|
controller := chi.URLParam(r, "controller")
|
|
if err := client.DeleteSDNController(r.Context(), controller); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.controller.delete", "sdn", controller)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) sdnIPAMs(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
|
|
}
|
|
ipams, err := client.SDNIPAMs(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, ipams)
|
|
}
|
|
|
|
type sdnIPAMRequest struct {
|
|
Ipam string `json:"ipam"`
|
|
Options map[string]string `json:"options"`
|
|
}
|
|
|
|
func (s *Server) createSDNIPAM(w http.ResponseWriter, r *http.Request) {
|
|
var req sdnIPAMRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Ipam == "" || req.Options["type"] == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "ipam 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.CreateSDNIPAM(r.Context(), req.Ipam, req.Options); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.ipam.create", "sdn", req.Ipam)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) updateSDNIPAM(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
|
|
}
|
|
ipam := chi.URLParam(r, "ipam")
|
|
if err := client.UpdateSDNIPAM(r.Context(), ipam, req.Options); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.ipam.update", "sdn", ipam)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) deleteSDNIPAM(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
|
|
}
|
|
ipam := chi.URLParam(r, "ipam")
|
|
if err := client.DeleteSDNIPAM(r.Context(), ipam); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.ipam.delete", "sdn", ipam)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) applySDNConfig(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
|
|
}
|
|
upid, err := client.ApplySDNConfig(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "sdn.apply", "connection", chi.URLParam(r, "id"))
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|