mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-14 15:18:03 -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>
174 lines
6.4 KiB
Go
174 lines
6.4 KiB
Go
package cluster_test
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/cluster"
|
|
"github.com/mudler/LocalAI/core/services/testutil"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var _ = Describe("Instance registry", func() {
|
|
var (
|
|
db *gorm.DB
|
|
reg *cluster.Registry
|
|
ctx context.Context
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
db = testutil.SetupTestDB()
|
|
ctx = context.Background()
|
|
Expect(cluster.Migrate(ctx, db)).To(Succeed())
|
|
reg = cluster.NewRegistry(db)
|
|
})
|
|
|
|
It("registers an instance and reads it back", func() {
|
|
Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1", "")).To(Succeed())
|
|
|
|
got, err := reg.Get(ctx, "inst-a")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.AdvertisedAddr).To(Equal("10.0.0.1:8080"))
|
|
Expect(got.Version).To(Equal("v1"))
|
|
})
|
|
|
|
It("re-registering the same id updates the address instead of duplicating", func() {
|
|
Expect(reg.Register(ctx, "inst-a", "10.0.0.1:8080", "v1", "")).To(Succeed())
|
|
Expect(reg.Register(ctx, "inst-a", "10.0.0.9:9090", "v2", "")).To(Succeed())
|
|
|
|
live, err := reg.Live(ctx, time.Hour)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(live).To(HaveLen(1))
|
|
Expect(live[0].AdvertisedAddr).To(Equal("10.0.0.9:9090"))
|
|
})
|
|
|
|
It("reports a missing instance distinguishably", func() {
|
|
_, err := reg.Get(ctx, "nope")
|
|
Expect(err).To(MatchError(cluster.ErrInstanceNotFound))
|
|
})
|
|
|
|
It("excludes instances whose heartbeat has aged out", func() {
|
|
Expect(reg.Register(ctx, "stale", "10.0.0.1:8080", "v1", "")).To(Succeed())
|
|
// Age the row directly; sleeping in a spec is forbidden.
|
|
Expect(db.Model(&cluster.Instance{}).Where("id = ?", "stale").
|
|
Update("last_seen", time.Now().Add(-10*time.Minute)).Error).To(Succeed())
|
|
|
|
live, err := reg.Live(ctx, time.Minute)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(live).To(BeEmpty())
|
|
})
|
|
|
|
It("brings a stale instance back with a heartbeat", func() {
|
|
Expect(reg.Register(ctx, "revive", "10.0.0.1:8080", "v1", "")).To(Succeed())
|
|
Expect(db.Model(&cluster.Instance{}).Where("id = ?", "revive").
|
|
Update("last_seen", time.Now().Add(-10*time.Minute)).Error).To(Succeed())
|
|
Expect(reg.Heartbeat(ctx, "revive")).To(Succeed())
|
|
|
|
live, err := reg.Live(ctx, time.Minute)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(live).To(HaveLen(1))
|
|
})
|
|
|
|
It("heartbeating an unknown instance is an error, not a silent insert", func() {
|
|
Expect(reg.Heartbeat(ctx, "ghost")).To(MatchError(cluster.ErrInstanceNotFound))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Advertised address discovery", func() {
|
|
// The address itself depends on host networking and is deliberately not
|
|
// asserted. What is portable is the shape: whatever interface routes to the
|
|
// database, the port must be the one the caller asked for, not the
|
|
// database's.
|
|
It("combines a local interface with the caller's port", func() {
|
|
addr, err := cluster.DiscoverAdvertisedAddr("postgres://198.51.100.1:5432/testdb", 8080)
|
|
if err != nil {
|
|
Skip("no route to a database host on this machine: " + err.Error())
|
|
}
|
|
host, port, splitErr := net.SplitHostPort(addr)
|
|
Expect(splitErr).ToNot(HaveOccurred())
|
|
Expect(port).To(Equal("8080"))
|
|
Expect(net.ParseIP(host)).ToNot(BeNil())
|
|
})
|
|
|
|
It("refuses a DSN it cannot derive an address from", func() {
|
|
_, err := cluster.DiscoverAdvertisedAddr("", 8080)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
// A DSN that NAMES loopback routes over loopback on every platform, so this
|
|
// is deterministic rather than host-dependent. Co-location is not the
|
|
// trigger: compose's `host=postgres` resolves to a bridge address and
|
|
// discovery works there. Returning 127.0.0.1 would make a peer dialling
|
|
// this replica reach itself.
|
|
It("refuses a loopback route instead of advertising an address peers cannot use", func() {
|
|
addr, err := cluster.DiscoverAdvertisedAddr("postgres://user@127.0.0.1:5432/testdb", 8080)
|
|
Expect(addr).To(BeEmpty())
|
|
Expect(err).To(MatchError(ContainSubstring("loopback")))
|
|
})
|
|
|
|
It("refuses a port that cannot be dialled", func() {
|
|
_, err := cluster.DiscoverAdvertisedAddr("postgres://198.51.100.1:5432/testdb", 0)
|
|
Expect(err).To(MatchError(ContainSubstring("out of range")))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Checking a configured advertised address", func() {
|
|
// The configured address bypasses discovery entirely, so it bypasses every
|
|
// rejection discovery makes. These are the checks that put back the ones
|
|
// that can be made without a route to look at.
|
|
It("accepts an address on a network other hosts can reach", func() {
|
|
reason, err := cluster.CheckAdvertisedAddr("10.0.0.7:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).To(BeEmpty())
|
|
})
|
|
|
|
It("accepts a name, because the dialler is what resolves it", func() {
|
|
reason, err := cluster.CheckAdvertisedAddr("localai-frontend.default.svc:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).To(BeEmpty())
|
|
})
|
|
|
|
It("refuses an address with no port, which nothing could dial", func() {
|
|
_, err := cluster.CheckAdvertisedAddr("10.0.0.7")
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("refuses a port outside the dialable range", func() {
|
|
_, err := cluster.CheckAdvertisedAddr("10.0.0.7:0")
|
|
Expect(err).To(MatchError(ContainSubstring("port")))
|
|
})
|
|
|
|
It("refuses an address that names no host", func() {
|
|
_, err := cluster.CheckAdvertisedAddr(":8080")
|
|
Expect(err).To(MatchError(ContainSubstring("no host")))
|
|
})
|
|
|
|
It("reports loopback without refusing it, because one host is a supported topology", func() {
|
|
// Correct on a single host, and the value most likely to be copied
|
|
// onto three, where every peer would then dial itself.
|
|
reason, err := cluster.CheckAdvertisedAddr("127.0.0.1:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).To(ContainSubstring("loopback"))
|
|
})
|
|
|
|
It("reports a bind address, which is not an address at all", func() {
|
|
reason, err := cluster.CheckAdvertisedAddr("0.0.0.0:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).To(ContainSubstring("unspecified"))
|
|
})
|
|
|
|
It("reports a scoped literal, which net.ParseIP alone would wave through as a name", func() {
|
|
// The zone has to be split off before parsing, or this address is
|
|
// indistinguishable from a hostname and collects no warning at all.
|
|
reason, err := cluster.CheckAdvertisedAddr("[fe80::1%eth0]:8080")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reason).ToNot(BeEmpty(), "a scoped address peers cannot dial was accepted in silence")
|
|
Expect(reason).To(ContainSubstring("fe80::1%eth0"),
|
|
"the reported address must carry its zone, or it is not the address being rejected")
|
|
})
|
|
})
|