Files
LocalAI/core/http/endpoints/cluster/peer.go
T
Ettore Di Giacinto 3b19575f81 feat(cluster): give phase 1 a call site, and prove it against real replicas
Tasks 1 to 5 built an instances table, a splice, both halves of a peer link
and an epoch fence, and nothing in the tree called any of it: no replica
registered, no route was mounted, no sweeper ran. Proving phase 1 end to
end therefore had to start by wiring it.

A frontend in distributed mode now publishes the address its peers dial,
heartbeats it, and sweeps replicas that stopped answering along with the
connection rows they owned, in one pass so the two can never disagree about
who is alive. It serves the peer link and owns the sessions peers dial in,
refusing streams on them until phase 2 installs a relay: a session nobody
accepts on does not fail a peer's Open, it hangs it.

The address is the one peers use, not the one the process binds, and it is
derived from the route to PostgreSQL. That derivation only holds while the
database is remote, so LOCALAI_DISTRIBUTED_ADVERTISE_ADDR sets it
explicitly and a replica that can determine neither warns and keeps
serving rather than failing to start.

Three e2e scenarios run against real local-ai processes, real PostgreSQL
and real dials: replicas publish addresses that can actually be connected
to; a sibling opens a stream over the peer link and is refused without the
cluster token; and a killed replica is reported unreachable, never absent,
loses the claim it held, and takes no worker with it. Each was verified by
mutation: eight injected defects, each failing the scenario that claims to
catch it.

Also moves RegisterClusterRoutes to core/http/routes beside every other
registrar, folds AutoMigrate and the epoch sequence into one
cluster.Migrate, and turns the peer route's auth-coverage spec into a real
assertion: it drives the request through the actual auth middleware
instead of comparing two string constants, which the old spec would have
passed even with the exemption deleted.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-20 03:05:34 +00:00

103 lines
3.7 KiB
Go

// SPDX-License-Identifier: MIT
// Package cluster serves the replica-to-replica link that a LocalAI frontend
// uses to reach a worker tunnel it does not own. A peer dials
// GET /api/cluster/peer, the connection becomes one multiplexed yamux session,
// and the relay opens a stream on it per request.
package cluster
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/libp2p/go-yamux/v5"
clustersvc "github.com/mudler/LocalAI/core/services/cluster"
"github.com/mudler/xlog"
)
// PeerHandler upgrades an authenticated peer dial to a WebSocket, wraps it as
// a yamux server session and hands it to onSession.
//
// onSession runs on the request goroutine, so it must return promptly; the
// session outlives the handler because the upgrade hijacks the connection, and
// closing it is the caller's job.
func PeerHandler(token string, onSession func(peerID string, sess *yamux.Session)) echo.HandlerFunc {
// gorilla's default CheckOrigin already restricts a browser to same-origin
// and lets a header-less client (which every peer is) through, so the
// zero value is what this link wants.
upgrader := websocket.Upgrader{}
return func(c echo.Context) error {
// Reject before upgrading. Upgrading and then closing would give the
// dialer a WebSocket error in place of an HTTP status, and both the
// route-coverage test and a peer's own retry logic read the status.
if !authorizedPeer(c.Request(), token) {
return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
}
peerID := c.QueryParam("id")
if peerID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing peer id")
}
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
// Upgrade has already written its own failure to the client.
xlog.Debug("cluster peer link upgrade failed", "peer", peerID, "error", err)
return nil
}
// Server side of the mux: the dialing peer is the client, so it owns
// the odd stream IDs and this side the even ones.
sess, err := yamux.Server(clustersvc.WebsocketConn(ws), nil, nil)
if err != nil {
xlog.Error("cluster peer link session setup failed", "peer", peerID, "error", err)
_ = ws.Close()
return nil
}
if onSession == nil {
// Nothing will ever read from this session, so do not leave the
// peer believing it has a live link.
_ = sess.Close()
return nil
}
xlog.Debug("cluster peer link established", "peer", peerID, "remote", ws.RemoteAddr().String())
// net/http recovers a panic from this goroutine but does not close a
// hijacked connection afterwards, so a panicking callback would leave
// the peer holding a link nobody accepts streams on: its opens would
// fill the 256-deep backlog and then hang without an error.
defer func() {
if r := recover(); r != nil {
_ = sess.Close()
panic(r)
}
}()
onSession(peerID, sess)
return nil
}
}
// authorizedPeer compares the request's bearer token with the cluster token in
// constant time, matching the check the worker file-transfer server makes.
//
// Unlike that one, an empty configured token authorizes nobody: this route is
// registered in every deployment, so failing open would publish an
// unauthenticated mux to any caller that can reach the port.
func authorizedPeer(r *http.Request, expected string) bool {
if expected == "" {
return false
}
// RFC 7235 makes the scheme case-insensitive; the token after it is not.
const prefix = "Bearer "
header := r.Header.Get("Authorization")
if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
return false
}
return subtle.ConstantTimeCompare([]byte(header[len(prefix):]), []byte(expected)) == 1
}