mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-21 21:54:52 -04:00
Distributed mode has not dialled a message broker since the control plane moved onto the workers' own outward tunnels and every fan-out family moved onto PostgreSQL LISTEN/NOTIFY. What was left was the dependency itself, and the code that existed only to feed it. Dropped from go.mod: nats-io/jwt/v2, nats-io/nats.go, nats-io/nkeys, nats-io/nuid and testcontainers-go/modules/nats, along with the fourteen indirect requires that only the NATS testcontainer pulled in. go.sum carries no nats line either, so the removal is not the partial kind where the require goes and the checksum stays. Deleted with them: pkg/natsauth in full, the broker client's remaining options and TLS files, the per-node JWT minting on both the register and the approve path, and the natsauth.Config parameter threaded through the node routes. The credential manager is renamed and stripped rather than deleted, because it still holds the tunnel token that every re-registration rotates. The bus flags stay accepted and ignored, and are now hidden, on every command that had them, so an existing unit file, compose file or Helm values file still starts on the day of the upgrade. What is not kept is the validation that REQUIRED one: a distributed frontend started with no bus URL is no longer fatal. The TLS paths lose type:"existingfile" deliberately, so a certificate deleted along with the broker cannot fail a startup. One operator-visible behaviour change: --nats-require-auth no longer makes an agent worker wait through admin approval. Ask for that wait with --distributed-require-auth, which already implied it. It is documented in the migration section and pinned from both sides. A deployment now needs PostgreSQL and the frontends' own HTTP listener, and nothing else. coverage-baseline.txt moves from 54.2 to 62.0. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
165 lines
7.8 KiB
Go
165 lines
7.8 KiB
Go
package routes
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
|
"github.com/mudler/LocalAI/core/services/galleryop"
|
|
"github.com/mudler/LocalAI/core/services/nodes"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// nodeReadyMiddleware returns middleware that checks the node registry is available.
|
|
func nodeReadyMiddleware(registry *nodes.NodeRegistry) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if registry == nil {
|
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
|
"error": "distributed mode not enabled",
|
|
})
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RegisterNodeSelfServiceRoutes registers /api/node/ endpoints used by backend
|
|
// nodes themselves (register, heartbeat, drain, query own models, deregister).
|
|
// These are authenticated via the registration token, not admin middleware.
|
|
//
|
|
// TODO(security): Node self-service endpoints authenticate via shared registration
|
|
// token but do not verify per-node identity. A compromised worker can heartbeat/drain/
|
|
// deregister other nodes. Future: issue per-node JWT at registration, validate node
|
|
// identity on subsequent requests (compare :id param with token subject).
|
|
func RegisterNodeSelfServiceRoutes(e *echo.Echo, registry *nodes.NodeRegistry, registrationToken string, autoApprove bool, authDB *gorm.DB, hmacSecret string) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
|
|
readyMw := nodeReadyMiddleware(registry)
|
|
tokenAuthMw := nodeTokenAuth(registrationToken)
|
|
|
|
node := e.Group("/api/node", readyMw, tokenAuthMw)
|
|
node.POST("/register", localai.RegisterNodeEndpoint(registry, registrationToken, autoApprove, authDB, hmacSecret))
|
|
node.POST("/:id/heartbeat", localai.HeartbeatEndpoint(registry))
|
|
node.POST("/:id/drain", localai.DrainNodeEndpoint(registry))
|
|
node.POST("/:id/resume", localai.ResumeNodeEndpoint(registry))
|
|
node.POST("/:id/deregister", localai.DeactivateNodeEndpoint(registry))
|
|
node.GET("/:id/models", localai.GetNodeModelsEndpoint(registry))
|
|
node.DELETE("/:id", localai.DeactivateNodeEndpoint(registry))
|
|
}
|
|
|
|
// RegisterNodeAdminRoutes registers /api/nodes/ endpoints used by admins
|
|
// (list, get, get models, drain, delete, approve, backend management). Protected by admin middleware.
|
|
//
|
|
// galleryService/opcache/appConfig are threaded in for the async node-scoped
|
|
// backend install path (POST /:id/backends/install). That handler enqueues a
|
|
// ManagementOp on the gallery channel rather than blocking on a NATS reply, so
|
|
// the browser gets HTTP 202 + jobID immediately instead of waiting up to 3 minutes.
|
|
//
|
|
// workerDialFor is how the log-proxy routes reach a worker's own HTTP server:
|
|
// over the tunnel that worker holds, never by connecting to the address it
|
|
// registered. It is nil outside distributed mode, and those two routes then
|
|
// answer 502 rather than dialling, because a worker with no tunnel has nothing
|
|
// for them to proxy to.
|
|
func RegisterNodeAdminRoutes(e *echo.Echo, registry *nodes.NodeRegistry, unloader nodes.NodeCommandSender, galleryService *galleryop.GalleryService, opcache *galleryop.OpCache, appConfig *config.ApplicationConfig, adminMw echo.MiddlewareFunc, authDB *gorm.DB, hmacSecret string, registrationToken string, workerDialFor nodes.WorkerNetDialerFor) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
|
|
readyMw := nodeReadyMiddleware(registry)
|
|
|
|
admin := e.Group("/api/nodes", readyMw, adminMw)
|
|
admin.GET("", localai.ListNodesEndpoint(registry))
|
|
|
|
// Cluster-wide loaded models (registered before /:id to avoid route conflicts)
|
|
admin.GET("/models", localai.ListAllNodeModelsEndpoint(registry))
|
|
|
|
// Model scheduling (registered before /:id to avoid route conflicts)
|
|
admin.GET("/scheduling", localai.ListSchedulingEndpoint(registry))
|
|
admin.GET("/scheduling/:model", localai.GetSchedulingEndpoint(registry))
|
|
admin.POST("/scheduling", localai.SetSchedulingEndpoint(registry))
|
|
admin.DELETE("/scheduling/:model", localai.DeleteSchedulingEndpoint(registry))
|
|
|
|
admin.GET("/:id", localai.GetNodeEndpoint(registry))
|
|
admin.GET("/:id/models", localai.GetNodeModelsEndpoint(registry))
|
|
admin.DELETE("/:id", localai.DeregisterNodeEndpoint(registry))
|
|
admin.POST("/:id/drain", localai.DrainNodeEndpoint(registry))
|
|
admin.POST("/:id/resume", localai.ResumeNodeEndpoint(registry))
|
|
admin.POST("/:id/approve", localai.ApproveNodeEndpoint(registry, authDB, hmacSecret))
|
|
|
|
// Backend management on workers
|
|
admin.GET("/:id/backends", localai.ListBackendsOnNodeEndpoint(unloader, registry))
|
|
admin.POST("/:id/backends/install", localai.InstallBackendOnNodeEndpoint(unloader, galleryService, opcache, appConfig))
|
|
// Upgrade is a distinct route (not install) because the worker's
|
|
// backend.install handler short-circuits when the backend already exists
|
|
// on disk; only the Upgrade op path force-reinstalls.
|
|
admin.POST("/:id/backends/upgrade", localai.UpgradeBackendOnNodeEndpoint(galleryService, opcache, appConfig))
|
|
admin.POST("/:id/backends/delete", localai.DeleteBackendOnNodeEndpoint(unloader))
|
|
|
|
// Model management on workers
|
|
admin.POST("/:id/models/unload", localai.UnloadModelOnNodeEndpoint(unloader, registry))
|
|
admin.POST("/:id/models/delete", localai.DeleteModelOnNodeEndpoint(unloader, registry))
|
|
|
|
// Backend log streaming (proxied from worker HTTP server)
|
|
admin.GET("/:id/backend-logs", localai.NodeBackendLogsListEndpoint(registry, registrationToken, workerDialFor))
|
|
admin.GET("/:id/backend-logs/:modelId", localai.NodeBackendLogsLinesEndpoint(registry, registrationToken, workerDialFor))
|
|
|
|
// Label management
|
|
admin.GET("/:id/labels", localai.GetNodeLabelsEndpoint(registry))
|
|
admin.PUT("/:id/labels", localai.SetNodeLabelsEndpoint(registry))
|
|
admin.PATCH("/:id/labels", localai.MergeNodeLabelsEndpoint(registry))
|
|
admin.DELETE("/:id/labels/:key", localai.DeleteNodeLabelEndpoint(registry))
|
|
|
|
// Per-node replica capacity. PUT sets a sticky admin override that
|
|
// survives worker restarts. DELETE clears the override so the worker's
|
|
// CLI flag takes over again at the next re-registration.
|
|
admin.PUT("/:id/max-replicas-per-model", localai.UpdateMaxReplicasPerModelEndpoint(registry))
|
|
admin.DELETE("/:id/max-replicas-per-model", localai.ResetMaxReplicasPerModelEndpoint(registry))
|
|
|
|
// Per-node VRAM allocation budget. PUT sets a sticky admin override that
|
|
// survives worker restarts; DELETE clears it so the worker's reported
|
|
// budget takes over again at the next re-registration.
|
|
admin.PUT("/:id/vram-budget", localai.UpdateVRAMBudgetEndpoint(registry))
|
|
admin.DELETE("/:id/vram-budget", localai.ResetVRAMBudgetEndpoint(registry))
|
|
|
|
// WebSocket proxy for real-time log streaming from workers
|
|
e.GET("/ws/nodes/:id/backend-logs/:modelId", localai.NodeBackendLogsWSEndpoint(registry, registrationToken, workerDialFor), readyMw, adminMw)
|
|
}
|
|
|
|
// nodeTokenAuth validates the registration token for node self-service endpoints.
|
|
// When registrationToken is empty (single-node / non-distributed mode), these
|
|
// endpoints are unprotected. This is intentional: in single-node mode there are
|
|
// no remote workers to authenticate. Operators enabling distributed mode MUST
|
|
// set a registration token via LOCALAI_REGISTRATION_TOKEN or config.
|
|
//
|
|
// It validates the token from an Authorization: Bearer <token> header using
|
|
// constant-time comparison.
|
|
func nodeTokenAuth(registrationToken string) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if registrationToken == "" {
|
|
return next(c)
|
|
}
|
|
|
|
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
|
"error": "missing or invalid Authorization header",
|
|
})
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(token), []byte(registrationToken)) != 1 {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{
|
|
"error": "invalid registration token",
|
|
})
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|