diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 2e929af8..a6419d40 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -25,17 +25,26 @@ jobs: strategy: fail-fast: false matrix: + # "francis" selects where the actor runtime lives: embedded in Pocket ID, or a standalone runtime it connects to include: - db: sqlite storage: filesystem + francis: embedded - db: postgres storage: filesystem + francis: embedded - db: sqlite storage: s3 + francis: embedded - db: sqlite storage: database + francis: embedded - db: postgres storage: database + francis: embedded + - db: sqlite + storage: filesystem + francis: remote steps: - name: Checkout code @@ -109,6 +118,31 @@ jobs: if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit == 'true' run: docker load < /tmp/localstack-s3-image.tar + - name: Resolve Francis runtime image + if: matrix.francis == 'remote' + id: francis-image + working-directory: ./tests/setup + # The Compose file is the single source of truth for the version, so the cache key follows it automatically + run: | + IMAGE=$(grep -oP '(?<=image: )ghcr\.io/italypaleale/francis:\S+' docker-compose-francis.yml) + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + echo "key=$(echo "$IMAGE" | tr '/:' '--')" >> "$GITHUB_OUTPUT" + - name: Cache Francis runtime Docker image + if: matrix.francis == 'remote' + uses: actions/cache@v5 + id: francis-cache + with: + path: /tmp/francis-image.tar + key: ${{ steps.francis-image.outputs.key }}-${{ runner.os }} + - name: Pull and save Francis runtime image + if: matrix.francis == 'remote' && steps.francis-cache.outputs.cache-hit != 'true' + run: | + docker pull "${{ steps.francis-image.outputs.image }}" + docker save "${{ steps.francis-image.outputs.image }}" > /tmp/francis-image.tar + - name: Load Francis runtime image + if: matrix.francis == 'remote' && steps.francis-cache.outputs.cache-hit == 'true' + run: docker load < /tmp/francis-image.tar + - name: Install test dependencies run: pnpm --filter pocket-id-tests install --frozen-lockfile @@ -128,7 +162,9 @@ jobs: SCIM_SERVICE_PROVIDER_URL_INTERNAL=http://scim-test-server:8080/v2 EOF - if [ "${{ matrix.db }}" = "postgres" ]; then + if [ "${{ matrix.francis }}" = "remote" ]; then + DOCKER_COMPOSE_FILE=docker-compose-francis.yml + elif [ "${{ matrix.db }}" = "postgres" ]; then DOCKER_COMPOSE_FILE=docker-compose-postgres.yml elif [ "${{ matrix.storage }}" = "s3" ]; then DOCKER_COMPOSE_FILE=docker-compose-s3.yml @@ -150,6 +186,10 @@ jobs: done } & + if [ "${{ matrix.francis }}" = "remote" ]; then + docker compose -f "$DOCKER_COMPOSE_FILE" logs -f --no-log-prefix francis-runtime > /tmp/francis-runtime.log 2>&1 & + fi + - name: Run Playwright tests working-directory: ./tests run: pnpm exec playwright test @@ -158,7 +198,7 @@ jobs: uses: actions/upload-artifact@v7 if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' with: - name: playwright-report-${{ matrix.db }}-${{ matrix.storage }} + name: playwright-report-${{ matrix.db }}-${{ matrix.storage }}-francis-${{ matrix.francis }} path: tests/.report include-hidden-files: true retention-days: 15 @@ -167,7 +207,16 @@ jobs: uses: actions/upload-artifact@v7 if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' with: - name: backend-${{ matrix.db }}-${{ matrix.storage }} + name: backend-${{ matrix.db }}-${{ matrix.storage }}-francis-${{ matrix.francis }} path: /tmp/backend.log include-hidden-files: true retention-days: 15 + + - name: Upload Francis Runtime Report + uses: actions/upload-artifact@v7 + if: always() && matrix.francis == 'remote' && github.event.pull_request.head.ref != 'i18n_crowdin' + with: + name: francis-runtime-${{ matrix.db }}-${{ matrix.storage }} + path: /tmp/francis-runtime.log + include-hidden-files: true + retention-days: 15 diff --git a/AGENTS.md b/AGENTS.md index 2e46f9a6..ed612d29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ End-to-end (needs Docker; **stop any local backend on `:1411` first** — see go ```sh cd tests/setup && docker compose up -d --build # rebuild after ANY code change, or you test stale code +# docker-compose-francis.yml runs the same suite against a standalone Francis runtime instead of the embedded one cd ../.. && pnpm test # = playwright test in tests/ ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bb43440..657ff37e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,6 +121,14 @@ The tests can be run like this: If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again. +By default the test environment runs Pocket ID with the Francis actor runtime embedded in it. To run the same suite against a **standalone Francis runtime** instead, start the environment from the other Compose file: + +```bash +docker compose -f docker-compose-francis.yml up -d --build +``` + +That brings up a SQLite-backed Francis runtime alongside Pocket ID and points `FRANCIS_HOST` at it, so Pocket ID starts no embedded runtime and the actor state, alarms, and placement all live in the runtime instead. The tests themselves are unchanged. CI runs this as an extra matrix entry. + #### Unit tests In the backend we are using unit tests with the built-in Go testing framework. The tests are located in the same folder as the code they are testing and have the `_test.go` suffix. diff --git a/backend/go.mod b/backend/go.mod index bb9f22f7..1f5fb6d9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -63,6 +63,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.18 // indirect diff --git a/backend/go.sum b/backend/go.sum index d9aa2475..2926c974 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= 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/auditlogs/module.go b/backend/internal/auditlogs/module.go index 95f1a514..4beb5dd3 100644 --- a/backend/internal/auditlogs/module.go +++ b/backend/internal/auditlogs/module.go @@ -5,13 +5,13 @@ import ( "errors" "fmt" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" ) type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host // RetentionDays is how long audit logs are kept before the cleanup job deletes them RetentionDays int diff --git a/backend/internal/bootstrap/actors_bootstrap.go b/backend/internal/bootstrap/actors_bootstrap.go index d4882930..4dc8437f 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,14 @@ 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") + +// 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 @@ -34,57 +44,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 +85,108 @@ 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 := 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.WithShutdownGracePeriod(10*time.Second), + ) + + h, err := remote.NewHost(opts...) + if err != nil { + return nil, fmt.Errorf("failed to create remote actor host: %w", err) + } + + 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 @@ -117,7 +197,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 +319,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 +356,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..e8a9b08b 100644 --- a/backend/internal/bootstrap/actors_bootstrap_test.go +++ b/backend/internal/bootstrap/actors_bootstrap_test.go @@ -2,10 +2,23 @@ package bootstrap import ( "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" "encoding/hex" + "encoding/pem" + "log/slog" + "math/big" + "os" "path/filepath" "testing" + "time" + francishost "github.com/italypaleale/francis/host" + "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 +44,151 @@ 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) + }) + + // 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) + }) + } + }) + + // The E2E suite runs the remote variant without a pinned CA, so this is the only place the pinning branch is exercised + t.Run("pinning the cluster CA builds a valid remote host", func(t *testing.T) { + cfg := baseConfig(t) + cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"} + cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough") + cfg.FrancisCA = testCAPEM(t) + + 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("an unparsable cluster CA is rejected", func(t *testing.T) { + cfg := baseConfig(t) + cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"} + cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough") + cfg.FrancisCA = []byte("-----BEGIN CERTIFICATE-----\nnot a certificate\n-----END CERTIFICATE-----") + + opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"} + _, err := opts.newRemoteHost(slog.New(slog.DiscardHandler)) + require.Error(t, err) + }) + + 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"} + 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) { @@ -70,3 +228,26 @@ func TestNewActorsBackupProvider(t *testing.T) { err = provider.Restore(t.Context(), bytes.NewReader(buf.Bytes())) require.NoError(t, err) } + +// testCAPEM returns a self-signed CA certificate in PEM form, standing in for the cluster CA an operator would pin with FRANCIS_CA +func testCAPEM(t *testing.T) []byte { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-cluster-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv) + require.NoError(t, err) + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} diff --git a/backend/internal/bootstrap/actors_client_bootstrap.go b/backend/internal/bootstrap/actors_client_bootstrap.go new file mode 100644 index 00000000..c31ecaeb --- /dev/null +++ b/backend/internal/bootstrap/actors_client_bootstrap.go @@ -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 + } +} diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 1aaa7984..ce5508c4 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" @@ -137,7 +137,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 241b9ea6..6a732d76 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" @@ -52,7 +52,7 @@ type services struct { emailVerificationModule *emailverification.Module apiModule *api.Module environmentModule *environment.Module - actors *local.Host + actors francishost.Host } // Initializes all services @@ -60,7 +60,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..b23c5fcd 100644 --- a/backend/internal/cmds/export.go +++ b/backend/internal/cmds/export.go @@ -9,6 +9,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" ) @@ -49,18 +50,27 @@ func runExport(ctx context.Context, flags exportFlags) error { _ = storage.Close() }() - // The actor host's data lives outside of the Pocket ID schema, so it's exported through Francis - providerOpts, err := bootstrap.ActorsProviderOptions(db, pg) - if err != nil { - return err + // The actor data lives outside of the Pocket ID schema, so it's exported through Francis + // A standalone runtime keeps it in its own store, out of reach from here, and the export service leaves the entry out of the archive when no provider is passed + var actorsProvider service.ActorsBackupProvider + if common.EnvConfig.HasEmbeddedFrancisRuntime() { + providerOpts, provErr := bootstrap.ActorsProviderOptions(db, pg) + if provErr != nil { + return provErr + } + + provider, provErr := bootstrap.NewActorsBackupProvider(ctx, providerOpts) + if provErr != nil { + return fmt.Errorf("failed to initialize the actor host's data provider: %w", provErr) + } + defer func() { + _ = provider.Close() + }() + + actorsProvider = provider + } else { + printRemoteActorDataNotice("The actor data is NOT included in this export", "back it up separately with: francis runtime backup -f actors.bin") } - actorsProvider, err := bootstrap.NewActorsBackupProvider(ctx, providerOpts) - if err != nil { - return fmt.Errorf("failed to initialize the actor host's data provider: %w", err) - } - defer func() { - _ = actorsProvider.Close() - }() exportService := service.NewExportService(db, storage, actorsProvider) diff --git a/backend/internal/cmds/francis_runtime.go b/backend/internal/cmds/francis_runtime.go new file mode 100644 index 00000000..226ca9aa --- /dev/null +++ b/backend/internal/cmds/francis_runtime.go @@ -0,0 +1,33 @@ +package cmds + +import ( + "archive/zip" + "fmt" + "os" + + "github.com/pocket-id/pocket-id/backend/internal/service" +) + +// printRemoteActorDataNotice warns that the command does not cover the actor data, which a standalone Francis runtime owns rather than Pocket ID +// It goes to stderr so it stays visible when the archive itself is streamed to stdout +func printRemoteActorDataNotice(consequence string, remedy string) { + fmt.Fprintf(os.Stderr, `WARNING: FRANCIS_HOST points to a standalone Francis runtime. + %s. + That data covers the app configuration, signup and one-time access tokens, device + login requests, LDAP sync state, and the schedule of the background jobs. + To cover it, %s. + +`, consequence, remedy) +} + +// ensureNoActorsBackup rejects an archive that carries the actor data when a standalone Francis runtime owns it +// Such an archive comes from a deployment with an embedded runtime, and restoring only its Pocket ID half would leave the runtime holding actor state belonging to a different deployment +func ensureNoActorsBackup(zipReader *zip.Reader) error { + for _, f := range zipReader.File { + if f.Name == service.ActorsBackupFileName { + return fmt.Errorf("this archive contains the actor data (%s) but FRANCIS_HOST points to a standalone Francis runtime, which owns that data instead: restore it into a deployment with an embedded runtime, or load the actor data into the runtime with 'francis runtime restore'", service.ActorsBackupFileName) + } + } + + return nil +} diff --git a/backend/internal/cmds/francis_runtime_test.go b/backend/internal/cmds/francis_runtime_test.go new file mode 100644 index 00000000..5999c1f2 --- /dev/null +++ b/backend/internal/cmds/francis_runtime_test.go @@ -0,0 +1,43 @@ +package cmds + +import ( + "archive/zip" + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/service" +) + +func TestEnsureNoActorsBackup(t *testing.T) { + buildZip := func(t *testing.T, names ...string) *zip.Reader { + t.Helper() + + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + for _, name := range names { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write([]byte("payload")) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + require.NoError(t, err) + + return zr + } + + t.Run("accepts an archive without the actor data", func(t *testing.T) { + err := ensureNoActorsBackup(buildZip(t, "database.json", "uploads/logo.png")) + require.NoError(t, err) + }) + + t.Run("rejects an archive carrying the actor data", func(t *testing.T) { + err := ensureNoActorsBackup(buildZip(t, "database.json", service.ActorsBackupFileName)) + require.Error(t, err) + require.ErrorContains(t, err, service.ActorsBackupFileName) + }) +} diff --git a/backend/internal/cmds/import.go b/backend/internal/cmds/import.go index 27957348..b10add59 100644 --- a/backend/internal/cmds/import.go +++ b/backend/internal/cmds/import.go @@ -47,6 +47,16 @@ func init() { // runImport handles the high-level orchestration of the import process func runImport(ctx context.Context, flags importFlags) error { + // A standalone Francis runtime owns the actor data, so this import only covers what lives in Pocket ID's own database + // Nothing here can fence the replicas either, since they are hosts of the runtime's cluster rather than of a cluster in this database + embeddedRuntime := common.EnvConfig.HasEmbeddedFrancisRuntime() + if !embeddedRuntime { + printRemoteActorDataNotice( + "The actor data will NOT be restored, and Pocket ID replicas will NOT be stopped for you", + "stop every replica first, then restore the runtime with: francis runtime restore -f actors.bin", + ) + } + if !flags.Yes { ok, err := askForConfirmation() if err != nil { @@ -75,35 +85,49 @@ func runImport(ctx context.Context, flags importFlags) error { } defer zipReader.Close() + // An archive carrying the actor data was taken from a deployment with an embedded runtime, and there is nowhere to put that data here + // Restoring only the Pocket ID half of it would leave the runtime holding actor state from a different deployment, so refuse rather than half-restore + if !embeddedRuntime { + err = ensureNoActorsBackup(&zipReader.Reader) + if err != nil { + return err + } + } + // Connect to the database without running migrations: the import re-creates the Pocket ID schema itself db, pg, err := bootstrap.ConnectDatabase(ctx) if err != nil { return err } - // The cluster admin talks to the same database as the actor host, so build its provider options the same way the host does - providerOpts, err := bootstrap.ActorsProviderOptions(db, pg) - if err != nil { - return err - } - - // Take exclusive access to the cluster so no Pocket ID replica is running while we overwrite the database - release, lost, err := acquireExclusiveAccess(ctx, providerOpts, flags.ForcefullyAcquireLock) - if err != nil { - return err - } - defer release() - - // Abort the import if exclusive access is lost partway through (for example if the lease can no longer be renewed) importCtx, cancel := context.WithCancel(ctx) defer cancel() - go func() { - select { - case <-lost: - cancel() - case <-importCtx.Done(): + + // Take exclusive access to the cluster so no Pocket ID replica is running while we overwrite the database + // The lease lives in the actor host's own tables, so it only exists when the runtime is embedded: with a standalone runtime the operator was told to stop the replicas instead + var providerOpts components.ProviderOptions + if embeddedRuntime { + // The cluster admin talks to the same database as the actor host, so build its provider options the same way the host does + providerOpts, err = bootstrap.ActorsProviderOptions(db, pg) + if err != nil { + return err } - }() + + release, lost, acquireErr := acquireExclusiveAccess(ctx, providerOpts, flags.ForcefullyAcquireLock) + if acquireErr != nil { + return acquireErr + } + defer release() + + // Abort the import if exclusive access is lost partway through (for example if the lease can no longer be renewed) + go func() { + select { + case <-lost: + cancel() + case <-importCtx.Done(): + } + }() + } // Init the storage provider storage, err := bootstrap.InitStorage(importCtx, db) @@ -116,15 +140,20 @@ func runImport(ctx context.Context, flags importFlags) error { _ = storage.Close() }() - // The actor host's data lives outside of the Pocket ID schema, so it's restored through Francis - // Restoring requires exclusive access to the cluster, which was acquired above - actorsProvider, err := bootstrap.NewActorsBackupProvider(importCtx, providerOpts) - if err != nil { - return fmt.Errorf("failed to initialize the actor host's data provider: %w", err) + // The actor data lives outside of the Pocket ID schema, so it's restored through Francis + // Restoring requires exclusive access to the cluster, which was acquired above, and the import service skips the actor data entirely when no provider is passed + var actorsProvider service.ActorsBackupProvider + if embeddedRuntime { + provider, provErr := bootstrap.NewActorsBackupProvider(importCtx, providerOpts) + if provErr != nil { + return fmt.Errorf("failed to initialize the actor host's data provider: %w", provErr) + } + defer func() { + _ = provider.Close() + }() + + actorsProvider = provider } - defer func() { - _ = actorsProvider.Close() - }() // Create the import service importService := service.NewImportService(db, storage, actorsProvider) diff --git a/backend/internal/cmds/one_time_access_token.go b/backend/internal/cmds/one_time_access_token.go index 590272e6..257afe90 100644 --- a/backend/internal/cmds/one_time_access_token.go +++ b/backend/internal/cmds/one_time_access_token.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + francishost "github.com/italypaleale/francis/host" + "github.com/jackc/pgx/v5/pgxpool" "github.com/spf13/cobra" "gorm.io/gorm" @@ -53,22 +55,9 @@ var oneTimeAccessTokenCmd = &cobra.Command{ return err } - // One-time access tokens are stored in the actor state store - // The CLI doesn't run the full actor host, so it uses a minimal state store to persist the token directly - actorStore, err := bootstrap.NewActorStateStore(bootstrap.NewActorsOpts{ - DB: db, - Postgres: pg, - EnvConfig: &common.EnvConfig, - InstanceID: instanceID, - }) - if err != nil { - return fmt.Errorf("failed to initialize the actor state store: %w", err) - } - - // Create a new access token that expires in 1 hour - tokenCtx, tokenCancel := context.WithTimeout(cmd.Context(), 10*time.Second) - defer tokenCancel() - token, _, err := onetimeaccess.StoreToken(tokenCtx, actorStore, user.ID, time.Hour, false) + // One-time access tokens live in the actor state store, which is reached differently depending on where the actor runtime runs + // The CLI never runs the full actor host: with an embedded runtime it writes to Pocket ID's database directly, and with a standalone one it joins the cluster as a client for just long enough to write the token + token, err := storeOneTimeAccessToken(cmd.Context(), db, pg, instanceID, user.ID) if err != nil { return fmt.Errorf("failed to create access token: %w", err) } @@ -81,6 +70,48 @@ var oneTimeAccessTokenCmd = &cobra.Command{ }, } +// storeOneTimeAccessToken persists a one-time access token valid for one hour, through whichever actor runtime this deployment uses, and returns the token +func storeOneTimeAccessToken(ctx context.Context, db *gorm.DB, pg *pgxpool.Pool, instanceID string, userID string) (string, error) { + // A standalone Francis runtime owns the actor state, so the token is written through a short-lived client connection to it + if !common.EnvConfig.HasEmbeddedFrancisRuntime() { + var token string + err := bootstrap.WithActorClient(ctx, &common.EnvConfig, func(clientCtx context.Context, client francishost.Host) error { + tokenCtx, tokenCancel := context.WithTimeout(clientCtx, 10*time.Second) + defer tokenCancel() + + var storeErr error + token, _, storeErr = onetimeaccess.StoreToken(tokenCtx, client, userID, time.Hour, false) + return storeErr + }) + if err != nil { + return "", err + } + + return token, nil + } + + // With the embedded runtime the actor state lives in Pocket ID's own database, which a minimal state store writes to without running an actor host + actorStore, err := bootstrap.NewActorStateStore(bootstrap.NewActorsOpts{ + DB: db, + Postgres: pg, + EnvConfig: &common.EnvConfig, + InstanceID: instanceID, + }) + if err != nil { + return "", fmt.Errorf("failed to initialize the actor state store: %w", err) + } + + tokenCtx, tokenCancel := context.WithTimeout(ctx, 10*time.Second) + defer tokenCancel() + + token, _, err := onetimeaccess.StoreToken(tokenCtx, actorStore, userID, time.Hour, false) + if err != nil { + return "", err + } + + return token, nil +} + func init() { rootCmd.AddCommand(oneTimeAccessTokenCmd) } diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index ce51557b..3ec61a35 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" @@ -42,6 +43,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 { @@ -93,6 +99,27 @@ 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 + // It is one of the three ways to authenticate to the runtime, and exactly one of them is required whenever FrancisHost points to a standalone runtime + FrancisHostPSK []byte `env:"FRANCIS_HOST_PSK" options:"file"` + // FrancisHostJWT is the bearer token Pocket ID presents to a standalone Francis runtime configured for JWT bootstrap + // Prefer FrancisHostJWTFile in production, since a token passed inline cannot be rotated without restarting Pocket ID + FrancisHostJWT string `env:"FRANCIS_HOST_JWT"` + // FrancisHostJWTFile is the path to a file holding the bearer token Pocket ID presents to a standalone Francis runtime + // Unlike the other "_FILE" variables this one keeps the path rather than the contents: the file is re-read on every connection to the runtime, so a rotated token (such as a Kubernetes projected service account token) is picked up without restarting Pocket ID + FrancisHostJWTFile string `env:"FRANCIS_HOST_JWT_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 @@ -133,6 +160,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, } @@ -195,6 +223,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 { @@ -231,6 +265,96 @@ 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") + } + + // Credentials have to match the runtime's byte-for-byte, and reading one 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) + config.FrancisHostJWT = strings.TrimSpace(config.FrancisHostJWT) + + err := validateFrancisBootstrap(config) + if err != nil { + return err + } + + config.FrancisAddresses = addresses + + return nil +} + +// validateFrancisBootstrap checks the credential Pocket ID presents when joining a standalone Francis runtime +// The runtime admits a host through exactly one bootstrap method, so configuring none or more than one is a configuration error rather than something to resolve by picking a winner +func validateFrancisBootstrap(config *EnvConfigSchema) error { + configured := make([]string, 0, 3) + if len(config.FrancisHostPSK) > 0 { + configured = append(configured, "FRANCIS_HOST_PSK") + } + if config.FrancisHostJWT != "" { + configured = append(configured, "FRANCIS_HOST_JWT") + } + if config.FrancisHostJWTFile != "" { + configured = append(configured, "FRANCIS_HOST_JWT_FILE") + } + + switch len(configured) { + case 1: + // Exactly one method, which is what the runtime expects + case 0: + return errors.New("one of FRANCIS_HOST_PSK, FRANCIS_HOST_JWT, or FRANCIS_HOST_JWT_FILE is required when FRANCIS_HOST points to a standalone Francis runtime") + default: + return fmt.Errorf("only one host bootstrap method may be configured, but %s are all set", strings.Join(configured, ", ")) + } + + // Francis rejects a shorter key, so checking the length here turns that into a configuration error at startup + if len(config.FrancisHostPSK) > 0 && len(config.FrancisHostPSK) < francisHostPSKMinLength { + return fmt.Errorf("FRANCIS_HOST_PSK must be at least %d bytes long", francisHostPSKMinLength) + } + + // A token read on every connection is useless if the file is not there when Pocket ID starts + if config.FrancisHostJWTFile != "" { + _, err := os.Stat(config.FrancisHostJWTFile) + if err != nil { + return fmt.Errorf("FRANCIS_HOST_JWT_FILE not found: %w", err) + } + } + + 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 85d1cac5..1a21e035 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -396,6 +396,182 @@ 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 no bootstrap method is configured", 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, "one of FRANCIS_HOST_PSK, FRANCIS_HOST_JWT, or FRANCIS_HOST_JWT_FILE is required") + }) + + t.Run("should accept a bootstrap JWT", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_JWT", " header.payload.signature\n") + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, "header.payload.signature", EnvConfig.FrancisHostJWT) + assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime()) + }) + + t.Run("should accept a bootstrap JWT file and keep its path", func(t *testing.T) { + setBaseEnv(t) + + // The path is what gets stored, not the contents: Francis re-reads the file on every connection so a rotated token is picked up + jwtFile := t.TempDir() + "/token" + require.NoError(t, os.WriteFile(jwtFile, []byte("header.payload.signature"), 0600)) + + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_JWT_FILE", jwtFile) + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, jwtFile, EnvConfig.FrancisHostJWTFile) + assert.Empty(t, EnvConfig.FrancisHostJWT) + }) + + t.Run("should fail when the bootstrap JWT file does not exist", func(t *testing.T) { + setBaseEnv(t) + t.Setenv("FRANCIS_HOST", "francis.example.com:8443") + t.Setenv("FRANCIS_HOST_JWT_FILE", "/nonexistent/token") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "FRANCIS_HOST_JWT_FILE not found") + }) + + t.Run("should fail when more than one bootstrap method is configured", 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") + t.Setenv("FRANCIS_HOST_JWT", "header.payload.signature") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "only one host bootstrap method may be configured, but FRANCIS_HOST_PSK, FRANCIS_HOST_JWT are all set") + }) + + 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 ccfca920..ae71769c 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" @@ -43,7 +43,7 @@ type ScimSyncScheduler interface { type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host HTTPClient *http.Client FileStorage storage.FileStorage diff --git a/backend/internal/oidc/module.go b/backend/internal/oidc/module.go index 9de3ef57..b08e8efa 100644 --- a/backend/internal/oidc/module.go +++ b/backend/internal/oidc/module.go @@ -8,7 +8,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/pocket-id/pocket-id/backend/internal/model" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -42,7 +42,7 @@ type AuditLogger interface { type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host Config Config HTTPClient *http.Client 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/scimsync/module.go b/backend/internal/scimsync/module.go index 481aef5b..3304626d 100644 --- a/backend/internal/scimsync/module.go +++ b/backend/internal/scimsync/module.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" "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/httpserver" @@ -16,7 +16,7 @@ import ( type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host HTTPClient *http.Client // ScheduleDisabled keeps automatic synchronizations from being armed diff --git a/backend/internal/service/actors_backup.go b/backend/internal/service/actors_backup.go index 53fb4eca..3fe73847 100644 --- a/backend/internal/service/actors_backup.go +++ b/backend/internal/service/actors_backup.go @@ -5,9 +5,9 @@ import ( "io" ) -// actorsBackupFileName is the name of the entry in the export ZIP that contains the actor host's data +// ActorsBackupFileName is the name of the entry in the export ZIP that contains the actor host's data // The payload is Francis' own backup stream, which is a binary, versioned, provider-neutral format -const actorsBackupFileName = "francis.bin" +const ActorsBackupFileName = "francis.bin" // ActorsBackupProvider backs up and restores the actor host's data: actor state, alarms, and dead-lettered jobs. type ActorsBackupProvider interface { diff --git a/backend/internal/service/actors_backup_test.go b/backend/internal/service/actors_backup_test.go index c57f4943..1e1603e3 100644 --- a/backend/internal/service/actors_backup_test.go +++ b/backend/internal/service/actors_backup_test.go @@ -45,7 +45,7 @@ func TestExportActorsBackup(t *testing.T) { files := writeActorsBackupZip(t, NewExportService(nil, nil, actors)) - require.Equal(t, map[string][]byte{actorsBackupFileName: actors.backupData}, files) + require.Equal(t, map[string][]byte{ActorsBackupFileName: actors.backupData}, files) }) t.Run("adds nothing without a provider", func(t *testing.T) { @@ -69,7 +69,7 @@ func TestImportActorsBackup(t *testing.T) { actors := &stubActorsBackupProvider{} files := readZip(t, buildZip(t, map[string][]byte{ "database.json": []byte("{}"), - actorsBackupFileName: []byte("francis-backup-payload"), + ActorsBackupFileName: []byte("francis-backup-payload"), })) err := NewImportService(nil, nil, actors).importActorsBackup(t.Context(), files) @@ -92,7 +92,7 @@ func TestImportActorsBackup(t *testing.T) { t.Run("surfaces restore errors", func(t *testing.T) { actors := &stubActorsBackupProvider{restoreErr: errors.New("a host is still connected")} - files := readZip(t, buildZip(t, map[string][]byte{actorsBackupFileName: []byte("francis-backup-payload")})) + files := readZip(t, buildZip(t, map[string][]byte{ActorsBackupFileName: []byte("francis-backup-payload")})) err := NewImportService(nil, nil, actors).importActorsBackup(t.Context(), files) diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 939daa9a..961523aa 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/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/service/export_service.go b/backend/internal/service/export_service.go index cbac2944..d6261c03 100644 --- a/backend/internal/service/export_service.go +++ b/backend/internal/service/export_service.go @@ -248,9 +248,9 @@ func (s *ExportService) addActorsBackupToZip(ctx context.Context, zipWriter *zip return nil } - w, err := zipWriter.Create(actorsBackupFileName) + w, err := zipWriter.Create(ActorsBackupFileName) if err != nil { - return fmt.Errorf("failed to create %s in zip: %w", actorsBackupFileName, err) + return fmt.Errorf("failed to create %s in zip: %w", ActorsBackupFileName, err) } err = s.actors.Backup(ctx, w) diff --git a/backend/internal/service/import_service.go b/backend/internal/service/import_service.go index 65f9597d..1946026c 100644 --- a/backend/internal/service/import_service.go +++ b/backend/internal/service/import_service.go @@ -81,20 +81,20 @@ func (s *ImportService) importActorsBackup(ctx context.Context, files []*zip.Fil var backupFile *zip.File for _, f := range files { - if f.Name == actorsBackupFileName { + if f.Name == ActorsBackupFileName { backupFile = f break } } if backupFile == nil { // Archives exported before Pocket ID included the actor host's data don't have that entry, in which case the existing data is left untouched - slog.WarnContext(ctx, "The archive does not contain the actor host's data, which will be left unchanged", slog.String("file", actorsBackupFileName)) + slog.WarnContext(ctx, "The archive does not contain the actor host's data, which will be left unchanged", slog.String("file", ActorsBackupFileName)) return nil } rc, err := backupFile.Open() if err != nil { - return fmt.Errorf("failed to open %s: %w", actorsBackupFileName, err) + return fmt.Errorf("failed to open %s: %w", ActorsBackupFileName, err) } defer rc.Close() diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go index 86db5900..642e6202 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" @@ -34,7 +34,7 @@ type ScimSyncScheduler interface { type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host Signer TokenService AuditLog AuditLogger diff --git a/backend/internal/webauthn/module.go b/backend/internal/webauthn/module.go index 0d7b6479..f87e8b6e 100644 --- a/backend/internal/webauthn/module.go +++ b/backend/internal/webauthn/module.go @@ -7,7 +7,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/italypaleale/francis/host/local" + francishost "github.com/italypaleale/francis/host" "github.com/lestrrat-go/jwx/v3/jwt" "gorm.io/gorm" @@ -29,7 +29,7 @@ type AuditLogger interface { type Dependencies struct { DB *gorm.DB - Actors *local.Host + Actors francishost.Host AppURL string Signer TokenService diff --git a/tests/setup/docker-compose-francis.yml b/tests/setup/docker-compose-francis.yml new file mode 100644 index 00000000..81db54e0 --- /dev/null +++ b/tests/setup/docker-compose-francis.yml @@ -0,0 +1,48 @@ +# This Docker Compose file is used to set up the environment for the tests. +# It's the variant where Pocket ID does not embed the Francis actor runtime, but connects to a standalone one instead. +services: + lldap: + extends: + file: docker-compose.yml + service: lldap + scim-test-server: + extends: + file: docker-compose.yml + service: scim-test-server + francis-runtime: + image: ghcr.io/italypaleale/francis:0.1.0-rc.2 + volumes: + - ./francis-config.yaml:/etc/francis/config.yaml:ro + - francis-test-data:/data + # The image ships its own HEALTHCHECK, which probes the runtime over the loopback + # It's repeated here so Pocket ID can wait on it, and so a runtime that never comes up fails fast instead of after the default retries + healthcheck: + test: ["CMD", "/bin/francis", "healthcheck"] + interval: 2s + timeout: 5s + retries: 15 + start_period: 5s + pocket-id: + extends: + file: docker-compose.yml + service: pocket-id + environment: + APP_ENV: test + ENCRYPTION_KEY: test-encryption-key + FILE_BACKEND: ${FILE_BACKEND} + # The runtime's port is UDP, since WebTransport runs over HTTP/3 + FRANCIS_HOST: francis-runtime:7400 + # Must match "bootstrap.hostPSK" in francis-config.yaml + FRANCIS_HOST_PSK: e2e-host-bootstrap-psk-0123456789 + # FRANCIS_CA is intentionally unset, so this exercises the same trust-on-first-use path an operator gets without it + # The cluster only exists inside this Compose network for the duration of the tests + # + # Peers reach actors placed on this host at ACTORS_HOST, which the runtime hands out, so it has to be the address other containers resolve rather than the default wildcard + ACTORS_HOST: pocket-id + depends_on: + francis-runtime: + condition: service_healthy + +volumes: + pocket-id-test-data: + francis-test-data: diff --git a/tests/setup/francis-config.yaml b/tests/setup/francis-config.yaml new file mode 100644 index 00000000..6c490807 --- /dev/null +++ b/tests/setup/francis-config.yaml @@ -0,0 +1,26 @@ +# Configuration for the standalone Francis runtime used by the "remote Francis" E2E variant. +# In that variant Pocket ID does not embed the actor runtime: it connects to this one instead, which owns the actor state, placement, and alarms. +# These secrets are fixed test values and must match the FRANCIS_HOST_PSK passed to Pocket ID in docker-compose-francis.yml. + +# The WebTransport server runs over HTTP/3, so this port is UDP +bind: "0.0.0.0:7400" + +# The runtime PSKs derive the cluster CA that signs every workload certificate +runtimePSKs: + - "e2e-runtime-psk-0123456789abcdef" + +# Hosts prove they may join by presenting this pre-shared key +bootstrap: + method: psk + hostPSK: "e2e-host-bootstrap-psk-0123456789" + +# The runtime owns its own SQLite store, which is separate from Pocket ID's database +# It lives on a volume because the image runs as a non-root user that cannot write to the image filesystem +provider: + connectionString: "/data/francis.db" + +# A single Pocket ID replica joins the cluster, matching the cap the embedded runtime applies when HA is off +maxHosts: 1 + +log: + level: debug diff --git a/tests/specs/cli.spec.ts b/tests/specs/cli.spec.ts index 6d0d3c06..a10adbbd 100644 --- a/tests/specs/cli.spec.ts +++ b/tests/specs/cli.spec.ts @@ -12,11 +12,19 @@ const containerName = 'pocket-id'; const setupDir = pathFromRoot('setup'); const exampleExportPath = pathFromRoot('resources/export'); const dockerCommandMaxBuffer = 100 * 1024 * 1024; -let mode: 'sqlite' | 'postgres' | 's3' = 'sqlite'; +let mode: 'sqlite' | 'postgres' | 's3' | 'francis' = 'sqlite'; + +// With a standalone Francis runtime the actor data lives in the runtime's own store rather than in Pocket ID's database, +// so an export cannot include francis.bin and an import refuses an archive that carries one. +function isRemoteFrancis(): boolean { + return mode === 'francis'; +} test.beforeAll(() => { const dockerComposeLs = runDockerCommand(['compose', 'ls', '--format', 'json']); - if (dockerComposeLs.includes('postgres')) { + if (dockerComposeLs.includes('francis')) { + mode = 'francis'; + } else if (dockerComposeLs.includes('postgres')) { mode = 'postgres'; } else if (dockerComposeLs.includes('s3')) { mode = 's3'; @@ -104,6 +112,30 @@ test('Import SQLite export via stdin', async () => { compareExports(exampleExportPath, exportExtracted); }); +test('Import rejects an archive with actor data against a standalone runtime', async () => { + test.skip( + !isRemoteFrancis(), + 'Only applies when a standalone Francis runtime owns the actor data' + ); + + // Keeping francis.bin makes this the archive of a deployment that embedded the runtime, which has nowhere to be restored here + const archivePath = path.join(tmpDir, 'example-export-with-actors.zip'); + const archive = archiveExampleExport(archivePath, true); + + // The import aborts before it opens the database, so the running instance is left untouched + let stderr = ''; + expect(() => { + try { + runImportFromStdin(archive); + } catch (err: any) { + stderr = err?.stderr?.toString() ?? ''; + throw err; + } + }).toThrow(); + + expect(stderr).toContain('francis.bin'); +}); + function compareExports(dir1: string, dir2: string): void { const hashes1 = hashAllFiles(dir1); const hashes2 = hashAllFiles(dir2); @@ -135,9 +167,18 @@ function compareExports(dir1: string, dir2: string): void { expect(normalizedActual).toEqual(normalizedExpected); // Compare francis.bin contents + // The reference export always carries it, while the produced one only does when Pocket ID owns the actor data const file1 = path.join(dir1, 'francis.bin'); const file2 = path.join(dir2, 'francis.bin'); - + + if (isRemoteFrancis()) { + expect( + fs.existsSync(file2), + `${file2} must not exist: the standalone Francis runtime owns the actor data` + ).toBe(false); + return; + } + for (const filePath of [file1, file2]) { expect(fs.existsSync(filePath), `${filePath} should exist`).toBe(true); @@ -149,12 +190,18 @@ function compareExports(dir1: string, dir2: string): void { } } -function archiveExampleExport(outputPath: string): Buffer { +// archiveExampleExport zips the reference export so it can be fed back to the import command. +// With a standalone Francis runtime it drops francis.bin, so the archive matches what an export produces in that topology; keepActorsBackup overrides that to build the archive the import is expected to reject. +function archiveExampleExport(outputPath: string, keepActorsBackup = false): Buffer { fs.rmSync(outputPath, { force: true }); + const skipActorsBackup = isRemoteFrancis() && !keepActorsBackup; + const zip = new AdmZip(); const files = fs.readdirSync(exampleExportPath); for (const file of files) { + if (skipActorsBackup && file === 'francis.bin') continue; + const filePath = path.join(exampleExportPath, file); if (fs.statSync(filePath).isFile()) { zip.addLocalFile(filePath); @@ -168,7 +215,6 @@ function archiveExampleExport(outputPath: string): Buffer { return buffer; } - // Helper to load JSON files function loadJSON(path: string) { return JSON.parse(fs.readFileSync(path, 'utf-8')); @@ -382,6 +428,9 @@ function dockerComposeArgs(args: string[]): string[] { case 's3': dockerComposeFile = 'docker-compose-s3.yml'; break; + case 'francis': + dockerComposeFile = 'docker-compose-francis.yml'; + break; } return ['compose', '-f', dockerComposeFile, ...args]; }