mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-06 09:49:04 +02:00
feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When set to "embedded" (the default), Pocket ID starts the runtime inside its own process. Any other value is the address, or a comma-separated list of addresses, of a standalone Francis runtime. Pocket ID then connects to it as a remote actor host and starts no embedded runtime. Because when using a remote runtime, it's likewise not possible to enforce a single instance of Pocket ID is running at once, the env vars currently have the `EXPERIMENTAL_` prefix, are **undocumented**, and show a warning if used. Notes: - Connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE, and optionally (but recommended) FRANCIS_CA. - When connecting to a remote runtime, exporting Pocket ID data does not include the actor state, which will need to be backed up and restored separately
This commit is contained in:
@@ -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 min length for the host bootstrap pre-shared key for Francis
|
||||
francisHostPSKMinLength int = 16
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
@@ -93,6 +99,22 @@ type EnvConfigSchema struct {
|
||||
ActorsPort string `env:"ACTORS_PORT"`
|
||||
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
|
||||
|
||||
// FrancisHost selects where the Francis actor runtime lives
|
||||
// When set to "embedded" (the default), Pocket ID runs the runtime inside its own process
|
||||
// Any other value is the address (or a comma-separated list of addresses) of a standalone Francis runtime to connect to
|
||||
FrancisHost string `env:"EXPERIMENTAL_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
|
||||
// One and only one of FrancisHostPSK and FrancisHostJWTFile must be set when FrancisHost is pointing at a standalone runtime
|
||||
FrancisHostPSK []byte `env:"EXPERIMENTAL_FRANCIS_HOST_PSK" options:"file"`
|
||||
// FrancisHostJWTFile is the path to a file holding the bearer token Pocket ID presents to a standalone Francis runtime
|
||||
// One and only one of FrancisHostPSK and FrancisHostJWTFile must be set when FrancisHost is pointing at a standalone runtime
|
||||
// Note: 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:"EXPERIMENTAL_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:"EXPERIMENTAL_FRANCIS_CA" options:"file"`
|
||||
|
||||
// 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
|
||||
@@ -108,6 +130,12 @@ type EnvConfigSchema struct {
|
||||
// This is true when DISMISS_SQLITE_STORAGE_WARNING is the exact confirmation phrase set in the constant above
|
||||
// Note: this is omitted from the general list of environment variables in the docs, and documented only in the SQLite-specific section
|
||||
DismissSQLiteStorageWarning DismissSQLiteStorageWarningConfig `env:"DISMISS_SQLITE_STORAGE_WARNING"`
|
||||
|
||||
/*** Internal properties ***/
|
||||
|
||||
// francisAddresses contains the runtime addresses parsed out of FrancisHost, and is empty when the actor runtime is embedded
|
||||
// It is automatically derived from FrancisHost
|
||||
francisAddresses []string
|
||||
}
|
||||
|
||||
var EnvConfig = defaultConfig()
|
||||
@@ -133,6 +161,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 +224,18 @@ 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
|
||||
}
|
||||
|
||||
// Show a warning if using a standalone Francis runtime as it's currently experimental and meant for development only
|
||||
// TODO: Remove when HA mode is ready
|
||||
if !config.HasEmbeddedFrancisRuntime() {
|
||||
slog.Warn("🚨🚨🚨 CONNECTING TO A STANDALONE FRANCIS RUNTIME IS EXPERIMENTAL AND MEANT FOR DEVELOPMENT ONLY 🚨🚨🚨")
|
||||
}
|
||||
|
||||
// Validate other required options
|
||||
err = validateAppURLs(config)
|
||||
if err != nil {
|
||||
@@ -231,6 +272,97 @@ 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)
|
||||
|
||||
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, 2)
|
||||
if len(config.FrancisHostPSK) > 0 {
|
||||
configured = append(configured, "FRANCIS_HOST_PSK")
|
||||
}
|
||||
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 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 both set", strings.Join(configured, " and "))
|
||||
}
|
||||
|
||||
// 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 (c *EnvConfigSchema) HasEmbeddedFrancisRuntime() bool {
|
||||
return len(c.francisAddresses) == 0
|
||||
}
|
||||
|
||||
// FrancisAddresses returns the value of francisAddresses
|
||||
func (c *EnvConfigSchema) FrancisAddresses() []string {
|
||||
return c.francisAddresses
|
||||
}
|
||||
|
||||
func validateAppURLs(config *EnvConfigSchema) error {
|
||||
if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -396,6 +396,175 @@ 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("EXPERIMENTAL_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("EXPERIMENTAL_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("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_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("EXPERIMENTAL_FRANCIS_HOST", "one.example.com:8443, two.example.com:8443 ,[2001:db8::1]:8443")
|
||||
t.Setenv("EXPERIMENTAL_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("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com")
|
||||
t.Setenv("EXPERIMENTAL_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("EXPERIMENTAL_FRANCIS_HOST", " , ")
|
||||
t.Setenv("EXPERIMENTAL_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("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "one of FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE is required")
|
||||
})
|
||||
|
||||
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("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_JWT_FILE", jwtFile)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jwtFile, EnvConfig.FrancisHostJWTFile)
|
||||
assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should fail when the bootstrap JWT file does not exist", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_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)
|
||||
|
||||
jwtFile := t.TempDir() + "/token"
|
||||
require.NoError(t, os.WriteFile(jwtFile, []byte("header.payload.signature"), 0600))
|
||||
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_JWT_FILE", jwtFile)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "only one host bootstrap method may be configured, but FRANCIS_HOST_PSK and FRANCIS_HOST_JWT_FILE are both set")
|
||||
})
|
||||
|
||||
t.Run("should fail when the bootstrap PSK is too short", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_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("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK_FILE", pskFile)
|
||||
t.Setenv("EXPERIMENTAL_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()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build unit
|
||||
|
||||
// This file contains utils for unit tests and it's only built when the "unit" tag is set
|
||||
|
||||
package common
|
||||
|
||||
// SetFrancisAddresses sets a value for francisAddresses
|
||||
func (c *EnvConfigSchema) SetFrancisAddresses(v []string) {
|
||||
c.francisAddresses = v
|
||||
}
|
||||
Reference in New Issue
Block a user