mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-16 15:45:09 +00:00
dbe6f0df1e
Root cause of the recurring "Request failed" / 502 errors on delete and
other actions: the Proxmox client was sending a request body on DELETE
calls, which Proxmox's API rejects outright. Params now go on the query
string instead, fixing delete for guests and replication jobs alike.
Other fixes:
- Guest console/shell errors now show Proxmox's actual message instead of
a generic failure, so a permission or lock issue is visible instead of
guessed at.
- Opening a console/shell popup no longer wastes the small window Proxmox
gives to claim a session ticket on the popup's own load time - the
ticket is requested only once the popup is ready to use it.
- An LXC container with an interface that has no IP address was crashing
the guest detail view ("Cannot read properties of null"). The backend
was sending a null array instead of an empty one; fixed at the source
plus a few related spots that had the same latent risk.
- The template upload dialog had broken spacing between its fields.
- Settings page: the "About" section could never show as active in the
sidebar nav because of how the scroll position was measured.
New:
- Direct SSH shell access from Inventory and node pages, independent of
Proxmox's own console/shell (useful when that's unreliable or
unavailable). Connections can now store SSH credentials (password or
private key) so this connects with one click instead of typing them in
each time.
- Network interfaces in a guest's Hardware tab are now editable as
separate fields (bridge, VLAN, IP, gateway, firewall, etc.) instead of
one raw text value, matching how Proxmox's own UI presents them. LXC
containers can also have their DNS nameserver/search domain set from
the same tab, and other hardware config keys can now be edited through
the same endpoint.
1189 lines
43 KiB
Go
1189 lines
43 KiB
Go
// Package api wires together HTTP handlers for Ferrum's REST API.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/google/uuid"
|
|
|
|
"ferrum/internal/auth"
|
|
"ferrum/internal/connections"
|
|
"ferrum/internal/digest"
|
|
"ferrum/internal/events"
|
|
"ferrum/internal/mcp"
|
|
"ferrum/internal/needle"
|
|
"ferrum/internal/notify"
|
|
"ferrum/internal/pbs"
|
|
"ferrum/internal/poller"
|
|
"ferrum/internal/pve"
|
|
"ferrum/internal/secrets"
|
|
"ferrum/internal/store"
|
|
)
|
|
|
|
type Server struct {
|
|
db *store.DB
|
|
auth *auth.Service
|
|
secrets *secrets.Box
|
|
connections *connections.Resolver
|
|
mcp *mcp.Server
|
|
needle *needle.Manager
|
|
options ServerOptions
|
|
webFS fs.FS
|
|
|
|
oidcMu sync.RWMutex
|
|
oidc *auth.OIDCClient // nil when SSO isn't configured; guarded because the settings UI can replace it at any time
|
|
|
|
evaluator *poller.AlertEvaluator // its SetNotifier is called when the notification settings are saved; nil until SetAlertEvaluator is called
|
|
digestScheduler *digest.Scheduler // its SetNotifier is called when the notification settings are saved; nil until SetDigestScheduler is called
|
|
notify *notify.Notifier // never nil — Notify is a no-op when no channel is enabled
|
|
|
|
events *events.Bus // in-process pub/sub backing SSE and outgoing webhooks; nil until SetEventBus is called (see cmd/ferrum/main.go)
|
|
webhooks *notify.WebhookDispatcher // nil until SetWebhookDispatcher is called
|
|
|
|
securityMu sync.RWMutex
|
|
require2FAAdmins bool // enforced by requireTOTPEnrolled below; toggled live from Settings
|
|
|
|
corsMu sync.RWMutex
|
|
corsOrigins []string // admin-configured CORS allow-list (system_settings.cors_allowed_origins); nil/empty means CORS stays off
|
|
|
|
logins *loginLimiter
|
|
aiChatLimit *requestLimiter
|
|
}
|
|
|
|
// SetCORSOrigins swaps in the live CORS allow-list, applied by
|
|
// applySystemRow at boot and on every Settings save.
|
|
func (s *Server) SetCORSOrigins(origins []string) {
|
|
s.corsMu.Lock()
|
|
s.corsOrigins = origins
|
|
s.corsMu.Unlock()
|
|
}
|
|
|
|
func (s *Server) corsOriginAllowed(origin string) bool {
|
|
if origin == "" {
|
|
return false
|
|
}
|
|
s.corsMu.RLock()
|
|
defer s.corsMu.RUnlock()
|
|
for _, o := range s.corsOrigins {
|
|
if o == origin || o == "*" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SetRequire2FAAdmins toggles whether admin accounts without TOTP enabled
|
|
// are blocked from everything except /auth/2fa/* and /auth/me|logout until
|
|
// they enroll — see requireTOTPEnrolled.
|
|
func (s *Server) SetRequire2FAAdmins(v bool) {
|
|
s.securityMu.Lock()
|
|
s.require2FAAdmins = v
|
|
s.securityMu.Unlock()
|
|
}
|
|
|
|
func (s *Server) getRequire2FAAdmins() bool {
|
|
s.securityMu.RLock()
|
|
defer s.securityMu.RUnlock()
|
|
return s.require2FAAdmins
|
|
}
|
|
|
|
// ServerOptions carries deployment-level behavior that handlers need at
|
|
// request time (cookie flags, proxy awareness).
|
|
type ServerOptions struct {
|
|
// SecureCookies marks the session cookie Secure unconditionally. When
|
|
// BehindProxy is also set, cookies are additionally marked Secure for
|
|
// any request whose X-Forwarded-Proto is https.
|
|
SecureCookies bool
|
|
// BehindProxy enables trusting X-Forwarded-For / X-Forwarded-Proto
|
|
// headers set by the reverse proxy in front of Ferrum.
|
|
BehindProxy bool
|
|
// NeedleBinPath locates the optional Needle 2 CLI binary backing the
|
|
// built-in AI provider (see internal/needle). Empty disables it.
|
|
NeedleBinPath string
|
|
}
|
|
|
|
// SetOIDC swaps in an SSO client built from the current admin-configured (or
|
|
// config.yaml/env, at startup) settings. Called once at boot and again every
|
|
// time the OIDC settings are saved from the UI — nil is the normal "SSO not
|
|
// configured" state and clears any previously active client.
|
|
func (s *Server) SetOIDC(client *auth.OIDCClient) {
|
|
s.oidcMu.Lock()
|
|
s.oidc = client
|
|
s.oidcMu.Unlock()
|
|
}
|
|
|
|
func (s *Server) getOIDC() *auth.OIDCClient {
|
|
s.oidcMu.RLock()
|
|
defer s.oidcMu.RUnlock()
|
|
return s.oidc
|
|
}
|
|
|
|
// Notifier exposes the server's Gotify/SMTP dispatcher so main.go can hand
|
|
// the same instance to the alert evaluator at startup.
|
|
func (s *Server) Notifier() *notify.Notifier {
|
|
return s.notify
|
|
}
|
|
|
|
// SetAlertEvaluator lets the notification settings handlers push a newly
|
|
// saved Gotify/SMTP config into the running poller without a restart.
|
|
func (s *Server) SetAlertEvaluator(e *poller.AlertEvaluator) {
|
|
s.evaluator = e
|
|
}
|
|
|
|
// SetEventBus attaches the shared in-process event bus (see internal/events)
|
|
// that the SSE endpoint reads from. Called once at startup.
|
|
func (s *Server) SetEventBus(b *events.Bus) {
|
|
s.events = b
|
|
}
|
|
|
|
// SetWebhookDispatcher attaches the outgoing-webhook dispatcher used by the
|
|
// admin webhook settings endpoints ("send test event"). Called once at
|
|
// startup.
|
|
func (s *Server) SetWebhookDispatcher(d *notify.WebhookDispatcher) {
|
|
s.webhooks = d
|
|
}
|
|
|
|
// SetDigestScheduler mirrors SetAlertEvaluator for the fleet digest
|
|
// scheduler — lets the notification settings handler push a newly saved
|
|
// Gotify/SMTP config into it, and lets the /settings/digest/send-now
|
|
// handler trigger an immediate send.
|
|
func (s *Server) SetDigestScheduler(d *digest.Scheduler) {
|
|
s.digestScheduler = d
|
|
}
|
|
|
|
func New(db *store.DB, authSvc *auth.Service, secretBox *secrets.Box, opts ServerOptions) *Server {
|
|
resolver := connections.New(db, secretBox)
|
|
s := &Server{
|
|
db: db, auth: authSvc, secrets: secretBox, options: opts,
|
|
connections: resolver,
|
|
notify: notify.New(notify.Settings{}),
|
|
logins: newLoginLimiter(),
|
|
aiChatLimit: newRequestLimiter(aiChatMaxPerMinute, time.Minute),
|
|
needle: needle.NewManager(opts.NeedleBinPath),
|
|
}
|
|
s.mcp = mcp.New(resolver, db,
|
|
func(ctx context.Context, userID, action, category, target string) {
|
|
s.auditEntry(ctx, userID, action, category, target, "mcp")
|
|
},
|
|
s.recordToolCall,
|
|
)
|
|
// Lets internal/needle render its --tools file from the same tool
|
|
// catalog the MCP server and AI Assistant already share, without
|
|
// needing to import internal/mcp itself (see needle.SetToolCatalog).
|
|
needle.SetToolCatalog(func() []needle.ToolDef {
|
|
defs := mcp.ToolDefinitions()
|
|
out := make([]needle.ToolDef, len(defs))
|
|
for i, t := range defs {
|
|
out[i] = needle.ToolDef{Name: t.Name, Description: t.Description, InputSchema: t.InputSchema}
|
|
}
|
|
return out
|
|
})
|
|
s.seedBuiltinNeedleProvider(context.Background())
|
|
return s
|
|
}
|
|
|
|
// Close releases resources the server owns beyond the DB connection (which
|
|
// callers already close separately) — currently just the Needle subprocess,
|
|
// if one was ever started. Called once at shutdown (see cmd/ferrum/main.go).
|
|
func (s *Server) Close() {
|
|
s.needle.Close()
|
|
}
|
|
|
|
// cookieSecure reports whether the session cookie for this request should
|
|
// carry the Secure flag: either configured globally, or — behind a proxy —
|
|
// when the proxy says the browser connection is https.
|
|
func (s *Server) cookieSecure(r *http.Request) bool {
|
|
if s.options.SecureCookies {
|
|
return true
|
|
}
|
|
if s.options.BehindProxy && r.Header.Get("X-Forwarded-Proto") == "https" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// maxRequestBody bounds JSON bodies (configs, VM specs, dashboard layouts).
|
|
const maxRequestBody = 2 << 20 // 2 MiB
|
|
|
|
func (s *Server) Router() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
if s.options.BehindProxy {
|
|
r.Use(middleware.RealIP)
|
|
}
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(s.requestLogger)
|
|
r.Use(s.cors)
|
|
r.Use(s.securityHeaders)
|
|
r.Use(middleware.Timeout(30 * time.Second))
|
|
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
// Cross-origin request check — see originCheck. Mounted on the whole
|
|
// /api/v1 group (login included) because every state-changing route
|
|
// beneath it is protected by the session cookie.
|
|
r.Use(s.originCheck)
|
|
|
|
// Bound every JSON body; handlers never need more than a few KiB. A
|
|
// multipart upload (ISO/template files) sets its own, much larger
|
|
// bound itself — see uploadStorageContent.
|
|
r.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Body != nil && !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
})
|
|
|
|
r.Get("/health", s.health)
|
|
|
|
r.Route("/auth", func(r chi.Router) {
|
|
r.Get("/setup-status", s.authSetupStatus)
|
|
r.Post("/setup", s.authSetup)
|
|
r.Post("/login", s.authLogin)
|
|
r.Post("/login/totp", s.authLoginTOTP)
|
|
r.Get("/oidc/config", s.oidcConfig)
|
|
r.Get("/oidc/login", s.oidcLogin)
|
|
r.Get("/oidc/callback", s.oidcCallback)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireAuth)
|
|
r.Post("/logout", s.authLogout)
|
|
r.Get("/me", s.authMe)
|
|
// Per-account UI settings (theme, …) — every user may set
|
|
// their own, no admin required.
|
|
r.Get("/me/preferences", s.getPreferences)
|
|
r.Put("/me/preferences", s.putPreferences)
|
|
r.Route("/2fa", func(r chi.Router) {
|
|
r.Get("/status", s.totpStatus)
|
|
r.Post("/enroll", s.totpEnroll)
|
|
r.Post("/confirm", s.totpConfirm)
|
|
r.Post("/disable", s.totpDisable)
|
|
})
|
|
r.Route("/apikeys", func(r chi.Router) {
|
|
r.Get("/", s.listAPIKeys)
|
|
r.Post("/", s.createAPIKey)
|
|
r.Delete("/{id}", s.revokeAPIKey)
|
|
})
|
|
r.Get("/agent-status", s.getAgentStatus)
|
|
})
|
|
})
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireAuth)
|
|
r.Use(s.requireTOTPEnrolled)
|
|
|
|
r.Route("/connections", func(r chi.Router) {
|
|
// Everything under /connections proxies a live PVE instance;
|
|
// mutating it (power actions, deletes, firewall, HA, ...) is
|
|
// an admin operation. Reads stay available to all users.
|
|
r.Use(s.requireAdminForMutations)
|
|
r.Get("/", s.listConnections)
|
|
r.Post("/", s.createConnection)
|
|
r.Post("/test", s.testConnection)
|
|
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Put("/", s.updateConnection)
|
|
r.Delete("/", s.deleteConnection)
|
|
|
|
r.Post("/vms", s.createVM)
|
|
r.Post("/lxc", s.createLXC)
|
|
r.Get("/nextid", s.nextGuestID)
|
|
r.Get("/templates", s.listTemplates)
|
|
r.Get("/health-score", s.connectionHealthScore)
|
|
|
|
// PBS (Proxmox Backup Server) remote — mounted on a
|
|
// connection whose stored type is "pbs" rather than
|
|
// "pve"; pbsClientFor rejects any other type.
|
|
r.Route("/pbs", func(r chi.Router) {
|
|
r.Get("/datastores", s.pbsListDatastores)
|
|
r.Route("/datastores/{store}", func(r chi.Router) {
|
|
r.Get("/namespaces", s.pbsListNamespaces)
|
|
r.Get("/groups", s.pbsListGroups)
|
|
r.Get("/snapshots", s.pbsListSnapshots)
|
|
r.Post("/snapshots/protected", s.pbsSetSnapshotProtected)
|
|
r.Post("/prune", s.pbsPrune)
|
|
r.Post("/gc", s.pbsStartGC)
|
|
r.Get("/gc", s.pbsGCStatus)
|
|
})
|
|
r.Route("/sync-jobs", func(r chi.Router) {
|
|
r.Get("/", s.pbsListSyncJobs)
|
|
r.Route("/{jobId}", func(r chi.Router) {
|
|
r.Get("/", s.pbsGetSyncJob)
|
|
r.Post("/run", s.pbsRunSyncJob)
|
|
})
|
|
})
|
|
r.Route("/verify-jobs", func(r chi.Router) {
|
|
r.Get("/", s.pbsListVerifyJobs)
|
|
r.Route("/{jobId}", func(r chi.Router) {
|
|
r.Get("/", s.pbsGetVerifyJob)
|
|
r.Post("/run", s.pbsRunVerifyJob)
|
|
})
|
|
})
|
|
r.Get("/tasks/{upid}/status", s.pbsTaskStatus)
|
|
r.Get("/tasks/{upid}/log", s.pbsTaskLog)
|
|
})
|
|
r.With(s.requireAdmin).Get("/certificates", s.connectionCertificates)
|
|
r.With(s.requireAdmin).Post("/ssh-session", s.openConnectionSSHShell)
|
|
|
|
r.Route("/export", func(r chi.Router) {
|
|
// Exporting the full live inventory as IaC is
|
|
// sensitive infrastructure detail — admin-only
|
|
// regardless of the requireAdminForMutations
|
|
// read-passthrough this route tree otherwise allows.
|
|
r.Use(s.requireAdmin)
|
|
r.Get("/terraform", s.exportTerraform)
|
|
r.Get("/ansible", s.exportAnsible)
|
|
})
|
|
|
|
r.Route("/guests/{type}/{node}/{vmid}", func(r chi.Router) {
|
|
r.Post("/power/{action}", s.guestPowerAction)
|
|
r.Post("/console", s.openGuestConsole)
|
|
r.Post("/shell", s.openGuestShell)
|
|
r.Post("/sendkey", s.guestSendKey)
|
|
r.Get("/config", s.getGuestConfig)
|
|
r.Put("/config", s.updateGuestConfig)
|
|
r.Put("/tags", s.updateGuestTags)
|
|
r.Post("/baseline", s.captureGuestBaseline)
|
|
r.Delete("/baseline", s.clearGuestBaseline)
|
|
r.Get("/drift", s.getGuestDrift)
|
|
r.Post("/clone", s.cloneGuest)
|
|
r.Post("/migrate", s.migrateGuest)
|
|
r.Get("/migrate", s.migratePrecondition)
|
|
r.Post("/remote-migrate", s.remoteMigrateGuest)
|
|
r.Post("/resize", s.resizeGuestDisk)
|
|
r.Post("/move-disk", s.moveGuestDisk)
|
|
r.Post("/template", s.setGuestTemplate)
|
|
r.Post("/unlock", s.unlockGuest)
|
|
r.Delete("/", s.deleteGuest)
|
|
r.Get("/firewall/rules", s.guestFirewallRules)
|
|
r.Post("/firewall/rules", s.addGuestFirewallRule)
|
|
r.Delete("/firewall/rules/{pos}", s.deleteGuestFirewallRule)
|
|
r.Get("/firewall/options", s.guestFirewallOptions)
|
|
r.Put("/firewall/options", s.updateGuestFirewallOptions)
|
|
r.Get("/rrddata", s.guestRRDData)
|
|
r.Get("/status", s.guestLiveStatus)
|
|
r.Get("/backups", s.guestBackups)
|
|
|
|
r.Route("/agent", func(r chi.Router) {
|
|
r.Get("/network", s.guestAgentNetwork)
|
|
r.Post("/ping", s.guestAgentPing)
|
|
r.Post("/exec", s.guestAgentExec)
|
|
r.With(s.requireAdmin).Get("/exec-status", s.guestAgentExecStatus)
|
|
r.Post("/fsfreeze/{action}", s.guestAgentFsfreeze)
|
|
r.Post("/shutdown", s.guestAgentShutdown)
|
|
r.Post("/set-password", s.guestAgentSetPassword)
|
|
r.Get("/osinfo", s.guestAgentOSInfo)
|
|
r.Get("/fsinfo", s.guestAgentFSInfo)
|
|
r.Get("/vcpus", s.guestAgentVCPUs)
|
|
r.Get("/hostname", s.guestAgentHostname)
|
|
r.Get("/timezone", s.guestAgentTimezone)
|
|
r.Post("/file-read", s.guestAgentFileRead)
|
|
r.Post("/file-write", s.guestAgentFileWrite)
|
|
})
|
|
|
|
r.Route("/snapshots", func(r chi.Router) {
|
|
r.Get("/", s.listSnapshots)
|
|
r.Post("/", s.createSnapshot)
|
|
r.Post("/{snapname}/rollback", s.rollbackSnapshot)
|
|
r.Delete("/{snapname}", s.deleteSnapshot)
|
|
})
|
|
})
|
|
|
|
r.Route("/nodes/{node}", func(r chi.Router) {
|
|
r.Get("/status", s.nodeStatus)
|
|
r.Get("/rrddata", s.nodeRRDData)
|
|
r.Get("/forecast", s.nodeForecast)
|
|
r.Post("/reboot", s.rebootNode)
|
|
r.Post("/shutdown", s.shutdownNode)
|
|
r.Post("/wakeonlan", s.wakeOnLan)
|
|
r.Post("/startall", s.startAllGuests)
|
|
r.Post("/stopall", s.stopAllGuests)
|
|
r.Post("/shell", s.openNodeShell)
|
|
r.Get("/network", s.nodeNetwork)
|
|
r.Get("/syslog", s.nodeSyslog)
|
|
r.With(s.requireAdmin).Get("/journal", s.nodeJournal)
|
|
r.Get("/services", s.nodeServices)
|
|
r.Get("/services/{service}/state", s.nodeServiceState)
|
|
r.Post("/services/{service}/{action}", s.nodeServiceAction)
|
|
r.Get("/firewall/rules", s.nodeFirewallRules)
|
|
r.Post("/firewall/rules", s.addNodeFirewallRule)
|
|
r.Delete("/firewall/rules/{pos}", s.deleteNodeFirewallRule)
|
|
r.Get("/replication", s.nodeReplicationStatus)
|
|
r.Get("/subscription", s.nodeSubscription)
|
|
|
|
r.Get("/storage", s.nodeStorageList)
|
|
r.Get("/storage/{storage}/content", s.storageContent)
|
|
r.Delete("/storage/{storage}/content/{volid}", s.deleteStorageContent)
|
|
r.Put("/storage/{storage}/content/{volid}/protected", s.setBackupProtected)
|
|
r.With(s.requireAdmin).Get("/storage/{storage}/file-restore", s.fileRestoreList)
|
|
r.With(s.requireAdmin).Get("/storage/{storage}/file-restore/download", s.fileRestoreDownload)
|
|
r.Post("/storage/{storage}/upload", s.uploadStorageContent)
|
|
r.Post("/storage/{storage}/download-url", s.downloadURLToStorage)
|
|
|
|
r.Get("/disks", s.nodeDisks)
|
|
r.Get("/disks/smart", s.diskSMART)
|
|
r.Post("/disks/wipedisk", s.wipeDisk)
|
|
r.Post("/disks/initgpt", s.initGPT)
|
|
r.Get("/disks/zfs", s.nodeZFSPools)
|
|
r.Post("/disks/zfs", s.createZFSPool)
|
|
r.Get("/disks/lvm", s.nodeLVMGroups)
|
|
r.Post("/disks/lvm", s.createLVMStorage)
|
|
r.Get("/disks/lvmthin", s.nodeLVMThinPools)
|
|
r.Post("/disks/lvmthin", s.createLVMThinStorage)
|
|
r.Post("/disks/directory", s.createDirectoryStorage)
|
|
|
|
r.Get("/scan/nfs", s.scanNFS)
|
|
r.Post("/scan/cifs", s.scanCIFS)
|
|
r.Get("/scan/iscsi", s.scanISCSI)
|
|
r.Get("/scan/lvm", s.scanLVM)
|
|
r.Get("/scan/zfs", s.scanZFS)
|
|
r.Get("/scan/glusterfs", s.scanGlusterFS)
|
|
|
|
r.Get("/apt/updates", s.aptUpdates)
|
|
r.Post("/apt/refresh", s.aptRefresh)
|
|
r.Post("/apt/upgrade", s.aptUpgrade)
|
|
|
|
r.Get("/ceph/status", s.cephStatus)
|
|
r.Get("/ceph/pools", s.cephPools)
|
|
r.Get("/ceph/osds", s.cephOSDs)
|
|
r.Get("/ceph/mon", s.cephMons)
|
|
r.Post("/ceph/mon", s.createCephMon)
|
|
r.Delete("/ceph/mon/{monid}", s.deleteCephMon)
|
|
r.Get("/ceph/mgr", s.cephMgrs)
|
|
r.Post("/ceph/mgr", s.createCephMgr)
|
|
r.Delete("/ceph/mgr/{mgrid}", s.deleteCephMgr)
|
|
r.Get("/ceph/fs", s.cephFilesystems)
|
|
r.Post("/ceph/fs", s.createCephFilesystem)
|
|
|
|
r.With(s.requireAdmin).Get("/dns", s.nodeDNS)
|
|
r.Put("/dns", s.updateNodeDNS)
|
|
r.With(s.requireAdmin).Get("/time", s.nodeTime)
|
|
r.Put("/time", s.updateNodeTimezone)
|
|
r.With(s.requireAdmin).Get("/hosts", s.nodeHosts)
|
|
r.Put("/hosts", s.updateNodeHosts)
|
|
|
|
r.With(s.requireAdmin).Get("/certificates", s.nodeCertificates)
|
|
r.Post("/certificates", s.uploadNodeCertificate)
|
|
r.Delete("/certificates", s.deleteNodeCertificate)
|
|
r.Post("/certificates/acme", s.orderAcmeCertificate)
|
|
r.Delete("/certificates/acme", s.revokeAcmeCertificate)
|
|
|
|
r.Delete("/cluster-membership", s.removeClusterNode)
|
|
|
|
r.Get("/tasks", s.nodeTasks)
|
|
r.Get("/tasks/{upid}/status", s.taskStatus)
|
|
r.Get("/tasks/{upid}/log", s.taskLog)
|
|
r.Delete("/tasks/{upid}", s.cancelTask)
|
|
|
|
r.Post("/replication/{repId}/run", s.scheduleReplicationNow)
|
|
})
|
|
|
|
r.Route("/cluster", func(r chi.Router) {
|
|
r.Get("/status", s.clusterStatus)
|
|
r.Get("/log", s.clusterLog)
|
|
r.Get("/tasks", s.clusterTasks)
|
|
r.Get("/firewall/rules", s.clusterFirewallRules)
|
|
r.Post("/firewall/rules", s.addClusterFirewallRule)
|
|
r.Delete("/firewall/rules/{pos}", s.deleteClusterFirewallRule)
|
|
|
|
r.Get("/firewall/aliases", s.clusterFirewallAliases)
|
|
r.Post("/firewall/aliases", s.addFirewallAlias)
|
|
r.Delete("/firewall/aliases/{name}", s.deleteFirewallAlias)
|
|
|
|
r.Get("/firewall/ipsets", s.clusterFirewallIPSets)
|
|
r.Post("/firewall/ipsets", s.addFirewallIPSet)
|
|
r.Delete("/firewall/ipsets/{name}", s.deleteFirewallIPSet)
|
|
r.Get("/firewall/ipsets/{name}/entries", s.firewallIPSetEntries)
|
|
r.Post("/firewall/ipsets/{name}/entries", s.addFirewallIPSetEntry)
|
|
r.Delete("/firewall/ipsets/{name}/entries", s.deleteFirewallIPSetEntry)
|
|
|
|
r.Get("/firewall/options", s.clusterFirewallOptions)
|
|
r.Put("/firewall/options", s.updateClusterFirewallOptions)
|
|
|
|
r.Get("/ha/resources", s.haResources)
|
|
r.Post("/ha/resources", s.addHAResource)
|
|
r.Put("/ha/resources/{sid}", s.updateHAResource)
|
|
r.Delete("/ha/resources/{sid}", s.removeHAResource)
|
|
r.Get("/ha/groups", s.haGroups)
|
|
r.Post("/ha/groups", s.createHAGroup)
|
|
r.Put("/ha/groups/{group}", s.updateHAGroup)
|
|
r.Delete("/ha/groups/{group}", s.deleteHAGroup)
|
|
r.Get("/ha/rules", s.listHARules)
|
|
r.Post("/ha/rules", s.createHARule)
|
|
r.Put("/ha/rules/{ruleId}", s.updateHARule)
|
|
r.Delete("/ha/rules/{ruleId}", s.deleteHARule)
|
|
r.Get("/ha/status", s.haStatus)
|
|
|
|
r.Get("/backup-jobs", s.backupJobs)
|
|
r.Post("/backup-jobs", s.createBackupJob)
|
|
r.Put("/backup-jobs/{jobId}", s.updateBackupJob)
|
|
r.Delete("/backup-jobs/{jobId}", s.deleteBackupJob)
|
|
r.Post("/backup-jobs/run", s.runBackupNow)
|
|
r.Get("/replication-jobs", s.replicationJobs)
|
|
r.Post("/replication-jobs", s.createReplicationJob)
|
|
r.Put("/replication-jobs/{repId}", s.updateReplicationJob)
|
|
r.Delete("/replication-jobs/{repId}", s.deleteReplicationJob)
|
|
|
|
r.Route("/firewall/groups", func(r chi.Router) {
|
|
r.Get("/", s.firewallSecurityGroups)
|
|
r.Post("/", s.createFirewallSecurityGroup)
|
|
r.Route("/{name}", func(r chi.Router) {
|
|
r.Get("/", s.securityGroupRules)
|
|
r.Delete("/", s.deleteFirewallSecurityGroup)
|
|
r.Post("/rules", s.addSecurityGroupRule)
|
|
r.Delete("/rules/{pos}", s.deleteSecurityGroupRule)
|
|
})
|
|
})
|
|
|
|
r.Get("/options", s.datacenterOptions)
|
|
r.Put("/options", s.updateDatacenterOptions)
|
|
|
|
r.Get("/config/nodes", s.clusterConfigNodes)
|
|
r.Get("/config/join", s.clusterJoinInfo)
|
|
r.Post("/config", s.createCluster)
|
|
r.Post("/config/join", s.joinCluster)
|
|
|
|
r.Route("/sdn", func(r chi.Router) {
|
|
r.Post("/apply", s.applySDNConfig)
|
|
r.Route("/zones", func(r chi.Router) {
|
|
r.Get("/", s.sdnZones)
|
|
r.Post("/", s.createSDNZone)
|
|
r.Put("/{zone}", s.updateSDNZone)
|
|
r.Delete("/{zone}", s.deleteSDNZone)
|
|
})
|
|
r.Route("/vnets", func(r chi.Router) {
|
|
r.Get("/", s.sdnVnets)
|
|
r.Post("/", s.createSDNVnet)
|
|
r.Delete("/{vnet}", s.deleteSDNVnet)
|
|
r.Get("/{vnet}/subnets", s.sdnSubnets)
|
|
r.Post("/{vnet}/subnets", s.createSDNSubnet)
|
|
r.Delete("/{vnet}/subnets", s.deleteSDNSubnet)
|
|
})
|
|
r.Route("/controllers", func(r chi.Router) {
|
|
r.Get("/", s.sdnControllers)
|
|
r.Post("/", s.createSDNController)
|
|
r.Put("/{controller}", s.updateSDNController)
|
|
r.Delete("/{controller}", s.deleteSDNController)
|
|
})
|
|
r.Route("/ipams", func(r chi.Router) {
|
|
r.Get("/", s.sdnIPAMs)
|
|
r.Post("/", s.createSDNIPAM)
|
|
r.Put("/{ipam}", s.updateSDNIPAM)
|
|
r.Delete("/{ipam}", s.deleteSDNIPAM)
|
|
})
|
|
})
|
|
})
|
|
|
|
r.Route("/storage", func(r chi.Router) {
|
|
r.Post("/", s.createStorage)
|
|
r.Put("/{storage}", s.updateStorage)
|
|
r.Delete("/{storage}", s.deleteStorage)
|
|
})
|
|
|
|
r.Route("/access", func(r chi.Router) {
|
|
r.Get("/users", s.pveAccessUsers)
|
|
r.Get("/roles", s.pveAccessRoles)
|
|
r.Get("/acl", s.pveAccessACL)
|
|
r.Get("/domains", s.pveAccessDomains)
|
|
})
|
|
|
|
r.Route("/pools", func(r chi.Router) {
|
|
r.Get("/", s.listPools)
|
|
r.Post("/", s.createPool)
|
|
r.Route("/{poolId}", func(r chi.Router) {
|
|
r.Get("/", s.poolDetail)
|
|
r.Put("/members", s.setPoolMembers)
|
|
r.Delete("/", s.deletePool)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
r.Route("/inventory", func(r chi.Router) {
|
|
r.Get("/", s.inventoryOverview)
|
|
})
|
|
|
|
r.Get("/overview", s.fleetOverviewHandler)
|
|
|
|
r.Route("/tags", func(r chi.Router) {
|
|
r.Get("/", s.listTags)
|
|
})
|
|
r.Get("/search", s.globalSearch)
|
|
r.Route("/bulk", func(r chi.Router) {
|
|
r.With(s.requireAdmin).Post("/guests/action", s.bulkGuestAction)
|
|
})
|
|
|
|
r.Get("/health-score", s.fleetHealthScore)
|
|
|
|
r.Route("/forecast", func(r chi.Router) {
|
|
r.Get("/capacity-warnings", s.fleetCapacityWarnings)
|
|
})
|
|
|
|
r.Route("/dashboards", func(r chi.Router) {
|
|
r.Get("/", s.listDashboards)
|
|
r.Post("/", s.createDashboard)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", s.getDashboard)
|
|
r.Put("/", s.updateDashboard)
|
|
r.Delete("/", s.deleteDashboard)
|
|
})
|
|
})
|
|
|
|
r.Route("/profile/sessions", func(r chi.Router) {
|
|
r.Get("/", s.listMySessions)
|
|
r.Delete("/", s.revokeMyOtherSessions)
|
|
r.Delete("/{id}", s.revokeMySession)
|
|
})
|
|
|
|
r.Route("/users", func(r chi.Router) {
|
|
r.Use(s.requireAdmin)
|
|
r.Get("/", s.listUsers)
|
|
r.Post("/", s.createUser)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Put("/", s.updateUser)
|
|
r.Delete("/", s.deleteUser)
|
|
r.Route("/sessions", func(r chi.Router) {
|
|
r.Get("/", s.listUserSessions)
|
|
r.Delete("/", s.revokeUserSessions)
|
|
r.Delete("/{sessionId}", s.revokeUserSession)
|
|
})
|
|
})
|
|
})
|
|
r.Route("/admin", func(r chi.Router) {
|
|
r.Use(s.requireAdmin)
|
|
r.Get("/roles", s.listRoles)
|
|
r.Get("/audit", s.auditLog)
|
|
|
|
r.Route("/settings", func(r chi.Router) {
|
|
r.Get("/oidc", s.getOIDCSettings)
|
|
r.Put("/oidc", s.putOIDCSettings)
|
|
r.Get("/notifications", s.getNotificationSettings)
|
|
r.Put("/notifications", s.putNotificationSettings)
|
|
r.Post("/notifications/test", s.testNotification)
|
|
r.Get("/security", s.getSecuritySettings)
|
|
r.Put("/security", s.putSecuritySettings)
|
|
r.Get("/defaults", s.getDefaultPreferences)
|
|
r.Put("/defaults", s.putDefaultPreferences)
|
|
r.Get("/agent", s.getAgentSettings)
|
|
r.Put("/agent", s.putAgentSettings)
|
|
r.Get("/system", s.getSystemSettings)
|
|
r.Put("/system", s.putSystemSettings)
|
|
r.Get("/digest", s.getDigestSettings)
|
|
r.Put("/digest", s.putDigestSettings)
|
|
r.Post("/digest/send-now", s.sendDigestNow)
|
|
|
|
r.Route("/ai/providers", func(r chi.Router) {
|
|
r.Get("/", s.listAIProviders)
|
|
r.Post("/", s.createAIProvider)
|
|
r.Post("/test", s.testAIProviderAdHoc)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Put("/", s.updateAIProvider)
|
|
r.Delete("/", s.deleteAIProvider)
|
|
r.Post("/test", s.testAIProvider)
|
|
r.Route("/models", func(r chi.Router) {
|
|
r.Post("/", s.addAIModel)
|
|
r.Put("/{modelId}", s.updateAIModel)
|
|
r.Delete("/{modelId}", s.deleteAIModel)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
r.Get("/ai/activity", s.adminAIActivity)
|
|
})
|
|
|
|
r.Route("/ai", func(r chi.Router) {
|
|
r.Get("/providers", s.listUsableAIProviders)
|
|
r.With(s.rateLimitAIChat).Post("/chat", s.aiChat)
|
|
r.Get("/activity", s.aiActivity)
|
|
})
|
|
// Compatibility: the frontend calls /roles and /audit directly;
|
|
// keep the un-prefixed paths working but admin-only.
|
|
r.Route("/roles", func(r chi.Router) {
|
|
r.Use(s.requireAdmin)
|
|
r.Get("/", s.listRoles)
|
|
})
|
|
r.Route("/audit", func(r chi.Router) {
|
|
r.Use(s.requireAdmin)
|
|
r.Get("/", s.auditLog)
|
|
})
|
|
|
|
r.Route("/alert-rules", func(r chi.Router) {
|
|
r.Use(s.requireAdminForMutations)
|
|
r.Get("/", s.listAlertRules)
|
|
r.Post("/", s.createAlertRule)
|
|
r.Put("/{id}", s.updateAlertRule)
|
|
r.Delete("/{id}", s.deleteAlertRule)
|
|
})
|
|
r.Route("/alerts", func(r chi.Router) {
|
|
r.Get("/", s.listAlerts)
|
|
r.Get("/summary", s.alertsSummary)
|
|
r.With(s.requireAdminForMutations).Post("/{id}/silence", s.silenceAlert)
|
|
r.With(s.requireAdminForMutations).Post("/{id}/unsilence", s.unSilenceAlert)
|
|
})
|
|
r.Get("/connection-health", s.connectionHealth)
|
|
|
|
// Direct SSH shell — not scoped to any /connections/{id}, since
|
|
// the target host/credentials are supplied by the caller, not
|
|
// looked up from a stored Proxmox connection. Same admin gate as
|
|
// the Proxmox console/shell routes above (arbitrary shell access
|
|
// either way).
|
|
r.Route("/ssh", func(r chi.Router) {
|
|
r.Use(s.requireAdmin)
|
|
r.Post("/sessions", s.openSSHShell)
|
|
})
|
|
|
|
// Server-Sent Events stream of bus activity (alert
|
|
// triggers/resolutions, connection health, ...) — see
|
|
// internal/api/events.go. The handler blocks on r.Context().Done()
|
|
// for its lifetime; the router-wide middleware.Timeout above only
|
|
// cancels that context, it doesn't itself cut the connection.
|
|
r.Get("/events", s.streamEvents)
|
|
|
|
r.Route("/settings/webhooks", func(r chi.Router) {
|
|
r.Use(s.requireAdmin)
|
|
r.Get("/", s.listWebhooks)
|
|
r.Post("/", s.createWebhook)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Put("/", s.updateWebhook)
|
|
r.Delete("/", s.deleteWebhook)
|
|
r.Post("/test", s.testWebhook)
|
|
r.Get("/deliveries", s.listWebhookDeliveries)
|
|
})
|
|
})
|
|
|
|
r.Route("/drift", func(r chi.Router) {
|
|
r.Get("/summary", s.driftSummary)
|
|
})
|
|
|
|
r.Route("/lifecycle", func(r chi.Router) {
|
|
r.Get("/actions", s.lifecycleActions)
|
|
})
|
|
r.Route("/settings/lifecycle", func(r chi.Router) {
|
|
r.Use(s.requireAdminForMutations)
|
|
r.Get("/", s.getLifecycleSettings)
|
|
r.Put("/", s.putLifecycleSettings)
|
|
})
|
|
})
|
|
})
|
|
|
|
// WebSocket console proxy — auth is validated via the console session ticket, not the cookie.
|
|
r.Get("/ws/console/{sessionId}", s.consoleWebSocket)
|
|
r.Get("/ws/ssh/{sessionId}", s.sshWebSocket)
|
|
|
|
// MCP (Model Context Protocol) endpoint for external tools like Claude —
|
|
// these clients carry an API key, never a browser cookie, so this route
|
|
// lives outside the cookie-based /api/v1 group and enforces its own
|
|
// bearer-only auth (see mcpAuth).
|
|
r.Post("/mcp", s.mcpAuth(s.mcpHandler))
|
|
|
|
r.NotFound(s.serveSPA)
|
|
|
|
return r
|
|
}
|
|
|
|
// health is a liveness+readiness probe: it reports "ok" only if the database
|
|
// is actually reachable, so an orchestrator restarting a wedged container
|
|
// (rather than just an alive-but-stuck process) can tell the difference.
|
|
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
|
defer cancel()
|
|
if err := s.db.PingContext(ctx); err != nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "unavailable", "error": "database unreachable"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// --- context helpers ---
|
|
|
|
type ctxKey string
|
|
|
|
const userCtxKey ctxKey = "user"
|
|
|
|
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user, err := s.authenticateRequest(r)
|
|
if err != nil {
|
|
writeErrorMsg(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// authenticateRequest resolves either the session cookie (browser SPA) or an
|
|
// API key (3rd-party apps, MCP clients — see internal/api/apikeys.go) into
|
|
// the user making the request. The cookie is checked first since it's the
|
|
// overwhelmingly common case and requires no header parsing.
|
|
func (s *Server) authenticateRequest(r *http.Request) (*auth.User, error) {
|
|
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
|
|
if user, err := s.auth.Authenticate(r.Context(), cookie.Value); err == nil {
|
|
return user, nil
|
|
}
|
|
}
|
|
if key := bearerAPIKey(r); key != "" {
|
|
agentSettings, err := s.loadAgentSettings(r.Context())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !agentSettings.apiEnabled {
|
|
return nil, auth.ErrInvalidCredentials
|
|
}
|
|
return s.auth.AuthenticateAPIKey(r.Context(), key)
|
|
}
|
|
return nil, auth.ErrInvalidCredentials
|
|
}
|
|
|
|
// bearerAPIKey extracts an API key from the Authorization header ("Bearer
|
|
// <key>") or the X-API-Key header, for clients that can't set Authorization.
|
|
func bearerAPIKey(r *http.Request) string {
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
|
return strings.TrimPrefix(h, "Bearer ")
|
|
}
|
|
return r.Header.Get("X-API-Key")
|
|
}
|
|
|
|
// requireTOTPEnrolled blocks admin accounts without TOTP enabled from every
|
|
// route in the group it's mounted on, when the admin-configured "require
|
|
// 2FA for admins" setting is on. Deliberately mounted only on the general
|
|
// protected group (/connections, /users, /dashboards, ...) — the /auth
|
|
// route tree (2fa/enroll, /auth/me, /auth/logout) lives in a separate group
|
|
// specifically so a blocked admin can still reach enrollment and sign out.
|
|
func (s *Server) requireTOTPEnrolled(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u := userFromContext(r)
|
|
if s.getRequire2FAAdmins() && u != nil && u.IsAdmin && !u.TOTPEnabled {
|
|
writeErrorCode(w, http.StatusForbidden, "totp_required", "your administrator requires two-factor authentication — enable it on your profile to continue")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u := userFromContext(r)
|
|
if u == nil || !u.IsAdmin {
|
|
writeErrorMsg(w, http.StatusForbidden, "admin access required")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requireAdminForMutations lets read-only requests (GET/HEAD/OPTIONS) through
|
|
// for any authenticated user but requires admin for anything that changes
|
|
// state. Applied to route trees that proxy live infrastructure.
|
|
func (s *Server) requireAdminForMutations(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
u := userFromContext(r)
|
|
if u == nil || !u.IsAdmin {
|
|
writeErrorMsg(w, http.StatusForbidden, "admin access required for this operation")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requestLogger emits one structured access-log line per request (method,
|
|
// path, status, duration, request ID) via slog, so ad-hoc handler-level
|
|
// slog calls aren't the only record of what the server did.
|
|
func (s *Server) requestLogger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
start := time.Now()
|
|
next.ServeHTTP(ww, r)
|
|
slog.Info("http request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", ww.Status(),
|
|
"bytes", ww.BytesWritten(),
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
"remote", r.RemoteAddr,
|
|
)
|
|
})
|
|
}
|
|
|
|
// cors applies the admin-configured CORS allow-list (system_settings.
|
|
// cors_allowed_origins — see SetCORSOrigins) so a browser-based 3rd-party
|
|
// app can call the REST API with a bearer token from another origin. Off by
|
|
// default: an empty allow-list means no Access-Control-* headers are set at
|
|
// all, identical to pre-existing same-origin-only behavior. The SPA itself
|
|
// is unaffected either way since it's always same-origin.
|
|
func (s *Server) cors(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if s.corsOriginAllowed(origin) {
|
|
h := w.Header()
|
|
h.Set("Access-Control-Allow-Origin", origin)
|
|
h.Set("Vary", "Origin")
|
|
h.Set("Access-Control-Allow-Credentials", "true")
|
|
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key")
|
|
h.Set("Access-Control-Max-Age", "600")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// originCheck is CSRF defense for every mutating route under /api/v1,
|
|
// complementing the session cookie. The threat: a cross-site form POST —
|
|
// login CSRF above all, which SameSite=Lax does not prevent for top-level
|
|
// navigations — would otherwise ride the victim's ambient cookie (or plant
|
|
// an attacker-chosen session via a forged login). Browsers mark cross-site
|
|
// requests with an Origin header (all POSTs) or Sec-Fetch-Site; curl and
|
|
// API-key clients send neither and are unaffected, as are the bearer-only
|
|
// MCP endpoint (outside this group) and the /ws console (single-use
|
|
// tickets). A configured CORS origin is allowed so a 3rd-party browser app
|
|
// keeps working.
|
|
func (s *Server) originCheck(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
|
default:
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
u, err := url.Parse(origin)
|
|
switch {
|
|
case err != nil || u.Host == "":
|
|
writeErrorMsg(w, http.StatusForbidden, "cross-origin request blocked")
|
|
case strings.EqualFold(u.Host, s.requestHost(r)) || s.corsOriginAllowed(origin):
|
|
next.ServeHTTP(w, r)
|
|
default:
|
|
writeErrorMsg(w, http.StatusForbidden, "cross-origin request blocked")
|
|
}
|
|
return
|
|
}
|
|
// No Origin (some browsers omit it on navigations): Sec-Fetch-Site
|
|
// is the newer and more explicit marker, sent on every fetch.
|
|
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
|
writeErrorMsg(w, http.StatusForbidden, "cross-origin request blocked")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requestHost is the host the client believes it is talking to. Behind a
|
|
// proxy the proxy may rewrite Host, so X-Forwarded-Host — when the
|
|
// deployment declared one is in front — is the externally visible host an
|
|
// Origin should be compared against.
|
|
func (s *Server) requestHost(r *http.Request) string {
|
|
if s.options.BehindProxy {
|
|
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
|
if i := strings.Index(fh, ","); i >= 0 { // multi-hop proxies append; the client-facing host comes first
|
|
fh = fh[:i]
|
|
}
|
|
return strings.TrimSpace(fh)
|
|
}
|
|
}
|
|
return r.Host
|
|
}
|
|
|
|
// rateLimitAIChat caps how often the authenticated caller can start a new
|
|
// /ai/chat completion — see aiChatMaxPerMinute. Keyed by user ID, so it
|
|
// applies the same whether the caller is the browser SPA (session cookie)
|
|
// or a 3rd-party app using an API key.
|
|
func (s *Server) rateLimitAIChat(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u := userFromContext(r)
|
|
key := r.RemoteAddr
|
|
if u != nil {
|
|
key = u.ID
|
|
}
|
|
if allowed, retryAfter := s.aiChatLimit.Allow(key); !allowed {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
|
|
writeErrorMsg(w, http.StatusTooManyRequests, "too many AI chat requests — slow down and try again shortly")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// securityHeaders sets the baseline browser-protection headers on every
|
|
// response. The CSP allows inline styles (React/Recharts set style
|
|
// attributes at runtime) and same-origin WebSockets (the console proxy),
|
|
// but no inline scripts — the SPA is fully served from /assets/.
|
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
h.Set("Content-Security-Policy", s.csp(r))
|
|
if s.cookieSecure(r) {
|
|
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// csp builds the per-request Content-Security-Policy. connect-src pins the
|
|
// WebSocket schemes to the request's own Host instead of a protocol-wide
|
|
// wildcard — the only WS client is the console proxy dialing back to the
|
|
// origin the page was served from. The Host is sanitized to a conservative
|
|
// charset before embedding so a crafted Host header can't smuggle extra CSP
|
|
// directives (header injection); with nothing safe to pin, it falls back to
|
|
// the old `ws: wss:` wildcards.
|
|
func (s *Server) csp(r *http.Request) string {
|
|
wsSrc := "ws: wss:"
|
|
if host := sanitizeCSPHost(s.requestHost(r)); host != "" {
|
|
wsSrc = "ws://" + host + " wss://" + host
|
|
}
|
|
return "default-src 'self'; " +
|
|
"script-src 'self'; " +
|
|
"style-src 'self' 'unsafe-inline'; " +
|
|
"img-src 'self' data: blob:; " +
|
|
// data: is needed for the Topology page's SVG export
|
|
// (html-to-image inlines @font-face as base64 data URIs so
|
|
// the exported file is self-contained) — without it the
|
|
// browser blocks that inlined @font-face while rendering
|
|
// the export's foreignObject content.
|
|
"font-src 'self' data:; " +
|
|
"connect-src 'self' " + wsSrc + "; " +
|
|
"object-src 'none'; " +
|
|
"frame-ancestors 'none'; " +
|
|
"base-uri 'self'; " +
|
|
"form-action 'self'"
|
|
}
|
|
|
|
// sanitizeCSPHost keeps only characters a real Host can contain (letters,
|
|
// digits, dots, hyphens, colons for the port, brackets for IPv6 literals) —
|
|
// anything else means the value isn't safe to embed in a response header.
|
|
func sanitizeCSPHost(host string) string {
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
for _, r := range host {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
|
r == '.', r == '-', r == ':', r == '[', r == ']':
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
return host
|
|
}
|
|
|
|
func userFromContext(r *http.Request) *auth.User {
|
|
u, _ := r.Context().Value(userCtxKey).(*auth.User)
|
|
return u
|
|
}
|
|
|
|
// audit records a best-effort audit log entry; failures are swallowed since
|
|
// auditing must never block the primary action.
|
|
func (s *Server) audit(r *http.Request, action, category, target string) {
|
|
u := userFromContext(r)
|
|
var userID string
|
|
if u != nil {
|
|
userID = u.ID
|
|
}
|
|
s.auditEntry(r.Context(), userID, action, category, target, r.RemoteAddr)
|
|
}
|
|
|
|
// auditEntry is the shared insert behind both audit (REST handlers, which
|
|
// have an *http.Request for the IP) and the MCP server's AuditFunc (which
|
|
// doesn't — see mcp.New below, wired with ip="mcp").
|
|
func (s *Server) auditEntry(ctx context.Context, userID, action, category, target, ip string) {
|
|
var userIDCol *string
|
|
if userID != "" {
|
|
userIDCol = &userID
|
|
}
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`INSERT INTO audit_log (id, user_id, action, category, target, ip, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
uuid.NewString(), userIDCol, action, category, target, ip, time.Now().UTC().Format(time.RFC3339),
|
|
)
|
|
}
|
|
|
|
// --- JSON helpers ---
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// writeError maps an error to a client response. Upstream Proxmox 4xx
|
|
// rejections carry an actionable message ("bad prune options", an unknown
|
|
// parameter) — their status and message pass through so the user can fix
|
|
// what they sent. Upstream 5xx and any local 5xx stay sanitized to a generic
|
|
// message: an upstream 500 body can be a proxy error page or stack trace,
|
|
// and local errors can carry SQL fragments, file paths, or driver errors —
|
|
// the details are logged server-side instead. An upstream 401 also keeps the
|
|
// caller's status: the dashboard frontend signs the user out on any 401, and
|
|
// an expired PBS/PVE ticket must read as a broken connection, not log them
|
|
// out of Ferrum.
|
|
func (s *Server) writeError(w http.ResponseWriter, status int, err error) {
|
|
var pveErr *pve.StatusError
|
|
if errors.As(err, &pveErr) {
|
|
s.writeUpstreamError(w, status, pveErr.StatusCode, pveErr.Message(), err)
|
|
return
|
|
}
|
|
var pbsErr *pbs.StatusError
|
|
if errors.As(err, &pbsErr) {
|
|
s.writeUpstreamError(w, status, pbsErr.StatusCode, pbsErr.Message(), err)
|
|
return
|
|
}
|
|
if status >= 500 {
|
|
slog.Error("request failed", "status", status, "error", err)
|
|
writeErrorMsg(w, status, "internal server error")
|
|
return
|
|
}
|
|
writeErrorMsg(w, status, err.Error())
|
|
}
|
|
|
|
// writeUpstreamError responds for an upstream Proxmox StatusError: 4xx gets
|
|
// the upstream status and message (except 401, which must not surface as a
|
|
// dashboard-session 401 — see writeError), everything else stays sanitized —
|
|
// a 5xx body can be a proxy error page or a raw backend stack trace/panic,
|
|
// not necessarily the clean "guest is locked" kind of message a 4xx carries,
|
|
// so it's logged server-side instead of shown to the user.
|
|
func (s *Server) writeUpstreamError(w http.ResponseWriter, status, upstreamStatus int, upstreamMsg string, err error) {
|
|
if upstreamStatus < 400 || upstreamStatus >= 500 {
|
|
slog.Error("request failed", "status", status, "error", err)
|
|
writeErrorMsg(w, status, "internal server error")
|
|
return
|
|
}
|
|
if upstreamStatus == http.StatusUnauthorized {
|
|
writeErrorMsg(w, status, upstreamMsg)
|
|
return
|
|
}
|
|
writeErrorMsg(w, upstreamStatus, upstreamMsg)
|
|
}
|
|
|
|
func writeErrorMsg(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// writeErrorCode is writeErrorMsg plus a machine-readable code the frontend
|
|
// can switch on (e.g. "totp_required" to redirect to the enrollment page)
|
|
// without parsing the human-readable message.
|
|
func writeErrorCode(w http.ResponseWriter, status int, code, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg, "code": code})
|
|
}
|