diff --git a/backend/go.mod b/backend/go.mod index 9d5c0719..13a68904 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,7 +24,7 @@ require ( github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 - github.com/italypaleale/francis v0.1.0-beta.23 + github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd github.com/italypaleale/go-kit v1.0.0 github.com/italypaleale/go-sql-utils v0.3.5 github.com/jackc/pgx/v5 v5.10.0 @@ -65,6 +65,8 @@ require ( require ( github.com/Azure/go-ntlmssp v0.1.1 // indirect + github.com/MicahParks/jwkset v0.11.3 // indirect + github.com/MicahParks/keyfunc/v3 v3.8.1 // indirect github.com/alphadose/haxmap v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect diff --git a/backend/go.sum b/backend/go.sum index 3187167c..9fe128f0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,6 +2,10 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/MicahParks/jwkset v0.11.3 h1:Phli4RdTDdIdLXZpuO7abkwZyzIk0RDTUPVVBHPRdkQ= +github.com/MicahParks/jwkset v0.11.3/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.8.1 h1:VR3jlEs2wz1xGjvUUwUHeoB+eJ6gUnBrpnIByRrtj6g= +github.com/MicahParks/keyfunc/v3 v3.8.1/go.mod h1:LcorJ0sz2tZGvgZqIfaeyLkJmM+kxIfRDu8dFY0TAas= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= @@ -243,8 +247,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/italypaleale/francis v0.1.0-beta.23 h1:Yu0K/LE7NwQWVC1yOH6/uVE/+1JZ966aAftPnDTUDmw= -github.com/italypaleale/francis v0.1.0-beta.23/go.mod h1:qB+0OgLCTWw3/AmdHe30bN81TTo5BcI95kAwAJdLPuM= +github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd h1:GVUawGFf4RwwXIUPX+TKgBqi60lTHCpP6n5roIthPZU= +github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd/go.mod h1:qB+0OgLCTWw3/AmdHe30bN81TTo5BcI95kAwAJdLPuM= github.com/italypaleale/go-kit v1.0.0 h1:c+SaYHTaoZzTgbPXhSbNVdNRkNdNhE0YljBWWoPTdKI= github.com/italypaleale/go-kit v1.0.0/go.mod h1:wg4UsIbsbtDiVqUjJdo/tO9lXo0OXBuwPOpSJ+u+1jI= github.com/italypaleale/go-sql-utils v0.3.5 h1:kkrhIo1tVJvcsjRc1kePWE9Cqdm26+Et5XZYSNmv764= diff --git a/backend/internal/apikey/module.go b/backend/internal/apikey/module.go index d0033488..cef50da0 100644 --- a/backend/internal/apikey/module.go +++ b/backend/internal/apikey/module.go @@ -6,7 +6,7 @@ import ( "fmt" "github.com/gin-gonic/gin" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -16,7 +16,7 @@ import ( type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host StaticApiKey string AppConfig appconfig.AppConfigResolver EmailSender APIKeyExpiryEmailSender diff --git a/backend/internal/appconfig/service.go b/backend/internal/appconfig/service.go index 7412b7ae..a27453d4 100644 --- a/backend/internal/appconfig/service.go +++ b/backend/internal/appconfig/service.go @@ -11,7 +11,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/italypaleale/francis/actor" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/apperror" @@ -26,7 +26,7 @@ type AppConfigService struct { envConfig *AppConfigModel } -func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) { +func NewService(ctx context.Context, actors francishost.Host, db *gorm.DB) (service *AppConfigService, err error) { service = &AppConfigService{} // If the UI config is disabled, we do not need to init the config actor @@ -56,8 +56,8 @@ func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service * } err = actors.RegisterSingletonActor( AppConfigActorType, NewAppConfigActor, - local.WithBootstrapData(bootstrapData), - local.WithIdleTimeout(-1), // Disable idle timeout for this actor + francishost.WithBootstrapData(bootstrapData), + francishost.WithIdleTimeout(-1), // Disable idle timeout for this actor ) if err != nil { return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err) diff --git a/backend/internal/bootstrap/actors_bootstrap.go b/backend/internal/bootstrap/actors_bootstrap.go index d4882930..1d8d563a 100644 --- a/backend/internal/bootstrap/actors_bootstrap.go +++ b/backend/internal/bootstrap/actors_bootstrap.go @@ -13,7 +13,9 @@ import ( "github.com/italypaleale/francis/components" "github.com/italypaleale/francis/components/postgres" "github.com/italypaleale/francis/components/sqlite" + francishost "github.com/italypaleale/francis/host" "github.com/italypaleale/francis/host/local" + "github.com/italypaleale/francis/host/remote" "github.com/jackc/pgx/v5/pgxpool" "gorm.io/gorm" @@ -24,6 +26,10 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/utils/crypto" ) +// ErrRemoteFrancisRuntime is returned by the helpers that reach the actor data through Pocket ID's own database when FRANCIS_HOST points to a standalone Francis runtime +// 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") + type NewActorsOpts struct { Postgres *pgxpool.Pool @@ -34,57 +40,25 @@ type NewActorsOpts struct { FileStorage storage.FileStorage } -func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitService, error) { - log := slog.Default() +func NewActors(o NewActorsOpts) (francishost.Host, map[string]*ratelimit.RateLimitService, error) { + log := slog.Default().With("scope", "actor-host") - // Derive a PSK from the global encryption key - // The runtime PSK derives the cluster CA used for host-to-host mTLS - psk, err := o.getPSK() - if err != nil { - return nil, nil, fmt.Errorf("failed to derive PSK: %w", err) + // Create the actor host for the configured topology + // The embedded runtime keeps the actor data in Pocket ID's own database, while a standalone Francis runtime owns it instead and coordinates every host that connects to it + var ( + h francishost.Host + err error + ) + if o.EnvConfig.HasEmbeddedFrancisRuntime() { + log.Debug("Starting the embedded Francis runtime") + h, err = o.newEmbeddedHost(log) + } else { + log.Info("Connecting to a standalone Francis runtime", slog.Any("addresses", o.EnvConfig.FrancisAddresses)) + h, err = o.newRemoteHost(log) } - - // Derive the cluster host limit from the HA setting - // With HA disabled the cluster is capped at a single replica - maxHosts := 1 - if o.EnvConfig.HAEnabled { - // 0 = no cap - maxHosts = 0 - } - - // Options for the host - opts := []local.HostOption{ - local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)), - local.WithLogger(log.With("scope", "actor-host")), - local.WithRuntimePSKs(psk), - local.WithShutdownGracePeriod(10 * time.Second), - local.WithMaxHosts(maxHosts), - local.WithHostHealthCheckDeadline(ActorsHostHealthCheckDeadline(o.EnvConfig.HAEnabled)), - } - - // With a single active host the relaxed alarm intervals reduce database load - // The longer lease duration also means fewer lease renewals, since Francis renews a lease 10s before it expires (no other host can claim the alarm anyways) - // When HA is enabled these are dropped so Francis uses its tighter defaults, which distribute alarm work and fail over faster across multiple hosts - if !o.EnvConfig.HAEnabled { - opts = append(opts, - local.WithAlarmsPollInterval(5*time.Minute), - local.WithAlarmsFetchAheadInterval(5*time.Minute), - local.WithAlarmsLeaseDuration(180*time.Second), - ) - } - - // Add the database connection - providerOpt, err := o.getProviderOption() if err != nil { return nil, nil, err } - opts = append(opts, providerOpt) - - // Create a new actor host - h, err := local.NewHost(opts...) - if err != nil { - return nil, nil, fmt.Errorf("failed to create actor host: %w", err) - } // Add all cron jobs err = o.registerCronJobs(h) @@ -107,6 +81,88 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer return h, rateLimitServices, nil } +// newEmbeddedHost creates the actor host that runs the Francis runtime inside the Pocket ID process, backed by Pocket ID's own database +func (o *NewActorsOpts) newEmbeddedHost(log *slog.Logger) (*local.Host, error) { + // Derive a PSK from the global encryption key + // The runtime PSK derives the cluster CA used for host-to-host mTLS + psk, err := o.getPSK() + if err != nil { + return nil, fmt.Errorf("failed to derive PSK: %w", err) + } + + // Derive the cluster host limit from the HA setting + // With HA disabled the cluster is capped at a single replica + maxHosts := 1 + if o.EnvConfig.HAEnabled { + // 0 = no cap + maxHosts = 0 + } + + // Options for the host + opts := []local.HostOption{ + local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)), + local.WithLogger(log), + local.WithRuntimePSKs(psk), + local.WithShutdownGracePeriod(10 * time.Second), + local.WithMaxHosts(maxHosts), + local.WithHostHealthCheckDeadline(ActorsHostHealthCheckDeadline(o.EnvConfig.HAEnabled)), + } + + // With a single active host the relaxed alarm intervals reduce database load + // The longer lease duration also means fewer lease renewals, since Francis renews a lease 10s before it expires (no other host can claim the alarm anyways) + // When HA is enabled these are dropped so Francis uses its tighter defaults, which distribute alarm work and fail over faster across multiple hosts + if !o.EnvConfig.HAEnabled { + opts = append(opts, + local.WithAlarmsPollInterval(5*time.Minute), + local.WithAlarmsFetchAheadInterval(5*time.Minute), + local.WithAlarmsLeaseDuration(180*time.Second), + ) + } + + // Add the database connection + providerOpt, err := o.getProviderOption() + if err != nil { + return nil, err + } + opts = append(opts, providerOpt) + + h, err := local.NewHost(opts...) + if err != nil { + return nil, fmt.Errorf("failed to create actor host: %w", err) + } + + return h, nil +} + +// newRemoteHost creates the actor host that connects to a standalone Francis runtime +// 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{ + // 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()) + } + + h, err := remote.NewHost(opts...) + if err != nil { + return nil, fmt.Errorf("failed to create remote actor host: %w", err) + } + + return h, nil +} + // 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 @@ -117,7 +173,12 @@ func (o *NewActorsOpts) getPSK() ([]byte, error) { // NewActorStateStore creates a minimal actor host that can read and write actor state directly, without joining the cluster or binding a network port. // It's meant for short-lived contexts such as CLI commands that need to persist actor state (for example, one-time access tokens) without running the full actor host. // The returned host must NOT be Run(): only direct state operations (Get/Set/Delete on state) are supported, and they require the actor state tables to already exist, which is the case whenever the server has run at least once against this database. +// It only works with the embedded runtime, since the actor state then lives in Pocket ID's own database: with a standalone Francis runtime it returns ErrRemoteFrancisRuntime. func NewActorStateStore(o NewActorsOpts) (*local.Host, error) { + if !o.EnvConfig.HasEmbeddedFrancisRuntime() { + return nil, ErrRemoteFrancisRuntime + } + providerOpt, err := o.getProviderOption() if err != nil { return nil, err @@ -234,7 +295,7 @@ func (o *NewActorsOpts) getProviderOption() (local.HostOption, error) { } } -func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) { +func (o *NewActorsOpts) registerCronJobs(host francishost.Host) (err error) { // In test mode, we do not register anything if common.EnvConfig.AppEnv == "test" { return nil @@ -271,7 +332,7 @@ func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) { // registerRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them // Unlike cron jobs, rate limiters keep no durable state, so they are registered in every environment -func (o *NewActorsOpts) registerRateLimiters(host *local.Host) (actors map[string]*ratelimit.RateLimit, err error) { +func (o *NewActorsOpts) registerRateLimiters(host francishost.Host) (actors map[string]*ratelimit.RateLimit, err error) { policies := middleware.RateLimitPolicies() actors = make(map[string]*ratelimit.RateLimit, len(policies)) for _, p := range policies { diff --git a/backend/internal/bootstrap/actors_bootstrap_test.go b/backend/internal/bootstrap/actors_bootstrap_test.go index 4d4c6bc8..b3db830c 100644 --- a/backend/internal/bootstrap/actors_bootstrap_test.go +++ b/backend/internal/bootstrap/actors_bootstrap_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" + "github.com/italypaleale/francis/host/local" + "github.com/italypaleale/francis/host/remote" "github.com/libtnb/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -31,6 +33,85 @@ func TestNewActorsOptsGetPSKUsesStableValue(t *testing.T) { require.Equalf(t, expected, actual, "actual result: %s", actual) } +// TestNewActorsSelectsTopology covers the branch that FRANCIS_HOST drives: with no standalone runtime configured Pocket ID starts an embedded one, and otherwise it connects to the addresses it was given. +func TestNewActorsSelectsTopology(t *testing.T) { + // The actor host is created but never run, so the database only has to exist + newDB := func(t *testing.T) *gorm.DB { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "pocket-id.db") + db, err := gorm.Open(sqlite.Open("file:"+dbPath+"?_pragma=foreign_keys(1)"), &gorm.Config{}) + require.NoError(t, err) + t.Cleanup(func() { + sqlDB, dbErr := db.DB() + if dbErr == nil { + _ = sqlDB.Close() + } + }) + + return db + } + + // registerCronJobs reads the app environment off the global config and skips every job in test mode, which keeps each case down to the host itself and the rate limiters + baseConfig := func(t *testing.T) *common.EnvConfigSchema { + t.Helper() + + originalAppEnv := common.EnvConfig.AppEnv + common.EnvConfig.AppEnv = common.AppEnvTest + t.Cleanup(func() { + common.EnvConfig.AppEnv = originalAppEnv + }) + + return &common.EnvConfigSchema{ + AppEnv: common.AppEnvTest, + EncryptionKey: []byte("test-encryption-key"), + ActorsHost: "127.0.0.1", + ActorsPort: "1414", + } + } + + t.Run("embedded runtime by default", func(t *testing.T) { + cfg := baseConfig(t) + + h, rateLimitServices, err := NewActors(NewActorsOpts{ + EnvConfig: cfg, + InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876", + DB: newDB(t), + }) + require.NoError(t, err) + require.IsType(t, &local.Host{}, h) + require.NotEmpty(t, rateLimitServices) + }) + + t.Run("remote runtime when addresses are configured", func(t *testing.T) { + cfg := baseConfig(t) + cfg.FrancisAddresses = []string{"runtime-1.example.com:8443", "runtime-2.example.com:8443"} + cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough") + + // No database is passed, since a standalone runtime owns the actor data and the remote host must not reach for Pocket ID's own database + h, rateLimitServices, err := NewActors(NewActorsOpts{ + EnvConfig: cfg, + InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876", + }) + require.NoError(t, err) + require.IsType(t, &remote.Host{}, h) + require.NotEmpty(t, rateLimitServices) + }) + + 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"} + cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough") + + _, err := NewActorStateStore(NewActorsOpts{ + EnvConfig: cfg, + InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876", + DB: newDB(t), + }) + require.ErrorIs(t, err, ErrRemoteFrancisRuntime) + }) +} + // TestNewActorsBackupProvider covers the provider the export and import use to back up and restore the actor host's data. // It builds the provider from the same options the actor host uses, so a mismatch between those options and the concrete provider would otherwise only surface at runtime, when an export or import is attempted. func TestNewActorsBackupProvider(t *testing.T) { diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 56292873..1c5d5fbf 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -10,7 +10,7 @@ import ( _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/italypaleale/francis/components" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "github.com/italypaleale/go-kit/servicerunner" "gorm.io/gorm" @@ -152,7 +152,7 @@ func Bootstrap(ctx context.Context) error { } // actorsRunServiceFn wraps the actor host's Run method in a background service and returns a "ready" signal that other services can wait on -func actorsRunServiceFn(actors *local.Host) (servicerunner.Service, *servicerunner.Ready) { +func actorsRunServiceFn(actors francishost.Host) (servicerunner.Service, *servicerunner.Ready) { actorsReady := servicerunner.NewReady() fn := func(ctx context.Context) error { runErrCh := make(chan error, 1) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index d1570970..19f4bae5 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "github.com/pocket-id/pocket-id/backend/internal/api" "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -49,7 +49,7 @@ type services struct { oneTimeAccessModule *onetimeaccess.Module emailVerificationModule *emailverification.Module apiModule *api.Module - actors *local.Host + actors francishost.Host } // Initializes all services @@ -57,7 +57,7 @@ func initServices( ctx context.Context, db *gorm.DB, instanceID string, - actors *local.Host, + actors francishost.Host, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, diff --git a/backend/internal/cmds/export.go b/backend/internal/cmds/export.go index 8e21d1e3..1e12a837 100644 --- a/backend/internal/cmds/export.go +++ b/backend/internal/cmds/export.go @@ -2,6 +2,7 @@ package cmds import ( "context" + "errors" "fmt" "io" "os" @@ -9,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/pocket-id/pocket-id/backend/internal/bootstrap" + "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -34,6 +36,12 @@ func init() { // runExport orchestrates the export flow func runExport(ctx context.Context, flags exportFlags) error { + // The export includes the actor data, which a standalone Francis runtime keeps in its own store rather than in Pocket ID's database + // Exporting anyway would silently produce an archive missing that data, so refuse instead + if !common.EnvConfig.HasEmbeddedFrancisRuntime() { + return errors.New("exporting is not supported when FRANCIS_HOST points to a standalone Francis runtime: export Pocket ID's data and the runtime's data separately, using the runtime's own backup command for the latter") + } + db, pg, err := bootstrap.NewDatabase(ctx) if err != nil { return fmt.Errorf("failed to connect to database: %w", err) diff --git a/backend/internal/cmds/import.go b/backend/internal/cmds/import.go index 27957348..73e89582 100644 --- a/backend/internal/cmds/import.go +++ b/backend/internal/cmds/import.go @@ -47,6 +47,12 @@ func init() { // runImport handles the high-level orchestration of the import process func runImport(ctx context.Context, flags importFlags) error { + // The archive carries the actor data, which is restored into Pocket ID's own database and so is only reachable with the embedded runtime + // A standalone Francis runtime owns that data instead, and it has to be restored through the runtime itself + if !common.EnvConfig.HasEmbeddedFrancisRuntime() { + return errors.New("importing is not supported when FRANCIS_HOST points to a standalone Francis runtime: import Pocket ID's data and the runtime's data separately, using the runtime's own restore command for the latter") + } + if !flags.Yes { ok, err := askForConfirmation() if err != nil { diff --git a/backend/internal/cmds/one_time_access_token.go b/backend/internal/cmds/one_time_access_token.go index 590272e6..9224b0ed 100644 --- a/backend/internal/cmds/one_time_access_token.go +++ b/backend/internal/cmds/one_time_access_token.go @@ -61,7 +61,10 @@ var oneTimeAccessTokenCmd = &cobra.Command{ EnvConfig: &common.EnvConfig, InstanceID: instanceID, }) - if err != nil { + if errors.Is(err, bootstrap.ErrRemoteFrancisRuntime) { + // Writing the token through a standalone runtime would mean joining the cluster as a full actor host, which this short-lived command does not do + return errors.New("generating a one-time access token from the CLI is not supported when FRANCIS_HOST points to a standalone Francis runtime: request the token from a running Pocket ID instance instead") + } else if err != nil { return fmt.Errorf("failed to initialize the actor state store: %w", err) } diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index 82691d31..667dc9db 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -1,6 +1,7 @@ package common import ( + "bytes" "errors" "fmt" "log/slog" @@ -37,6 +38,11 @@ const ( defaultSqliteConnString string = "data/pocket-id.db" defaultFsUploadPath string = "data/uploads" AppUrl string = "http://localhost:1411" + + // FrancisHostEmbedded is the FRANCIS_HOST value that keeps the Francis actor runtime embedded in the Pocket ID process + FrancisHostEmbedded string = "embedded" + // francisHostPSKMinLength is the shortest host bootstrap pre-shared key Francis accepts + francisHostPSKMinLength int = 16 ) type EnvConfigSchema struct { @@ -88,6 +94,20 @@ type EnvConfigSchema struct { ActorsPort string `env:"ACTORS_PORT"` ActorsHost string `env:"ACTORS_HOST" options:"toLower"` + // FrancisHost selects where the Francis actor runtime lives + // When empty or set to "embedded", Pocket ID runs the runtime inside its own process, which is the default + // Any other value is the address (or a comma-separated list of addresses) of a standalone Francis runtime to connect to, and in that case Pocket ID does not start an embedded runtime + FrancisHost string `env:"FRANCIS_HOST" options:"toLower"` + // FrancisHostPSK is the pre-shared key Pocket ID presents to a standalone Francis runtime when joining the cluster + // It must match the "bootstrap.hostPSK" value in the runtime's own configuration, and it is required whenever FrancisHost points to a standalone runtime + FrancisHostPSK []byte `env:"FRANCIS_HOST_PSK" options:"file"` + // FrancisCA is the PEM-encoded cluster CA of a standalone Francis runtime, which Pocket ID pins before its first connection + // Leaving it empty makes Pocket ID trust the certificate the runtime presents on the first connection, which is vulnerable to an attacker intercepting that connection + FrancisCA []byte `env:"FRANCIS_CA" options:"file"` + // FrancisAddresses contains the runtime addresses parsed out of FrancisHost, and is empty when the actor runtime is embedded + // It is derived from FrancisHost and is not bound to an environment variable of its own + FrancisAddresses []string + // HAEnabled turns on high-availability mode, allowing more than one replica of Pocket ID to run against the same database at once // It is intentionally not bound to an environment variable while HA support is still being completed // TODO: Add env var when HA mode is ready @@ -124,6 +144,7 @@ func defaultConfig() EnvConfigSchema { Host: "0.0.0.0", ActorsPort: "1414", ActorsHost: "0.0.0.0", + FrancisHost: FrancisHostEmbedded, GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBUrl: MaxMindGeoLiteCityUrl, } @@ -186,6 +207,12 @@ func ValidateEnvConfig(config *EnvConfigSchema) error { // Prepare the DB config prepareDbConfig(config) + // Resolve where the Francis actor runtime lives, which decides whether Pocket ID starts an embedded one + err = prepareFrancisConfig(config) + if err != nil { + return err + } + // Validate other required options err = validateAppURLs(config) if err != nil { @@ -222,6 +249,60 @@ func prepareDbConfig(config *EnvConfigSchema) { } } +// prepareFrancisConfig resolves FRANCIS_HOST into the list of standalone runtime addresses Pocket ID connects to +// An empty value or the "embedded" constant keeps the actor runtime inside the Pocket ID process, and leaves the address list empty +func prepareFrancisConfig(config *EnvConfigSchema) error { + config.FrancisAddresses = nil + + value := strings.TrimSpace(config.FrancisHost) + if value == "" || value == FrancisHostEmbedded { + return nil + } + + // Any other value is one address, or a comma-separated list of addresses, of the standalone runtime replicas + // Pocket ID dials them directly rather than resolving a service record, so each one must carry an explicit port + parts := strings.Split(value, ",") + addresses := make([]string, 0, len(parts)) + for _, address := range parts { + address = strings.TrimSpace(address) + if address == "" { + continue + } + + _, port, err := net.SplitHostPort(address) + if err != nil || port == "" { + return fmt.Errorf("invalid address '%s' in FRANCIS_HOST: addresses must be in the 'host:port' format", address) + } + + addresses = append(addresses, address) + } + + if len(addresses) == 0 { + return errors.New("FRANCIS_HOST does not contain any address") + } + + // The key has to match the runtime's byte-for-byte, and reading it from a file (including a container secret) usually leaves a trailing newline behind, so surrounding whitespace is never meaningful here + config.FrancisHostPSK = bytes.TrimSpace(config.FrancisHostPSK) + + // A standalone runtime only admits hosts that present the bootstrap pre-shared key it is configured with + // Francis rejects a shorter key, so checking the length here turns that into a configuration error at startup + switch { + case len(config.FrancisHostPSK) == 0: + return errors.New("FRANCIS_HOST_PSK is required when FRANCIS_HOST points to a standalone Francis runtime") + case len(config.FrancisHostPSK) < francisHostPSKMinLength: + return fmt.Errorf("FRANCIS_HOST_PSK must be at least %d bytes long", francisHostPSKMinLength) + } + + config.FrancisAddresses = addresses + + return nil +} + +// HasEmbeddedFrancisRuntime returns true when Pocket ID runs the Francis actor runtime inside its own process, which is the case unless FRANCIS_HOST points to a standalone runtime +func (config *EnvConfigSchema) HasEmbeddedFrancisRuntime() bool { + return len(config.FrancisAddresses) == 0 +} + func validateAppURLs(config *EnvConfigSchema) error { if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil { return err diff --git a/backend/internal/common/env_config_test.go b/backend/internal/common/env_config_test.go index c7c170a6..092215bb 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -353,6 +353,134 @@ func TestParseEnvConfig(t *testing.T) { }) } +func TestFrancisHostConfig(t *testing.T) { + originalConfig := EnvConfig + t.Cleanup(func() { + EnvConfig = originalConfig + }) + + // setBaseEnv sets the variables every valid configuration needs, so each subtest only sets what it is about + setBaseEnv := func(t *testing.T) { + t.Helper() + EnvConfig = defaultConfig() + t.Setenv("DB_CONNECTION_STRING", "file:test.db") + t.Setenv("APP_URL", "http://localhost:3000") + } + + t.Run("should default to the embedded runtime", func(t *testing.T) { + setBaseEnv(t) + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, FrancisHostEmbedded, EnvConfig.FrancisHost) + assert.Empty(t, EnvConfig.FrancisAddresses) + assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime()) + }) + + t.Run("should keep the embedded runtime when FRANCIS_HOST is empty", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "") + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Empty(t, EnvConfig.FrancisAddresses) + assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime()) + }) + + t.Run("should keep the embedded runtime when FRANCIS_HOST is 'embedded'", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "EMBEDDED") + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, FrancisHostEmbedded, EnvConfig.FrancisHost) // lowercased + assert.Empty(t, EnvConfig.FrancisAddresses) + assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime()) + }) + + t.Run("should parse a single remote runtime address", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough") + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, []string{"francis.example.com:8443"}, EnvConfig.FrancisAddresses) + assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime()) + }) + + t.Run("should parse a comma-separated list of remote runtime addresses", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "one.example.com:8443, two.example.com:8443 ,[2001:db8::1]:8443") + t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough") + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, []string{"one.example.com:8443", "two.example.com:8443", "[2001:db8::1]:8443"}, EnvConfig.FrancisAddresses) + assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime()) + }) + + t.Run("should fail when a remote runtime address has no port", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com") + t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid address 'francis.example.com' in FRANCIS_HOST") + }) + + t.Run("should fail when FRANCIS_HOST only contains separators", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", " , ") + t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "FRANCIS_HOST does not contain any address") + }) + + t.Run("should fail when the bootstrap PSK is missing", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "FRANCIS_HOST_PSK is required") + }) + + t.Run("should fail when the bootstrap PSK is too short", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_PSK", "too-short") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "FRANCIS_HOST_PSK must be at least 16 bytes long") + }) + + t.Run("should read the bootstrap PSK and the CA from files", func(t *testing.T) { + setBaseEnv(t) + + tempDir := t.TempDir() + pskFile := tempDir + "/psk" + caFile := tempDir + "/ca.pem" + caContent := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----" + // The trailing newline is what writing a secret to a file usually leaves behind, and it must not become part of the key + require.NoError(t, os.WriteFile(pskFile, []byte("bootstrap-psk-that-is-long-enough\n"), 0600)) + require.NoError(t, os.WriteFile(caFile, []byte(caContent), 0600)) + + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_PSK_FILE", pskFile) + t.Setenv("FRANCIS_CA_FILE", caFile) + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, []byte("bootstrap-psk-that-is-long-enough"), EnvConfig.FrancisHostPSK) + assert.Equal(t, []byte(caContent), EnvConfig.FrancisCA) + }) +} + func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) { // Create temporary directory for test files tempDir := t.TempDir() diff --git a/backend/internal/devicelogin/module.go b/backend/internal/devicelogin/module.go index 6ef3cea9..bfb5f79f 100644 --- a/backend/internal/devicelogin/module.go +++ b/backend/internal/devicelogin/module.go @@ -6,7 +6,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -33,7 +33,7 @@ type IPLocationResolver interface { type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host BaseURL string Signer TokenService diff --git a/backend/internal/emailverification/module.go b/backend/internal/emailverification/module.go index e2502dcf..40289426 100644 --- a/backend/internal/emailverification/module.go +++ b/backend/internal/emailverification/module.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/gin-gonic/gin" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -13,7 +13,7 @@ import ( type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host Users UserProvider EmailSender EmailSender diff --git a/backend/internal/ldapsync/module.go b/backend/internal/ldapsync/module.go index 81aafb66..b93b55d5 100644 --- a/backend/internal/ldapsync/module.go +++ b/backend/internal/ldapsync/module.go @@ -7,7 +7,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -38,7 +38,7 @@ type GroupSyncer interface { type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host HTTPClient *http.Client FileStorage storage.FileStorage diff --git a/backend/internal/onetimeaccess/module.go b/backend/internal/onetimeaccess/module.go index 2b3e1f16..8daf7e17 100644 --- a/backend/internal/onetimeaccess/module.go +++ b/backend/internal/onetimeaccess/module.go @@ -6,7 +6,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -33,7 +33,7 @@ type UserProvider interface { type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host Signer TokenService AuditLog AuditLogger diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index c41fb186..f8ef72cb 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -16,7 +16,7 @@ import ( "github.com/go-webauthn/webauthn/protocol" "github.com/google/uuid" "github.com/italypaleale/francis/actor" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwt" @@ -51,7 +51,7 @@ type LdapSyncer interface { type TestService struct { db *gorm.DB - actors *local.Host + actors francishost.Host jwtService *JwtService appConfigService *appconfig.AppConfigService ldapSyncer LdapSyncer @@ -69,7 +69,7 @@ const ( e2eEmailVerificationToken = "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup" ) -func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) { +func NewTestService(db *gorm.DB, actors francishost.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) { s := &TestService{ db: db, actors: actors, diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go index d7b174f1..c3dcb3e8 100644 --- a/backend/internal/usersignup/module.go +++ b/backend/internal/usersignup/module.go @@ -6,7 +6,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -29,7 +29,7 @@ type UserCreator interface { type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host Signer TokenService AuditLog AuditLogger