mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-22 06:04:55 -04:00
GET /api/cluster/peer authenticated with the deployment's shared registration token and took the dialling replica's id from ?id= on trust. Every worker holds that token, so anything holding it could open a peer link as any replica: relay through it to every worker tunnel that replica owns, displace a real replica's inbound link by declaring its id, and point the roughly 31 GiB per-session receive window at one replica. Validating the id against the instances table does not fix this, because the attack declares a real replica's id. So the route now checks two credentials and needs both. The shared token still says the dialler belongs to this deployment; a new per-replica credential says which replica it is. The credential follows the per-node worker credential rather than inventing a second mechanism: crypto/rand.Text, stored only as a hex SHA-256, compared in constant time, with no fallback to the shared token. It differs in the stronger direction. A worker's credential is minted by the frontend and handed over once; a replica writes its own instances row, so it mints its own secret, publishes only the hash in the same statement that publishes its address, and never sends the plaintext anywhere but the peer dial. A peer that presents no credential is refused, not waved through. An old replica and an attacker holding the shared token send the same request, so accepting the first accepts the second; there is no safe downgrade here, only a quiet one. The refusal is made loud instead, on both sides, naming the upgrade rather than the network. On the documented frontend-first order a new replica still dials an old one; an old replica cannot dial a new one, which costs relayed requests that land on a not-yet-restarted replica and surfaces as no route, never as absence. A rejected peer gets its own sentinel, ErrPeerRejected, whose unwrap chain carries ErrPeerUnreachable as well and no absence sentinel at all. Keeping the older sentinel means no existing consumer changes behaviour; the cause stays out of the chain, so absence cannot escape through it and nothing can read an authorization failure as a worker that went away. One consequence beyond the fix: a replica with no advertised address has no instances row, so it now cannot dial out either. It was already unreachable inward. The startup error and the docs say so. Registry.Register, NewMembership, NewPeerPool, PeerHandler and RegisterClusterRoutes all gained required arguments, so the identity cannot be dropped without a compile failure. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
52 lines
2.6 KiB
Go
52 lines
2.6 KiB
Go
package routes
|
|
|
|
import (
|
|
clusterep "github.com/mudler/LocalAI/core/http/endpoints/cluster"
|
|
clustersvc "github.com/mudler/LocalAI/core/services/cluster"
|
|
"github.com/mudler/LocalAI/core/services/nodes"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/libp2p/go-yamux/v5"
|
|
)
|
|
|
|
// RegisterClusterRoutes registers the replica-to-replica peer link. onPeer
|
|
// receives every authenticated session; see clusterep.PeerHandler for what it
|
|
// is expected to do with it.
|
|
//
|
|
// instances is what turns the ?id= on that route from a self-declared label
|
|
// into a claim: the handler resolves the id to a replica row and checks the
|
|
// dialler's own peer credential against the hash it publishes. It is a required
|
|
// argument rather than an option, so a deployment cannot register this route
|
|
// with nothing to verify against; a nil one makes the handler answer 503.
|
|
//
|
|
// The path is core/services/cluster's own constant, so the handler and the
|
|
// dialler cannot be registered and dialled at different paths. That the path
|
|
// also falls under auth.ClusterPathPrefix, and so bypasses the global session
|
|
// middleware, is asserted by driving a request through that middleware in
|
|
// core/http/endpoints/cluster/peer_test.go.
|
|
//
|
|
// The route carries no auth middleware: it authenticates itself against the
|
|
// cluster token, because a peer replica has no session and no user.
|
|
func RegisterClusterRoutes(e *echo.Echo, token string, instances *clustersvc.Registry, onPeer func(string, *yamux.Session)) {
|
|
e.GET(clustersvc.PeerPath, clusterep.PeerHandler(token, instances, onPeer))
|
|
}
|
|
|
|
// RegisterWorkerTunnelRoute registers the endpoint a worker dials to open its
|
|
// tunnel. registry authenticates the dial against the node's own stored token;
|
|
// tunnels is what the resulting session is attached to.
|
|
//
|
|
// Unlike the peer link this is registered in EVERY deployment, single-binary
|
|
// ones included, and both arguments may be nil there. Two reasons. The handler
|
|
// fails closed without a registry, since a token can only be checked against a
|
|
// node row and there are none; and being registered unconditionally is what
|
|
// puts the route in front of the route-coverage test under build tag `auth`,
|
|
// which is the thing that holds the reject-before-upgrade rule in place. A
|
|
// route registered only in distributed mode is invisible to that test.
|
|
//
|
|
// Like the peer link, it carries no auth middleware and derives its path from
|
|
// core/services/cluster's own constant, so the handler and the worker's dialler
|
|
// cannot end up on different paths.
|
|
func RegisterWorkerTunnelRoute(e *echo.Echo, registry *nodes.NodeRegistry, tunnels *clustersvc.TunnelRegistry) {
|
|
e.GET(clustersvc.ConnectPath, clusterep.ConnectHandler(registry, tunnels))
|
|
}
|