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

1103 lines
34 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"ferrum/internal/pve"
)
func (s *Server) nodeStatus(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
}
status, err := client.NodeStatus(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (s *Server) nodeDisks(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
}
disks, err := client.NodeDisks(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, disks)
}
func (s *Server) diskSMART(w http.ResponseWriter, r *http.Request) {
devpath := r.URL.Query().Get("disk")
if devpath == "" {
writeErrorMsg(w, http.StatusBadRequest, "disk 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
}
smart, err := client.DiskSMART(r.Context(), chi.URLParam(r, "node"), devpath)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, smart)
}
func (s *Server) rebootNode(w http.ResponseWriter, r *http.Request) {
node := chi.URLParam(r, "node")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
upid, err := client.RebootNode(r.Context(), node)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.reboot", "node", node)
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) shutdownNode(w http.ResponseWriter, r *http.Request) {
node := chi.URLParam(r, "node")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
upid, err := client.ShutdownNode(r.Context(), node)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.shutdown", "node", node)
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) nodeStorageList(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
}
storages, err := client.NodeStorage(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, storages)
}
func (s *Server) storageContent(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
}
items, err := client.StorageContent(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "storage"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) deleteStorageContent(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
}
node, storage, volid := chi.URLParam(r, "node"), chi.URLParam(r, "storage"), chi.URLParam(r, "volid")
if err := client.DeleteStorageContent(r.Context(), node, storage, volid); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "storage.delete-content", "storage", node+"/"+storage+"/"+volid)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) setBackupProtected(w http.ResponseWriter, r *http.Request) {
var req struct {
Protected bool `json:"protected"`
}
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
}
node, storage, volid := chi.URLParam(r, "node"), chi.URLParam(r, "storage"), chi.URLParam(r, "volid")
if err := client.SetBackupProtected(r.Context(), node, storage, volid, req.Protected); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "backup.set-protected", "storage", node+"/"+storage+"/"+volid)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) nodeNetwork(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
}
ifaces, err := client.NodeNetwork(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, ifaces)
}
func (s *Server) aptUpdates(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
}
updates, err := client.AptUpdates(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, updates)
}
func (s *Server) aptRefresh(w http.ResponseWriter, r *http.Request) {
node := chi.URLParam(r, "node")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
upid, err := client.RefreshAptIndex(r.Context(), node)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) aptUpgrade(w http.ResponseWriter, r *http.Request) {
node := chi.URLParam(r, "node")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
upid, err := client.UpgradeNode(r.Context(), node)
if err != nil {
if errors.Is(err, pve.ErrUpgradeNotSupported) {
s.writeError(w, http.StatusNotImplemented, err)
return
}
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.upgrade", "node", node)
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) nodeSyslog(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
}
entries, err := client.NodeSyslog(r.Context(), chi.URLParam(r, "node"), 200)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, entries)
}
func (s *Server) nodeFirewallRules(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.NodeFirewallRules(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, rules)
}
func (s *Server) addNodeFirewallRule(w http.ResponseWriter, r *http.Request) {
var req newFirewallRuleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Type == "" || req.Action == "" {
writeErrorMsg(w, http.StatusBadRequest, "type and action are required")
return
}
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
rule := pve.NewFirewallRule{
Type: req.Type, Action: req.Action, Source: req.Source, Dest: req.Dest,
Proto: req.Proto, Dport: req.Dport, Sport: req.Sport, Macro: req.Macro,
Comment: req.Comment, Enable: req.Enable,
}
if err := client.AddNodeFirewallRule(r.Context(), chi.URLParam(r, "node"), rule); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "firewall.rule.create", "firewall", "node:"+chi.URLParam(r, "node"))
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
}
func (s *Server) deleteNodeFirewallRule(w http.ResponseWriter, r *http.Request) {
pos, err := strconv.Atoi(chi.URLParam(r, "pos"))
if 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
}
if err := client.DeleteNodeFirewallRule(r.Context(), chi.URLParam(r, "node"), pos); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "firewall.rule.delete", "firewall", "node:"+chi.URLParam(r, "node"))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) nodeReplicationStatus(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
}
status, err := client.NodeReplicationStatus(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, status)
}
// cephNotConfigured ends a Ceph request where the target server has no
// Ceph (not installed, or the endpoint isn't implemented on its PVE
// version). 204 reads as "no data" to the frontend — which shows "Not
// configured" — instead of an error state and a 502 in the logs.
func cephNotConfigured(w http.ResponseWriter, err error) bool {
if !pve.IsNotAvailable(err) {
return false
}
w.WriteHeader(http.StatusNoContent)
return true
}
func (s *Server) cephStatus(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
}
status, err := client.CephClusterStatus(r.Context(), chi.URLParam(r, "node"))
if cephNotConfigured(w, err) {
return
}
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (s *Server) cephPools(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.CephPools(r.Context(), chi.URLParam(r, "node"))
if cephNotConfigured(w, err) {
return
}
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, pools)
}
func (s *Server) cephOSDs(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
}
osds, err := client.CephOSDs(r.Context(), chi.URLParam(r, "node"))
if cephNotConfigured(w, err) {
return
}
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, osds)
}
func (s *Server) cephMons(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
}
mons, err := client.CephMons(r.Context(), chi.URLParam(r, "node"))
if cephNotConfigured(w, err) {
return
}
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, mons)
}
func (s *Server) createCephMon(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.CreateCephMon(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ceph.mon.create", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) deleteCephMon(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
}
if err := client.DeleteCephMon(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "monid")); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ceph.mon.delete", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) cephMgrs(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
}
mgrs, err := client.CephMgrs(r.Context(), chi.URLParam(r, "node"))
if cephNotConfigured(w, err) {
return
}
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, mgrs)
}
func (s *Server) createCephMgr(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.CreateCephMgr(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ceph.mgr.create", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) deleteCephMgr(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
}
if err := client.DeleteCephMgr(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "mgrid")); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ceph.mgr.delete", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) cephFilesystems(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
}
fs, err := client.CephFilesystems(r.Context(), chi.URLParam(r, "node"))
if cephNotConfigured(w, err) {
return
}
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, fs)
}
func (s *Server) createCephFilesystem(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Name == "" {
writeErrorMsg(w, http.StatusBadRequest, "name is required")
return
}
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
upid, err := client.CreateCephFilesystem(r.Context(), chi.URLParam(r, "node"), req.Name)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "ceph.fs.create", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
// --- PBS file-level restore ---
func (s *Server) fileRestoreList(w http.ResponseWriter, r *http.Request) {
volume := r.URL.Query().Get("volume")
if volume == "" {
writeErrorMsg(w, http.StatusBadRequest, "volume query parameter is required")
return
}
path := r.URL.Query().Get("path")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
entries, err := client.FileRestoreList(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "storage"), volume, path)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, entries)
}
// fileRestoreDownload proxies a single-file (or zipped directory) download
// out of a PBS backup archive — streamed straight through rather than
// buffered, since a restored file/zip can be large.
func (s *Server) fileRestoreDownload(w http.ResponseWriter, r *http.Request) {
volume := r.URL.Query().Get("volume")
path := r.URL.Query().Get("path")
if volume == "" || path == "" {
writeErrorMsg(w, http.StatusBadRequest, "volume and path query parameters are required")
return
}
// A large restored file can take well past the 30s global request
// timeout to stream; detach from that inherited deadline the same way
// /mcp and /ai/chat do, and apply a generous one of our own.
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Minute)
defer cancel()
client, err := s.clientFor(ctx, chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
resp, err := client.FileRestoreDownload(ctx, chi.URLParam(r, "node"), chi.URLParam(r, "storage"), volume, path)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", "application/octet-stream")
}
filename := path[strings.LastIndex(path, "/")+1:]
if filename == "" {
filename = "restore"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, resp.Body)
}
// --- Node DNS / time / hosts ---
func (s *Server) nodeDNS(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
}
cfg, err := client.NodeDNS(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (s *Server) updateNodeDNS(w http.ResponseWriter, r *http.Request) {
var req pve.NodeDNSConfig
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
}
if err := client.UpdateNodeDNS(r.Context(), chi.URLParam(r, "node"), req); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.dns.update", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) nodeTime(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
}
info, err := client.NodeTime(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, info)
}
func (s *Server) updateNodeTimezone(w http.ResponseWriter, r *http.Request) {
var req struct {
Timezone string `json:"timezone"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Timezone == "" {
writeErrorMsg(w, http.StatusBadRequest, "timezone is 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.SetNodeTimezone(r.Context(), chi.URLParam(r, "node"), req.Timezone); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.timezone.update", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) nodeHosts(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
}
hosts, err := client.NodeHosts(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, hosts)
}
func (s *Server) updateNodeHosts(w http.ResponseWriter, r *http.Request) {
var req pve.NodeHosts
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
}
if err := client.UpdateNodeHosts(r.Context(), chi.URLParam(r, "node"), req.Data, req.Digest); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.hosts.update", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// --- Node certificates ---
func (s *Server) nodeCertificates(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
}
certs, err := client.NodeCertificates(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, certs)
}
func (s *Server) uploadNodeCertificate(w http.ResponseWriter, r *http.Request) {
var req struct {
Certificate string `json:"certificate"`
Key string `json:"key,omitempty"`
Force bool `json:"force,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Certificate == "" {
writeErrorMsg(w, http.StatusBadRequest, "certificate is 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.UploadCustomCertificate(r.Context(), chi.URLParam(r, "node"), req.Certificate, req.Key, req.Force); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.certificate.upload", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) deleteNodeCertificate(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
}
if err := client.DeleteCustomCertificate(r.Context(), chi.URLParam(r, "node")); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.certificate.delete", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) orderAcmeCertificate(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.AcmeOrderCertificate(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.certificate.acme.order", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) revokeAcmeCertificate(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.RevokeAcmeCertificate(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.certificate.acme.revoke", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) wakeOnLan(w http.ResponseWriter, r *http.Request) {
node := chi.URLParam(r, "node")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
result, err := client.WakeOnLan(r.Context(), node)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.wakeonlan", "node", node)
writeJSON(w, http.StatusOK, map[string]string{"result": result})
}
func (s *Server) startAllGuests(w http.ResponseWriter, r *http.Request) {
node := chi.URLParam(r, "node")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
upid, err := client.StartAllGuests(r.Context(), node)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.startall", "node", node)
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) stopAllGuests(w http.ResponseWriter, r *http.Request) {
node := chi.URLParam(r, "node")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
upid, err := client.StopAllGuests(r.Context(), node)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.stopall", "node", node)
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) nodeJournal(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
}
entries, err := client.NodeJournal(r.Context(), chi.URLParam(r, "node"), 500)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, entries)
}
// --- Node services (systemd units PVE manages: pveproxy, pvedaemon, ...) ---
func (s *Server) nodeServices(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
}
services, err := client.NodeServices(r.Context(), chi.URLParam(r, "node"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, services)
}
func (s *Server) nodeServiceState(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
}
state, err := client.NodeServiceState(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "service"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, state)
}
func (s *Server) nodeServiceAction(w http.ResponseWriter, r *http.Request) {
action := chi.URLParam(r, "action")
switch action {
case "start", "stop", "restart", "reload":
default:
writeErrorMsg(w, http.StatusBadRequest, "action must be one of start, stop, restart, reload")
return
}
node, service := chi.URLParam(r, "node"), chi.URLParam(r, "service")
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
if err := client.NodeServiceAction(r.Context(), node, service, action); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.service."+action, "node", node+"/"+service)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// uploadStorageContentLimit bounds an ISO/template upload — generous enough
// for a real installer image, unlike the 2 MiB JSON body cap the rest of the
// API uses.
const uploadStorageContentLimit = 8 << 30 // 8 GiB
func (s *Server) uploadStorageContent(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, uploadStorageContentLimit)
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeErrorMsg(w, http.StatusBadRequest, "invalid upload: "+err.Error())
return
}
content := r.FormValue("content")
if content != "iso" && content != "vztmpl" {
writeErrorMsg(w, http.StatusBadRequest, `content must be "iso" or "vztmpl"`)
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeErrorMsg(w, http.StatusBadRequest, "file is required")
return
}
defer file.Close()
// A multi-GB ISO/template upload can take well past the 30s global
// request timeout to stream to Proxmox; detach from that inherited
// deadline the same way /mcp and /ai/chat do, and apply a generous one
// of our own.
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Minute)
defer cancel()
client, err := s.clientFor(ctx, chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
node, storage := chi.URLParam(r, "node"), chi.URLParam(r, "storage")
upid, err := client.UploadStorageContent(ctx, node, storage, content, header.Filename, file)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "storage.upload", "storage", node+"/"+storage+"/"+header.Filename)
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
func (s *Server) downloadURLToStorage(w http.ResponseWriter, r *http.Request) {
var req struct {
Content string `json:"content"`
Filename string `json:"filename"`
URL string `json:"url"`
Checksum string `json:"checksum,omitempty"`
ChecksumAlgorithm string `json:"checksumAlgorithm,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Content != "iso" && req.Content != "vztmpl" {
writeErrorMsg(w, http.StatusBadRequest, `content must be "iso" or "vztmpl"`)
return
}
if req.Filename == "" || req.URL == "" {
writeErrorMsg(w, http.StatusBadRequest, "filename and url are required")
return
}
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
node, storage := chi.URLParam(r, "node"), chi.URLParam(r, "storage")
upid, err := client.DownloadURLToStorage(r.Context(), node, storage, req.Content, req.Filename, req.URL, req.Checksum, req.ChecksumAlgorithm)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "storage.download-url", "storage", node+"/"+storage+"/"+req.Filename)
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
}
// --- Cluster membership ---
func (s *Server) clusterConfigNodes(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
}
nodes, err := client.ClusterConfigNodes(r.Context())
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, nodes)
}
func (s *Server) clusterJoinInfo(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
}
info, err := client.ClusterJoinInfo(r.Context())
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, info)
}
func (s *Server) createCluster(w http.ResponseWriter, r *http.Request) {
var req struct {
ClusterName string `json:"clusterName"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.ClusterName == "" {
writeErrorMsg(w, http.StatusBadRequest, "clusterName is 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.CreateCluster(r.Context(), req.ClusterName); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "cluster.create", "connection", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) joinCluster(w http.ResponseWriter, r *http.Request) {
var req struct {
Hostname string `json:"hostname"`
Fingerprint string `json:"fingerprint"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Hostname == "" || req.Fingerprint == "" || req.Password == "" {
writeErrorMsg(w, http.StatusBadRequest, "hostname, fingerprint and password 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.JoinCluster(r.Context(), req.Hostname, req.Fingerprint, req.Password); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "cluster.join", "connection", chi.URLParam(r, "id"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) removeClusterNode(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
}
if err := client.RemoveClusterNode(r.Context(), chi.URLParam(r, "node")); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "cluster.node.remove", "node", chi.URLParam(r, "node"))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// taskStatus is the single-task status check — cheaper than nodeTasks'
// full listing (which caps at limit=100 and can drop an older UPID off the
// end) for a client polling "is my UPID done yet?".
func (s *Server) taskStatus(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
}
task, err := client.TaskStatus(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "upid"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) taskLog(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
}
lines, err := client.TaskLog(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "upid"))
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
writeJSON(w, http.StatusOK, lines)
}
func (s *Server) cancelTask(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
}
if err := client.CancelTask(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "upid")); err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
s.audit(r, "node.task.cancel", "node", chi.URLParam(r, "node")+"/"+chi.URLParam(r, "upid"))
w.WriteHeader(http.StatusNoContent)
}