mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-19 03:32:29 -04:00
The worker end of the tunnel. It dials wss://<register-to>/api/cluster/connect, holds one yamux session as the CLIENT, and serves every stream the frontend opens on it. Nothing dials into the worker, which is the point: no inbound port, no reachable address. Each stream opens with a length-prefixed frame naming a tag and a target, and the worker answers before either side speaks the tunnelled protocol. The reply is sent on every stream, not only on refusal, because the protocols carried here are client-speaks-first and a reply sent only sometimes would arrive interleaved with a response body. Two tags today: grpc reaches a backend process, and only on 127.0.0.1 within this worker's own backend port range, because a tunnel terminates inside the worker and letting the frontend name a host would make every worker a proxy into its own LAN; http reaches the worker's file-transfer server, whose address the frontend is not asked about. An unknown tag, an unreachable local service and an unparseable request are three refusals and stay three on the wire. A frontend gives up on the first and retries the second. Each is answered AND the stream is ended: a worker that says why and leaves the stream open has parked the caller on a request nobody will answer, and a deadline on the far side cannot tell that from a slow worker. The specs assert the stream ends rather than that an error occurred, which is what phase 1 shipped in three places and held in none. Reconnects double from 500ms to a 30s ceiling, each wait drawn between half the interval and all of it, and the interval returns to its floor only after a session that LASTED. Resetting on connect is how a rolling restart, where every dial succeeds and dies moments later, becomes a retry storm against the first replica back up. Nothing is assumed to survive a reconnect: the credential is read at dial time, never captured. And the credential is now real. The tunnel endpoint advertised authenticating a worker against its own secret, but registration stored the hash of the shared registration token, so a leak plus a known node ID still opened a tunnel. Registration now mints a per-node secret, returns the plaintext once as tunnel_token, and stores only its SHA-256 in a new column; the endpoint compares against that and does not fall back to the old one. Rotating on every registration follows from storing only the hash, since a re-registering worker cannot be told the secret it already holds; its live tunnel is unaffected, because the credential is checked when a tunnel is dialled and never again. Unlike the agent API key and the NATS JWT next to it, the credential IS issued to a node awaiting approval: the tunnel route re-reads the node's status on every dial and refuses a pending one, so it is inert until an admin acts, and withholding it would strand every worker that registers exactly once. A node that has not registered since this change cannot tunnel, and the column cannot be back-filled because the plaintext only ever existed in the response that minted it. The boot warning that said tunnels need LOCALAI_REGISTRATION_TOKEN is replaced: it was true while the tunnel authenticated against that token's hash, and says the wrong thing now. What is still true, and is what it warns about instead, is that without one, registration itself is unauthenticated. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
222 lines
8.2 KiB
Go
222 lines
8.2 KiB
Go
package workerregistry
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/pkg/natsauth"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// statusPending mirrors nodes.StatusPending. It is duplicated rather than
|
|
// imported so the lightweight registration client does not pull in the nodes
|
|
// package (and its gorm/DB dependencies).
|
|
const statusPending = "pending"
|
|
|
|
// defaultMaxAttempts bounds how many times Acquire registers (and how many
|
|
// consecutive times RefreshLoop may fail) before giving up. It is high enough
|
|
// to ride out a slow admin approval or a transient frontend outage, but finite
|
|
// so an unauthorized/unapprovable worker exits and surfaces the problem (via a
|
|
// non-zero exit and the resulting restart) rather than waiting forever.
|
|
const defaultMaxAttempts = 100
|
|
|
|
// RegisterFunc performs one idempotent registration round-trip.
|
|
type RegisterFunc func(ctx context.Context) (*RegisterResponse, error)
|
|
|
|
// NATSCredentialManager acquires NATS credentials at startup — waiting through
|
|
// admin approval when required — and refreshes them before the minted JWT
|
|
// expires, by re-registering (which mints a fresh JWT). The live NATS
|
|
// connection adopts a refreshed JWT on its next reconnect via Provider. Safe
|
|
// for concurrent use.
|
|
//
|
|
// It addresses two failure modes: a worker that needs credentials but registers
|
|
// while still pending approval (it would otherwise give up and never connect),
|
|
// and a long-running worker whose 24h JWT expires with no way to renew it.
|
|
type NATSCredentialManager struct {
|
|
register RegisterFunc
|
|
requireCreds bool // block until credentials are present (frontend minting in use)
|
|
|
|
// Tunables; defaults set by NewNATSCredentialManager, overridable in tests.
|
|
initialBackoff time.Duration
|
|
maxBackoff time.Duration
|
|
maxAttempts int // bound on Acquire attempts / consecutive refresh failures (<=0 = unlimited)
|
|
refreshLead float64 // refresh once this fraction of the JWT lifetime has elapsed
|
|
refreshRetry time.Duration
|
|
expiryOf func(jwt string) (time.Time, bool)
|
|
|
|
mu sync.RWMutex
|
|
jwt string
|
|
seed string
|
|
nodeID string
|
|
// tunnelToken is the node's own tunnel credential from the most recent
|
|
// registration. It is kept here because every re-registration this manager
|
|
// performs ROTATES it, so the tunnel client has to read the current value
|
|
// at dial time rather than be handed one at startup.
|
|
tunnelToken string
|
|
}
|
|
|
|
// NewNATSCredentialManager builds a manager over register. When requireCreds is
|
|
// true, Acquire blocks until the node is approved and credentials are minted.
|
|
func NewNATSCredentialManager(register RegisterFunc, requireCreds bool) *NATSCredentialManager {
|
|
return &NATSCredentialManager{
|
|
register: register,
|
|
requireCreds: requireCreds,
|
|
initialBackoff: 2 * time.Second,
|
|
maxBackoff: 30 * time.Second,
|
|
maxAttempts: defaultMaxAttempts,
|
|
refreshLead: 0.75,
|
|
refreshRetry: 30 * time.Second,
|
|
expiryOf: jwtExpiry,
|
|
}
|
|
}
|
|
|
|
// jwtExpiry decodes the expiry of a minted user JWT. ok is false when the token
|
|
// is empty/undecodable or carries no expiry (e.g. a non-expiring service JWT).
|
|
func jwtExpiry(token string) (time.Time, bool) {
|
|
if token == "" {
|
|
return time.Time{}, false
|
|
}
|
|
uc, err := natsauth.DecodeUserClaims(token)
|
|
if err != nil || uc.Expires == 0 {
|
|
return time.Time{}, false
|
|
}
|
|
return time.Unix(uc.Expires, 0), true
|
|
}
|
|
|
|
func (m *NATSCredentialManager) store(res *RegisterResponse) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.nodeID = res.ID
|
|
if res.NatsJWT != "" && res.NatsUserSeed != "" {
|
|
m.jwt, m.seed = res.NatsJWT, res.NatsUserSeed
|
|
}
|
|
// Guarded the same way the NATS pair is: a response that carries no tunnel
|
|
// token (a frontend that predates them, or one whose minting failed) must
|
|
// not wipe a working credential this worker already holds. Overwriting with
|
|
// "" would lock the tunnel out until the next registration that did carry
|
|
// one, which is the opposite of what an empty field means.
|
|
if res.TunnelToken != "" {
|
|
m.tunnelToken = res.TunnelToken
|
|
}
|
|
}
|
|
|
|
// TunnelToken returns the node's current tunnel credential, empty until one has
|
|
// been issued. It is the callback the tunnel client reads on every dial.
|
|
func (m *NATSCredentialManager) TunnelToken() string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.tunnelToken
|
|
}
|
|
|
|
// Current returns the latest NATS credentials (both empty until acquired).
|
|
func (m *NATSCredentialManager) Current() (jwt, seed string) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.jwt, m.seed
|
|
}
|
|
|
|
// NodeID returns the node ID from the most recent registration.
|
|
func (m *NATSCredentialManager) NodeID() string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.nodeID
|
|
}
|
|
|
|
// Provider returns a callback compatible with messaging.WithUserJWTProvider,
|
|
// supplying the current credentials on each (re)connect.
|
|
func (m *NATSCredentialManager) Provider() func() (string, string) {
|
|
return m.Current
|
|
}
|
|
|
|
// HasCredentials reports whether complete NATS credentials have been obtained.
|
|
func (m *NATSCredentialManager) HasCredentials() bool {
|
|
jwt, seed := m.Current()
|
|
return jwt != "" && seed != ""
|
|
}
|
|
|
|
// Acquire registers and, when requireCreds is set, keeps re-registering with
|
|
// exponential backoff until the node is approved (status != pending) and
|
|
// credentials are minted. Without requireCreds it returns the first successful
|
|
// response (the historical one-shot behavior, preserved for anonymous NATS).
|
|
func (m *NATSCredentialManager) Acquire(ctx context.Context) (*RegisterResponse, error) {
|
|
backoff := m.initialBackoff
|
|
var lastReason error
|
|
for attempt := 1; m.maxAttempts <= 0 || attempt <= m.maxAttempts; attempt++ {
|
|
res, err := m.register(ctx)
|
|
switch {
|
|
case err != nil:
|
|
lastReason = err
|
|
xlog.Warn("Registration failed, retrying", "attempt", attempt, "next_retry", backoff, "error", err)
|
|
case !m.requireCreds:
|
|
m.store(res)
|
|
return res, nil
|
|
case res.Status == statusPending:
|
|
lastReason = fmt.Errorf("node %s still pending admin approval", res.ID)
|
|
xlog.Info("Node pending admin approval; waiting", "node", res.ID, "attempt", attempt, "next_retry", backoff)
|
|
case res.NatsJWT == "" || res.NatsUserSeed == "":
|
|
lastReason = fmt.Errorf("node %s approved but NATS credentials not minted", res.ID)
|
|
xlog.Info("Node approved but NATS credentials not yet minted; waiting", "node", res.ID, "attempt", attempt, "next_retry", backoff)
|
|
default:
|
|
m.store(res)
|
|
return res, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
backoff = min(backoff*2, m.maxBackoff)
|
|
}
|
|
return nil, fmt.Errorf("giving up acquiring NATS credentials after %d attempts: %w", m.maxAttempts, lastReason)
|
|
}
|
|
|
|
// RefreshLoop re-registers to mint a fresh JWT before the current one expires,
|
|
// updating the credentials returned by Current/Provider so the NATS connection
|
|
// adopts them on its next reconnect. It returns nil when ctx is cancelled or
|
|
// when the current credential has no expiry (nothing to refresh), and a non-nil
|
|
// error after maxAttempts consecutive refresh failures — letting the caller
|
|
// exit the worker so it restarts and re-acquires (or surfaces the outage)
|
|
// rather than silently drifting toward an expired, unrenewable JWT.
|
|
func (m *NATSCredentialManager) RefreshLoop(ctx context.Context) error {
|
|
failures := 0
|
|
for {
|
|
jwt, _ := m.Current()
|
|
exp, ok := m.expiryOf(jwt)
|
|
if !ok {
|
|
xlog.Debug("NATS credential has no expiry; refresh loop exiting")
|
|
return nil
|
|
}
|
|
wait := max(time.Duration(float64(time.Until(exp))*m.refreshLead), 0)
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-time.After(wait):
|
|
}
|
|
|
|
res, err := m.register(ctx)
|
|
if err == nil && res.NatsJWT != "" && res.NatsUserSeed != "" {
|
|
m.store(res)
|
|
failures = 0
|
|
xlog.Info("Refreshed NATS credentials", "node", res.ID)
|
|
continue
|
|
}
|
|
failures++
|
|
if err != nil {
|
|
xlog.Warn("NATS credential refresh failed; will retry", "attempt", failures, "error", err)
|
|
} else {
|
|
xlog.Warn("NATS credential refresh returned no credentials; will retry", "attempt", failures)
|
|
}
|
|
if m.maxAttempts > 0 && failures >= m.maxAttempts {
|
|
return fmt.Errorf("NATS credential refresh failed %d times in a row", failures)
|
|
}
|
|
// Back off before retrying so a persistent failure near expiry does not spin.
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-time.After(m.refreshRetry):
|
|
}
|
|
}
|
|
}
|