mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-09 03:09:04 +02:00
feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When it is empty or set to "embedded" (the default), Pocket ID starts the runtime inside its own process, backed by its own database. 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. Note: connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT, and optionally (but recommended) FRANCIS_CA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMgoTZtznSjP4SHTaHbRen
This commit is contained in:
co-authored by
Claude Opus 5
parent
81cb290bed
commit
cea1267f37
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user