Skip to content

Commit 3a932a9

Browse files
authored
feat(distributed): Add NATS JWT authentication and TLS/mTLS options (#10159)
* feat(distributed): NATS JWT auth, TLS/mTLS options, and e2e coverage Mint per-node NATS user JWTs at registration when LOCALAI_NATS_ACCOUNT_SEED is set, and connect workers with scoped credentials from the register response. Add optional LOCALAI_NATS_TLS_CA/CERT/KEY for private CA and mTLS alongside tls:// URLs, plus test-e2e-distributed and NatsJWT container e2e specs. Document JWT setup (nats-auth-setup.sh) and TLS env vars in distributed-mode. Assisted-by: Grok:grok grok-build Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(distributed): correct NATS JWT scoping and harden client auth The JWT-auth path added in 46467cc7 had several gaps that fail silently under LOCALAI_NATS_REQUIRE_AUTH: - Agent-worker minted JWTs did not allow the subjects the agent worker actually subscribes to (jobs.mcp-ci.new and nodes.<id>.backend.stop), so MCP-CI jobs and backend-stop session cleanup were silently dropped. Scope the agent permission set to those subjects. - NATS subscription permission violations were swallowed (Subscribe returned a live-but-dead subscription). Confirm subscriptions with a server round-trip so a denial surfaces synchronously, and log async permission errors. - The backend worker connected anonymously when given a JWT without its paired seed; reject the unpaired credential instead. - The documented service-user permissions in nats-auth-setup.sh omitted prefixcache.>, which the frontend publishes and subscribes; add it. Also: add a credential-provider hook to the messaging client (consumed by the follow-up credential-lifecycle change), drop the always-nil error from NatsMessagingOptions, run go mod tidy (jwt/v2 and nkeys are now direct), and gofmt the feature's files. Tests: an agent-JWT e2e spec that connects to the enforcing NATS server and exercises every subscription the agent worker makes, plus permission allow-list coverage unit tests. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(distributed): acquire and auto-refresh worker NATS credentials Workers fetched NATS credentials once at startup, which broke two cases under JWT auth: a worker that registered while still pending admin approval never received a minted JWT (it connected unauthenticated and gave up), and a long-running worker's 24h JWT expired with no way to renew it. Introduce workerregistry.NATSCredentialManager, built on idempotent re-registration (the frontend preserves the node row and mints a fresh JWT each call): - Acquire re-registers through admin approval until the node is approved and credentials are minted (or returns the first success when auth is not required, preserving anonymous-NATS behavior). - RefreshLoop re-registers before the JWT expires (~75% of its lifetime), updating the credentials served to the connection. - Both are bounded (default 100 attempts / consecutive failures) and return an error on exhaustion, so an unapprovable or unrenewable worker exits non-zero and surfaces the problem instead of hanging or drifting toward an expired credential. The messaging client gains WithUserJWTProvider, fetching credentials on each (re)connect so the connection transparently adopts a refreshed JWT when the server expires the old one. RegisterFull exposes the approval status and full response; Register delegates to it. Both the backend worker and the agent worker are wired to this: explicit env credentials are used as-is, minted credentials are acquired-with-wait and refreshed, and a permanent refresh failure shuts the worker down so it restarts and re-acquires. Tests cover Acquire (wait-through-pending, bounded give-up, context cancel), RefreshLoop (refresh-before-expiry, bounded failure, no-expiry exit) and jwtExpiry decoding. Docs updated in distributed-mode.md. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 9d10418 commit 3a932a9

33 files changed

Lines changed: 1856 additions & 86 deletions

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,20 @@ run-e2e-aio: protogen-go
309309
@echo 'Running e2e AIO tests'
310310
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e-aio
311311

312+
# Distributed architecture e2e (PostgreSQL + NATS via testcontainers).
313+
# Includes NatsJWT specs (JWT-enabled NATS). Requires Docker.
314+
# VLLMMultinode is excluded here; use test-e2e-vllm-multinode for that.
315+
test-e2e-distributed: protogen-go
316+
@echo 'Running distributed e2e tests (label Distributed, incl. NatsJWT)'
317+
$(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter='Distributed && !VLLMMultinode' --flake-attempts $(TEST_FLAKES) -v -r ./tests/e2e/distributed
318+
312319
# vLLM multi-node DP smoke (CPU). Builds local-ai:tests and the
313320
# cpu-vllm backend from the current working tree, then drives a
314321
# head + headless follower via testcontainers-go and asserts a chat
315322
# completion. BuildKit caches both images, so re-runs only rebuild
316323
# what changed. The test lives under tests/e2e/distributed and is
317324
# selected by the VLLMMultinode label so it doesn't run alongside
318-
# the other distributed-suite tests by default.
325+
# test-e2e-distributed.
319326
test-e2e-vllm-multinode: docker-build-e2e extract-backend-vllm protogen-go
320327
@echo 'Running e2e vLLM multi-node DP test'
321328
LOCALAI_IMAGE=local-ai \

core/application/distributed.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,12 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
102102
xlog.Info("Distributed instance", "id", cfg.Distributed.InstanceID)
103103

104104
// Connect to NATS
105-
natsClient, err := messaging.New(cfg.Distributed.NatsURL)
105+
natsAuth := cfg.Distributed.NatsAuthConfig()
106+
if natsAuth.RequireAuth && (natsAuth.ServiceUserJWT == "" || natsAuth.ServiceUserSeed == "") {
107+
return nil, fmt.Errorf("LOCALAI_NATS_REQUIRE_AUTH requires LOCALAI_NATS_SERVICE_JWT and LOCALAI_NATS_SERVICE_SEED")
108+
}
109+
natsOpts := cfg.Distributed.NatsMessagingOptions("", "")
110+
natsClient, err := messaging.New(cfg.Distributed.NatsURL, natsOpts...)
106111
if err != nil {
107112
return nil, fmt.Errorf("connecting to NATS: %w", err)
108113
}

core/cli/agent_worker.go

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ type AgentWorkerCMD struct {
5252
Subject string `env:"LOCALAI_AGENT_SUBJECT" default:"agent.execute" help:"NATS subject for agent execution" group:"distributed"`
5353
Queue string `env:"LOCALAI_AGENT_QUEUE" default:"agent-workers" help:"NATS queue group name" group:"distributed"`
5454

55+
NatsJWT string `env:"LOCALAI_NATS_JWT" help:"NATS user JWT override (defaults to nats_jwt from registration)" group:"distributed"`
56+
NatsUserSeed string `env:"LOCALAI_NATS_USER_SEED" help:"NATS user seed override (defaults to nats_user_seed from registration)" group:"distributed"`
57+
NatsServiceJWT string `env:"LOCALAI_NATS_SERVICE_JWT" help:"Fallback NATS service JWT when registration does not mint agent JWT" group:"distributed"`
58+
NatsServiceSeed string `env:"LOCALAI_NATS_SERVICE_SEED" help:"Fallback NATS service seed paired with LOCALAI_NATS_SERVICE_JWT" group:"distributed"`
59+
NatsRequireAuth bool `env:"LOCALAI_NATS_REQUIRE_AUTH" default:"false" help:"Require NATS JWT+seed to connect" group:"distributed"`
60+
NatsTLSCA string `env:"LOCALAI_NATS_TLS_CA" type:"existingfile" help:"PEM file for NATS server CA (private PKI)" group:"distributed"`
61+
NatsTLSCert string `env:"LOCALAI_NATS_TLS_CERT" type:"existingfile" help:"Client certificate for NATS mTLS" group:"distributed"`
62+
NatsTLSKey string `env:"LOCALAI_NATS_TLS_KEY" type:"existingfile" help:"Client private key for NATS mTLS" group:"distributed"`
63+
5564
// Timeouts
5665
MCPCIJobTimeout string `env:"LOCALAI_MCP_CI_JOB_TIMEOUT" default:"10m" help:"Timeout for MCP CI job execution" group:"distributed"`
5766
}
@@ -81,15 +90,30 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
8190
registrationBody["token"] = cmd.RegistrationToken
8291
}
8392

84-
nodeID, apiToken, err := regClient.RegisterWithRetry(context.Background(), registrationBody, 10)
93+
// Context cancelled on shutdown — used by registration waits, heartbeat, and
94+
// other background goroutines.
95+
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
96+
defer shutdownCancel()
97+
98+
// Acquire credentials via (re)registration. When the bus requires auth and no
99+
// static fallback is configured, wait through admin approval until the
100+
// frontend mints credentials rather than starting unauthenticated.
101+
credMgr := workerregistry.NewNATSCredentialManager(
102+
func(ctx context.Context) (*workerregistry.RegisterResponse, error) {
103+
return regClient.RegisterFull(ctx, registrationBody)
104+
},
105+
cmd.NatsRequireAuth && cmd.NatsJWT == "" && cmd.NatsServiceJWT == "",
106+
)
107+
res, err := credMgr.Acquire(shutdownCtx)
85108
if err != nil {
86109
return fmt.Errorf("registration failed: %w", err)
87110
}
111+
nodeID := res.ID
88112
xlog.Info("Registered with frontend", "nodeID", nodeID, "frontend", cmd.RegisterTo)
89113

90114
// Use provisioned API token if none was set
91115
if cmd.APIToken == "" {
92-
cmd.APIToken = apiToken
116+
cmd.APIToken = res.APIToken
93117
}
94118

95119
// Start heartbeat
@@ -98,14 +122,40 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
98122
xlog.Warn("invalid heartbeat interval, using default 10s", "input", cmd.HeartbeatInterval, "error", err)
99123
}
100124
heartbeatInterval = cmp.Or(heartbeatInterval, 10*time.Second)
101-
// Context cancelled on shutdown — used by heartbeat and other background goroutines
102-
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
103-
defer shutdownCancel()
104125

105126
go regClient.HeartbeatLoop(shutdownCtx, nodeID, heartbeatInterval, func() map[string]any { return map[string]any{} })
106127

107-
// Connect to NATS
108-
natsClient, err := messaging.New(cmd.NatsURL)
128+
// Resolve NATS credentials with precedence: explicit env override, then
129+
// frontend-minted (auto-refreshed before expiry), then service fallback.
130+
// Each static source must supply JWT and seed together.
131+
natsTLS := messaging.TLSFiles{CA: cmd.NatsTLSCA, Cert: cmd.NatsTLSCert, Key: cmd.NatsTLSKey}
132+
var natsOpts []messaging.Option
133+
switch {
134+
case cmd.NatsJWT != "" || cmd.NatsUserSeed != "":
135+
if (cmd.NatsJWT == "") != (cmd.NatsUserSeed == "") {
136+
return fmt.Errorf("LOCALAI_NATS_JWT and LOCALAI_NATS_USER_SEED must be set together")
137+
}
138+
natsOpts = append(natsOpts, messaging.WithUserJWT(cmd.NatsJWT, cmd.NatsUserSeed))
139+
case credMgr.HasCredentials():
140+
natsOpts = append(natsOpts, messaging.WithUserJWTProvider(credMgr.Provider()))
141+
go func() {
142+
if err := credMgr.RefreshLoop(shutdownCtx); err != nil {
143+
xlog.Error("NATS credential refresh permanently failed; shutting down agent worker", "error", err)
144+
shutdownCancel()
145+
}
146+
}()
147+
case cmd.NatsServiceJWT != "" || cmd.NatsServiceSeed != "":
148+
if (cmd.NatsServiceJWT == "") != (cmd.NatsServiceSeed == "") {
149+
return fmt.Errorf("LOCALAI_NATS_SERVICE_JWT and LOCALAI_NATS_SERVICE_SEED must be set together")
150+
}
151+
natsOpts = append(natsOpts, messaging.WithUserJWT(cmd.NatsServiceJWT, cmd.NatsServiceSeed))
152+
case cmd.NatsRequireAuth:
153+
return fmt.Errorf("NATS JWT+seed required: enable frontend minting or set LOCALAI_NATS_* env vars")
154+
}
155+
if natsTLS.Enabled() {
156+
natsOpts = append(natsOpts, messaging.WithTLS(natsTLS))
157+
}
158+
natsClient, err := messaging.New(cmd.NatsURL, natsOpts...)
109159
if err != nil {
110160
return fmt.Errorf("connecting to NATS: %w", err)
111161
}
@@ -183,17 +233,25 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
183233

184234
xlog.Info("Agent worker ready, waiting for jobs", "subject", cmd.Subject, "queue", cmd.Queue)
185235

186-
// Wait for shutdown
236+
// Wait for an OS signal or an internal fatal condition (e.g. NATS
237+
// credentials became unrenewable), so the worker restarts and re-acquires
238+
// rather than lingering unable to serve.
187239
sigCh := make(chan os.Signal, 1)
188240
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
189-
<-sigCh
241+
var runErr error
242+
select {
243+
case <-sigCh:
244+
case <-shutdownCtx.Done():
245+
runErr = fmt.Errorf("agent worker shutting down: NATS credentials unavailable")
246+
xlog.Error("Internal shutdown requested", "error", runErr)
247+
}
190248

191249
xlog.Info("Shutting down agent worker")
192250
shutdownCancel() // stop heartbeat loop immediately
193251
dispatcher.Stop()
194252
mcpTools.CloseAllMCPSessions()
195253
regClient.GracefulDeregister(nodeID)
196-
return nil
254+
return runErr
197255
}
198256

199257
// handleMCPToolRequest handles a NATS request-reply for MCP tool execution.

core/cli/run.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ type RunCMD struct {
159159
DistributedPrefixCacheTTL string `env:"LOCALAI_DISTRIBUTED_PREFIX_CACHE_TTL" help:"Idle-timeout for prefix-cache index entries; also drives the background eviction cadence (every TTL/2). Default 5m." group:"distributed"`
160160
BackendInstallTimeout string `env:"LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT" help:"NATS round-trip timeout for backend.install requests sent to worker nodes (default 15m). Increase for slow links pulling multi-GB images." group:"distributed"`
161161
BackendUpgradeTimeout string `env:"LOCALAI_NATS_BACKEND_UPGRADE_TIMEOUT" help:"NATS round-trip timeout for backend.upgrade requests (default 15m)." group:"distributed"`
162+
NatsAccountSeed string `env:"LOCALAI_NATS_ACCOUNT_SEED" help:"NATS account signing seed (SU...) used to mint per-node worker JWTs at registration" group:"distributed"`
163+
NatsServiceJWT string `env:"LOCALAI_NATS_SERVICE_JWT" help:"NATS user JWT for the frontend (and agent workers) to publish control-plane messages" group:"distributed"`
164+
NatsServiceSeed string `env:"LOCALAI_NATS_SERVICE_SEED" help:"NATS user signing seed (SU...) paired with LOCALAI_NATS_SERVICE_JWT" group:"distributed"`
165+
NatsWorkerJWTTTL string `env:"LOCALAI_NATS_WORKER_JWT_TTL" help:"Lifetime of minted per-node NATS JWTs (e.g. 24h, default 24h)" group:"distributed"`
166+
NatsRequireAuth bool `env:"LOCALAI_NATS_REQUIRE_AUTH" default:"false" help:"Require NATS JWT credentials (service JWT + account seed) when distributed mode is enabled" group:"distributed"`
167+
NatsTLSCA string `env:"LOCALAI_NATS_TLS_CA" type:"existingfile" help:"PEM file for NATS server CA (private PKI); use with tls:// in --nats-url" group:"distributed"`
168+
NatsTLSCert string `env:"LOCALAI_NATS_TLS_CERT" type:"existingfile" help:"Client certificate for NATS mTLS" group:"distributed"`
169+
NatsTLSKey string `env:"LOCALAI_NATS_TLS_KEY" type:"existingfile" help:"Client private key for NATS mTLS" group:"distributed"`
162170
ExposeNodeHeader bool `env:"LOCALAI_EXPOSE_NODE_HEADER" default:"false" help:"Set the X-LocalAI-Node response header on inference responses (OpenAI chat/completions/embeddings, Anthropic /v1/messages, Ollama /api/chat,/api/generate,/api/embed) with the ID of the worker that served the request. Disabled by default: the node ID reveals internal topology and should not be exposed on a public endpoint. Best-effort: under heavy concurrency the header may reflect a recent routing decision rather than this exact request's." group:"distributed"`
163171

164172
Version bool
@@ -283,6 +291,34 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
283291
if r.RegistrationToken != "" {
284292
opts = append(opts, config.WithRegistrationToken(r.RegistrationToken))
285293
}
294+
if r.NatsAccountSeed != "" {
295+
opts = append(opts, config.WithNatsAccountSeed(r.NatsAccountSeed))
296+
}
297+
if r.NatsServiceJWT != "" {
298+
opts = append(opts, config.WithNatsServiceJWT(r.NatsServiceJWT))
299+
}
300+
if r.NatsServiceSeed != "" {
301+
opts = append(opts, config.WithNatsServiceSeed(r.NatsServiceSeed))
302+
}
303+
if r.NatsWorkerJWTTTL != "" {
304+
d, err := time.ParseDuration(r.NatsWorkerJWTTTL)
305+
if err != nil {
306+
return fmt.Errorf("invalid LOCALAI_NATS_WORKER_JWT_TTL %q: %w", r.NatsWorkerJWTTTL, err)
307+
}
308+
opts = append(opts, config.WithNatsWorkerJWTTTL(d))
309+
}
310+
if r.NatsRequireAuth {
311+
opts = append(opts, config.EnableNatsRequireAuth)
312+
}
313+
if r.NatsTLSCA != "" {
314+
opts = append(opts, config.WithNatsTLSCA(r.NatsTLSCA))
315+
}
316+
if r.NatsTLSCert != "" {
317+
opts = append(opts, config.WithNatsTLSCert(r.NatsTLSCert))
318+
}
319+
if r.NatsTLSKey != "" {
320+
opts = append(opts, config.WithNatsTLSKey(r.NatsTLSKey))
321+
}
286322
if r.AutoApproveNodes {
287323
opts = append(opts, config.EnableAutoApproveNodes)
288324
}

core/cli/worker/worker_vllm.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func (r *VLLMDistributed) Run(ctx *cliContext.Context) error {
9696
FrontendURL: r.RegisterTo,
9797
RegistrationToken: r.RegistrationToken,
9898
}
99-
nodeID, _, regErr := regClient.RegisterWithRetry(context.Background(), r.registrationBody(), 10)
99+
nodeID, _, _, _, regErr := regClient.RegisterWithRetry(context.Background(), r.registrationBody(), 10)
100100
if regErr != nil {
101101
return fmt.Errorf("registering with frontend: %w", regErr)
102102
}

core/cli/workerregistry/client.go

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,65 +58,77 @@ func (c *RegistrationClient) setAuth(req *http.Request) {
5858

5959
// RegisterResponse is the JSON body returned by /api/node/register.
6060
type RegisterResponse struct {
61-
ID string `json:"id"`
62-
APIToken string `json:"api_token,omitempty"`
61+
ID string `json:"id"`
62+
Status string `json:"status,omitempty"` // "pending" until an admin approves the node
63+
APIToken string `json:"api_token,omitempty"`
64+
NatsJWT string `json:"nats_jwt,omitempty"`
65+
NatsUserSeed string `json:"nats_user_seed,omitempty"`
6366
}
6467

65-
// Register sends a single registration request and returns the node ID and
66-
// (optionally) an auto-provisioned API token.
67-
func (c *RegistrationClient) Register(ctx context.Context, body map[string]any) (string, string, error) {
68+
// RegisterFull sends a single registration request and returns the full
69+
// response (node ID, approval status, and optional API token / NATS creds).
70+
// Re-registration is idempotent: the frontend preserves the node row and mints
71+
// a fresh NATS JWT each call, so this doubles as the credential-refresh call.
72+
func (c *RegistrationClient) RegisterFull(ctx context.Context, body map[string]any) (*RegisterResponse, error) {
6873
jsonBody, _ := json.Marshal(body)
6974
url := c.baseURL() + "/api/node/register"
7075

7176
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
7277
if err != nil {
73-
return "", "", fmt.Errorf("creating request: %w", err)
78+
return nil, fmt.Errorf("creating request: %w", err)
7479
}
7580
req.Header.Set("Content-Type", "application/json")
7681
c.setAuth(req)
7782

7883
resp, err := c.httpClient().Do(req)
7984
if err != nil {
80-
return "", "", fmt.Errorf("posting to %s: %w", url, err)
85+
return nil, fmt.Errorf("posting to %s: %w", url, err)
8186
}
8287
defer resp.Body.Close()
8388

8489
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
85-
return "", "", fmt.Errorf("registration failed with status %d", resp.StatusCode)
90+
return nil, fmt.Errorf("registration failed with status %d", resp.StatusCode)
8691
}
8792

8893
var result RegisterResponse
8994
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
90-
return "", "", fmt.Errorf("decoding response: %w", err)
95+
return nil, fmt.Errorf("decoding response: %w", err)
9196
}
92-
return result.ID, result.APIToken, nil
97+
return &result, nil
98+
}
99+
100+
// Register sends a single registration request and returns the node ID and
101+
// optional credentials (API token for agent workers, NATS JWT when configured).
102+
func (c *RegistrationClient) Register(ctx context.Context, body map[string]any) (nodeID, apiToken, natsJWT, natsSeed string, err error) {
103+
res, err := c.RegisterFull(ctx, body)
104+
if err != nil {
105+
return "", "", "", "", err
106+
}
107+
return res.ID, res.APIToken, res.NatsJWT, res.NatsUserSeed, nil
93108
}
94109

95110
// RegisterWithRetry retries registration with exponential backoff.
96-
func (c *RegistrationClient) RegisterWithRetry(ctx context.Context, body map[string]any, maxRetries int) (string, string, error) {
111+
func (c *RegistrationClient) RegisterWithRetry(ctx context.Context, body map[string]any, maxRetries int) (nodeID, apiToken, natsJWT, natsSeed string, err error) {
97112
backoff := 2 * time.Second
98113
maxBackoff := 30 * time.Second
99114

100-
var nodeID, apiToken string
101-
var err error
102-
103115
for attempt := 1; attempt <= maxRetries; attempt++ {
104-
nodeID, apiToken, err = c.Register(ctx, body)
116+
nodeID, apiToken, natsJWT, natsSeed, err = c.Register(ctx, body)
105117
if err == nil {
106-
return nodeID, apiToken, nil
118+
return nodeID, apiToken, natsJWT, natsSeed, nil
107119
}
108120
if attempt == maxRetries {
109-
return "", "", fmt.Errorf("failed after %d attempts: %w", maxRetries, err)
121+
return "", "", "", "", fmt.Errorf("failed after %d attempts: %w", maxRetries, err)
110122
}
111123
xlog.Warn("Registration failed, retrying", "attempt", attempt, "next_retry", backoff, "error", err)
112124
select {
113125
case <-ctx.Done():
114-
return "", "", ctx.Err()
126+
return "", "", "", "", ctx.Err()
115127
case <-time.After(backoff):
116128
}
117129
backoff = min(backoff*2, maxBackoff)
118130
}
119-
return nodeID, apiToken, err
131+
return nodeID, apiToken, natsJWT, natsSeed, err
120132
}
121133

122134
// Heartbeat sends a single heartbeat POST with the given body.

0 commit comments

Comments
 (0)