feat: support JWT bootstrap and CLI access to a standalone Francis runtime

Pocket ID can now authenticate to a standalone runtime with a JWT as well
as a pre-shared key: FRANCIS_HOST_JWT carries the token inline, while
FRANCIS_HOST_JWT_FILE keeps a path that Francis re-reads on every
connection, so a rotated token (a Kubernetes projected service account
token, say) is picked up without a restart. Exactly one bootstrap method
must be configured, which the config validates up front.

The one-time-access-token command works against a standalone runtime
again. It has no actor host of its own, so it joins the cluster as a
client-only host for just long enough to write the token: that hosts no
actor, so the runtime never places one on it, and it disconnects before
the command returns.
This commit is contained in:
Alessandro (Ale) Segala
2026-08-31 05:26:59 +00:00
parent 600ca3f31f
commit 7ee7caae8f
6 changed files with 311 additions and 44 deletions
+38 -14
View File
@@ -30,6 +30,10 @@ import (
// That runtime owns the actor data instead, so it can only be reached through the runtime itself
var ErrRemoteFrancisRuntime = errors.New("the actor data is owned by the standalone Francis runtime configured in FRANCIS_HOST, and is not stored in Pocket ID's database")
// ErrEmbeddedFrancisRuntime is returned by the helpers that reach the actor data through a standalone Francis runtime when Pocket ID runs an embedded one
// There is no runtime to connect to in that case, and the actor data is in Pocket ID's own database
var ErrEmbeddedFrancisRuntime = errors.New("the actor runtime is embedded in Pocket ID, so there is no standalone Francis runtime to connect to")
type NewActorsOpts struct {
Postgres *pgxpool.Pool
@@ -138,22 +142,12 @@ func (o *NewActorsOpts) newEmbeddedHost(log *slog.Logger) (*local.Host, error) {
// The runtime owns the actor state, placement, and alarms, so none of the embedded runtime's database and clustering options apply here
// That includes the cap on the number of hosts in the cluster, which the runtime enforces through its own "maxHosts" setting: Pocket ID cannot limit itself to a single replica from this side
func (o *NewActorsOpts) newRemoteHost(log *slog.Logger) (*remote.Host, error) {
opts := []remote.HostOption{
opts := append(
remoteConnectionOptions(o.EnvConfig, log),
// Actors placed on this host are invoked by its peers at this address, which is also the one it advertises to the runtime
remote.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
remote.WithLogger(log),
remote.WithRuntimeAddresses(o.EnvConfig.FrancisAddresses...),
remote.WithHostBootstrapPSK(o.EnvConfig.FrancisHostPSK),
remote.WithShutdownGracePeriod(10 * time.Second),
}
// Pinning the cluster CA lets Pocket ID verify the runtime on its very first connection
// Francis requires the trust decision to be explicit, so without a pinned CA we have to opt into trusting the certificate served on first use, which it warns about
if len(o.EnvConfig.FrancisCA) > 0 {
opts = append(opts, remote.WithPinnedCA(o.EnvConfig.FrancisCA))
} else {
opts = append(opts, remote.WithUnsafeNoPinnedCA())
}
remote.WithShutdownGracePeriod(10*time.Second),
)
h, err := remote.NewHost(opts...)
if err != nil {
@@ -163,6 +157,36 @@ func (o *NewActorsOpts) newRemoteHost(log *slog.Logger) (*remote.Host, error) {
return h, nil
}
// remoteConnectionOptions builds the options that address and authenticate Pocket ID to a standalone Francis runtime
// Both the actor host and the short-lived client the CLI commands use go through here, so they always present the same identity to the same cluster
func remoteConnectionOptions(envConfig *common.EnvConfigSchema, log *slog.Logger) []remote.HostOption {
opts := []remote.HostOption{
remote.WithLogger(log),
remote.WithRuntimeAddresses(envConfig.FrancisAddresses...),
}
// The configuration is validated to carry exactly one bootstrap method, so the first match is the one the operator chose
switch {
case len(envConfig.FrancisHostPSK) > 0:
opts = append(opts, remote.WithHostBootstrapPSK(envConfig.FrancisHostPSK))
case envConfig.FrancisHostJWTFile != "":
// Francis re-reads the file on every connection, so a rotated token is picked up without restarting Pocket ID
opts = append(opts, remote.WithHostBootstrapJWTFile(envConfig.FrancisHostJWTFile))
case envConfig.FrancisHostJWT != "":
opts = append(opts, remote.WithHostBootstrapJWT(envConfig.FrancisHostJWT))
}
// Pinning the cluster CA lets Pocket ID verify the runtime on its very first connection
// Francis requires the trust decision to be explicit, so without a pinned CA we have to opt into trusting the certificate served on first use, which it warns about
if len(envConfig.FrancisCA) > 0 {
opts = append(opts, remote.WithPinnedCA(envConfig.FrancisCA))
} else {
opts = append(opts, remote.WithUnsafeNoPinnedCA())
}
return opts
}
// Derive a PSK from the global encryption key
func (o *NewActorsOpts) getPSK() ([]byte, error) {
// This is tied to the instance ID of the Pocket ID deployment/cluster
@@ -2,10 +2,14 @@ package bootstrap
import (
"bytes"
"context"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"testing"
francishost "github.com/italypaleale/francis/host"
"github.com/italypaleale/francis/host/local"
"github.com/italypaleale/francis/host/remote"
"github.com/libtnb/sqlite"
@@ -98,6 +102,48 @@ func TestNewActorsSelectsTopology(t *testing.T) {
require.NotEmpty(t, rateLimitServices)
})
// Each bootstrap method has to produce a host Francis accepts, which is the only part of the remote wiring that can be checked without a runtime to connect to
t.Run("every bootstrap method builds a valid remote host", func(t *testing.T) {
jwtFile := filepath.Join(t.TempDir(), "token")
require.NoError(t, os.WriteFile(jwtFile, []byte("header.payload.signature"), 0600))
for name, apply := range map[string]func(cfg *common.EnvConfigSchema){
"PSK": func(cfg *common.EnvConfigSchema) { cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough") },
"JWT": func(cfg *common.EnvConfigSchema) { cfg.FrancisHostJWT = "header.payload.signature" },
"JWT file": func(cfg *common.EnvConfigSchema) { cfg.FrancisHostJWTFile = jwtFile },
} {
t.Run(name, func(t *testing.T) {
cfg := baseConfig(t)
cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"}
apply(cfg)
opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"}
h, err := opts.newRemoteHost(slog.New(slog.DiscardHandler))
require.NoError(t, err)
require.NotNil(t, h)
})
}
})
t.Run("no bootstrap method is rejected by Francis", func(t *testing.T) {
cfg := baseConfig(t)
cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"}
opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"}
_, err := opts.newRemoteHost(slog.New(slog.DiscardHandler))
require.Error(t, err)
})
t.Run("the actor client requires a standalone runtime", func(t *testing.T) {
cfg := baseConfig(t)
err := WithActorClient(t.Context(), cfg, func(context.Context, francishost.Host) error {
t.Fatal("the callback must not run without a standalone runtime")
return nil
})
require.ErrorIs(t, err, ErrEmbeddedFrancisRuntime)
})
t.Run("state store is unavailable with a remote runtime", func(t *testing.T) {
cfg := baseConfig(t)
cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"}
@@ -0,0 +1,78 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
francishost "github.com/italypaleale/francis/host"
"github.com/italypaleale/francis/host/remote"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// actorClientConnectTimeout bounds how long a CLI command waits to join the cluster
// Francis reconnects to the runtime indefinitely, which is right for the server but would leave a command hanging against an unreachable runtime
const actorClientConnectTimeout = 30 * time.Second
// WithActorClient connects to the standalone Francis runtime, calls fn once the connection is live, and disconnects before returning.
// It's meant for CLI commands, which have no actor host of their own: the client joins the cluster only for the duration of fn, and hosts no actor while connected, so the runtime never places an actor on it.
// It requires FRANCIS_HOST to point to a standalone runtime, and returns ErrEmbeddedFrancisRuntime otherwise, since an embedded runtime is reached through the database instead.
func WithActorClient(parentCtx context.Context, envConfig *common.EnvConfigSchema, fn func(ctx context.Context, client francishost.Host) error) error {
if envConfig.HasEmbeddedFrancisRuntime() {
return ErrEmbeddedFrancisRuntime
}
log := slog.Default().With("scope", "actor-client")
// The client hosts no actor, so it advertises no address of its own and binds nothing
// The short grace period keeps a command from lingering on the way out, since there are no actors to drain
client, err := remote.NewHost(append(
remoteConnectionOptions(envConfig, log),
remote.WithClientOnly(),
remote.WithShutdownGracePeriod(2*time.Second),
)...)
if err != nil {
return fmt.Errorf("failed to create the actor client: %w", err)
}
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
runErrCh := make(chan error, 1)
go func() {
runErrCh <- client.Run(ctx)
}()
// Every operation travels on the runtime session, so nothing can run before the client has joined the cluster
connectCtx, connectCancel := context.WithTimeout(ctx, actorClientConnectTimeout)
defer connectCancel()
select {
case <-client.Ready():
case runErr := <-runErrCh:
return fmt.Errorf("failed to connect to the Francis runtime: %w", runErr)
case <-connectCtx.Done():
cancel()
<-runErrCh
return fmt.Errorf("timed out connecting to the Francis runtime after %v", actorClientConnectTimeout)
}
fnErr := fn(ctx, client)
// Leave the cluster before returning, so the runtime drops the registration instead of waiting for the health check to lapse
cancel()
runErr := <-runErrCh
// The error from fn is the one the caller asked for, and a canceled run is just the disconnect we asked for
switch {
case fnErr != nil:
return fnErr
case runErr != nil && !errors.Is(runErr, context.Canceled):
return fmt.Errorf("error disconnecting from the Francis runtime: %w", runErr)
default:
return nil
}
}