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

180 lines
4.9 KiB
Go

package api
import (
"sync"
"time"
)
// loginLimiter throttles authentication attempts per client IP + username.
// After too many failures inside the window the key is locked out until the
// window elapses, blunting online password guessing without new
// dependencies. State is in-memory: per-instance throttling is the right
// scope for a single-binary deployment.
type loginLimiter struct {
mu sync.Mutex
failed map[string]*failRecord
maxFailures int
window time.Duration
sweepInterval time.Duration
lastSweep time.Time
}
type failRecord struct {
failures int
firstFailure time.Time
lockedUntil time.Time
}
const (
loginMaxFailures = 5
loginWindow = 15 * time.Minute
loginSweepInterval = 5 * time.Minute
// aiChatMaxPerMinute bounds how many /ai/chat completions one user can
// start per minute — the endpoint proxies to a paid/rate-limited LLM
// provider, so an unbounded caller (buggy client, or a compromised
// session/API key) could otherwise run up cost or exhaust the
// provider's own rate limit for every other user.
aiChatMaxPerMinute = 20
)
func newLoginLimiter() *loginLimiter {
return &loginLimiter{
failed: map[string]*failRecord{},
maxFailures: loginMaxFailures,
window: loginWindow,
sweepInterval: loginSweepInterval,
lastSweep: time.Now(),
}
}
// Allowed reports whether an attempt for key may proceed. When locked, the
// returned retryAfter tells the client how long to wait.
func (l *loginLimiter) Allowed(key string) (allowed bool, retryAfter time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
l.sweepLocked()
rec, ok := l.failed[key]
if !ok {
return true, 0
}
now := time.Now()
if now.Before(rec.lockedUntil) {
return false, rec.lockedUntil.Sub(now)
}
// Window elapsed since the failures began — start fresh.
if now.Sub(rec.firstFailure) > l.window {
delete(l.failed, key)
return true, 0
}
if rec.failures >= l.maxFailures {
rec.lockedUntil = rec.firstFailure.Add(l.window)
return false, time.Until(rec.lockedUntil)
}
return true, 0
}
// RecordFailure counts a failed attempt, engaging the lockout once
// maxFailures is reached inside the window.
func (l *loginLimiter) RecordFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
l.sweepLocked()
now := time.Now()
rec, ok := l.failed[key]
if !ok || now.Sub(rec.firstFailure) > l.window {
l.failed[key] = &failRecord{failures: 1, firstFailure: now}
return
}
rec.failures++
if rec.failures >= l.maxFailures {
rec.lockedUntil = rec.firstFailure.Add(l.window)
}
}
// RecordSuccess clears the failure history after a successful login.
func (l *loginLimiter) RecordSuccess(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.failed, key)
}
// SetPolicy changes the failure threshold and window — applied to attempts
// evaluated from this point on; a key already locked out keeps its existing
// lockedUntil rather than being retroactively reinterpreted.
func (l *loginLimiter) SetPolicy(maxFailures int, window time.Duration) {
l.mu.Lock()
l.maxFailures = maxFailures
l.window = window
l.mu.Unlock()
}
// sweepLocked prunes stale entries so the map can't grow without bound.
// Caller must hold l.mu.
func (l *loginLimiter) sweepLocked() {
now := time.Now()
if now.Sub(l.lastSweep) < l.sweepInterval {
return
}
l.lastSweep = now
for key, rec := range l.failed {
if now.Sub(rec.firstFailure) > l.window && now.After(rec.lockedUntil) {
delete(l.failed, key)
}
}
}
// requestLimiter caps how many requests one key (typically a user ID) can
// make inside a fixed window — a hard ceiling, not smooth throttling, which
// is all endpoints proxying to a paid/rate-limited upstream (the AI chat's
// LLM provider) need: it bounds the cost/DoS blast radius of one runaway
// caller without needing a token-bucket's extra bookkeeping.
type requestLimiter struct {
mu sync.Mutex
counts map[string]*windowCount
max int
window time.Duration
sweepInterval time.Duration
lastSweep time.Time
}
type windowCount struct {
count int
windowFrom time.Time
}
func newRequestLimiter(max int, window time.Duration) *requestLimiter {
return &requestLimiter{counts: map[string]*windowCount{}, max: max, window: window, sweepInterval: window, lastSweep: time.Now()}
}
// Allow reports whether another request for key may proceed inside the
// current window, incrementing its count if so.
func (l *requestLimiter) Allow(key string) (allowed bool, retryAfter time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
if now.Sub(l.lastSweep) >= l.sweepInterval {
l.lastSweep = now
for k, c := range l.counts {
if now.Sub(c.windowFrom) > l.window {
delete(l.counts, k)
}
}
}
c, ok := l.counts[key]
if !ok || now.Sub(c.windowFrom) > l.window {
l.counts[key] = &windowCount{count: 1, windowFrom: now}
return true, 0
}
if c.count >= l.max {
return false, l.window - now.Sub(c.windowFrom)
}
c.count++
return true, 0
}