From 6f73711d3bb1a01692b13638fafd365714ff895c Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 29 Oct 2026 02:48:40 -0700 Subject: [PATCH] [self-hosted] Override relay auth secret and store encryption key from the environment (#8405) * [self-hosted] Override relay auth secret and store encryption key from the environment Combined server.authSecret and server.store.encryptionKey could only be set in YAML, so operators could not keep config.yaml in git without secrets. A non-empty NB_AUTH_SECRET or NB_STORE_ENCRYPTION_KEY now overrides those fields after YAML is parsed and before defaults and validation, matching the standalone relay env name for the auth secret. * [self-hosted] Cover secret env overrides when no config path is given LoadConfig with an empty path starts from DefaultConfig and applies NB_AUTH_SECRET and NB_STORE_ENCRYPTION_KEY without reading YAML. The existing tests only used YAML paths, so that branch had no coverage. --- combined/cmd/config.go | 23 ++++ combined/cmd/config_env_test.go | 237 ++++++++++++++++++++++++++++++++ combined/config.yaml.example | 5 +- 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 combined/cmd/config_env_test.go diff --git a/combined/cmd/config.go b/combined/cmd/config.go index 0c7302fe5..d0ad3167f 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -21,6 +21,13 @@ import ( nbconfig "github.com/netbirdio/netbird/management/internals/server/config" ) +const ( + // envRelayAuthSecret is the standalone relay --auth-secret env name so split + // and combined deployments share it. + envRelayAuthSecret = "NB_AUTH_SECRET" //nolint:gosec // env var name, not a credential + envStoreEncryptionKey = "NB_STORE_ENCRYPTION_KEY" +) + // CombinedConfig is the root configuration for the combined server. // The combined server is primarily a Management server with optional embedded // Signal, Relay, and STUN services. @@ -446,6 +453,7 @@ func LoadConfig(configPath string) (*CombinedConfig, error) { cfg := DefaultConfig() if configPath == "" { + applyServerSecretEnv(cfg) return cfg, nil } @@ -458,12 +466,27 @@ func LoadConfig(configPath string) (*CombinedConfig, error) { return nil, fmt.Errorf("failed to parse config file: %w", err) } + applyServerSecretEnv(cfg) + // Populate internal configs from server settings cfg.ApplySimplifiedDefaults() return cfg, nil } +// applyServerSecretEnv copies non-empty environment overrides onto the parsed +// YAML values before defaults and validation run. +func applyServerSecretEnv(cfg *CombinedConfig) { + if secret := os.Getenv(envRelayAuthSecret); secret != "" { + cfg.Server.AuthSecret = secret + log.Infof("server.authSecret taken from %s", envRelayAuthSecret) + } + if key := os.Getenv(envStoreEncryptionKey); key != "" { + cfg.Server.Store.EncryptionKey = key + log.Infof("server.store.encryptionKey taken from %s", envStoreEncryptionKey) + } +} + // Validate validates the configuration func (c *CombinedConfig) Validate() error { if c.Server.ExposedAddress == "" { diff --git a/combined/cmd/config_env_test.go b/combined/cmd/config_env_test.go new file mode 100644 index 000000000..c3fcf548e --- /dev/null +++ b/combined/cmd/config_env_test.go @@ -0,0 +1,237 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func unsetSecretEnvs(t *testing.T) { + t.Helper() + t.Setenv(envRelayAuthSecret, "") + t.Setenv(envStoreEncryptionKey, "") + require.NoError(t, os.Unsetenv(envRelayAuthSecret)) + require.NoError(t, os.Unsetenv(envStoreEncryptionKey)) +} + +func writeCombinedConfig(t *testing.T, authSecret, encryptionKey string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + content := fmt.Sprintf(` +server: + exposedAddress: "https://example.com:443" + dataDir: %q + authSecret: %q + store: + encryptionKey: %q +`, dir, authSecret, encryptionKey) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestLoadConfigRelayAuthSecretEnv(t *testing.T) { + envSecret := "env-relay-auth-secret" + yamlSecret := "yaml-relay-auth-secret" + + tests := []struct { + name string + yamlSecret string + envValue string + envSet bool + wantSecret string + wantErr string + }{ + { + name: "env unset uses YAML", + yamlSecret: yamlSecret, + wantSecret: yamlSecret, + }, + { + name: "env overrides YAML", + yamlSecret: yamlSecret, + envValue: envSecret, + envSet: true, + wantSecret: envSecret, + }, + { + name: "empty env leaves YAML", + yamlSecret: yamlSecret, + envValue: "", + envSet: true, + wantSecret: yamlSecret, + }, + { + name: "env set with empty YAML passes validation", + yamlSecret: "", + envValue: envSecret, + envSet: true, + wantSecret: envSecret, + }, + { + name: "neither set requires authSecret", + yamlSecret: "", + wantErr: "server.authSecret is required when running local relay", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + unsetSecretEnvs(t) + if tc.envSet { + t.Setenv(envRelayAuthSecret, tc.envValue) + } + + cfg, err := LoadConfig(writeCombinedConfig(t, tc.yamlSecret, "yaml-store-key")) + require.NoError(t, err) + + err = cfg.Validate() + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr, "validation error should mention authSecret") + return + } + require.NoError(t, err) + + assert.Equal(t, tc.wantSecret, cfg.Server.AuthSecret, "server.authSecret") + assert.Equal(t, tc.wantSecret, cfg.Relay.AuthSecret, "relay.authSecret") + assert.Equal(t, tc.wantSecret, cfg.Management.Relays.Secret, "management.relays.secret") + }) + } +} + +func TestLoadConfigStoreEncryptionKeyEnv(t *testing.T) { + envKey := "env-store-encryption-key" + yamlKey := "yaml-store-encryption-key" + + tests := []struct { + name string + yamlKey string + envValue string + envSet bool + wantKey string + }{ + { + name: "env unset uses YAML", + yamlKey: yamlKey, + wantKey: yamlKey, + }, + { + name: "env overrides YAML", + yamlKey: yamlKey, + envValue: envKey, + envSet: true, + wantKey: envKey, + }, + { + name: "empty env leaves YAML", + yamlKey: yamlKey, + envValue: "", + envSet: true, + wantKey: yamlKey, + }, + { + name: "env set with empty YAML", + yamlKey: "", + envValue: envKey, + envSet: true, + wantKey: envKey, + }, + { + name: "neither set leaves encryption key empty", + yamlKey: "", + wantKey: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + unsetSecretEnvs(t) + if tc.envSet { + t.Setenv(envStoreEncryptionKey, tc.envValue) + } + + cfg, err := LoadConfig(writeCombinedConfig(t, "yaml-relay-secret", tc.yamlKey)) + require.NoError(t, err) + require.NoError(t, cfg.Validate()) + + assert.Equal(t, tc.wantKey, cfg.Server.Store.EncryptionKey, "server.store.encryptionKey") + assert.Equal(t, tc.wantKey, cfg.Management.Store.EncryptionKey, "management.store.encryptionKey") + + mgmt, err := cfg.ToManagementConfig() + require.NoError(t, err) + assert.Equal(t, tc.wantKey, mgmt.DataStoreEncryptionKey, "management DataStoreEncryptionKey") + }) + } +} + +func TestLoadConfigNoPathSecretEnv(t *testing.T) { + unsetSecretEnvs(t) + t.Setenv(envRelayAuthSecret, "env-relay-auth-secret") + t.Setenv(envStoreEncryptionKey, "env-store-encryption-key") + + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, "env-relay-auth-secret", cfg.Server.AuthSecret, "server.authSecret") + assert.Equal(t, "env-store-encryption-key", cfg.Server.Store.EncryptionKey, "server.store.encryptionKey") +} + +func TestApplyServerSecretEnvLogsNameNotValue(t *testing.T) { + unsetSecretEnvs(t) + secret := "relay-secret-must-not-appear" + key := "store-key-must-not-appear" + t.Setenv(envRelayAuthSecret, secret) + t.Setenv(envStoreEncryptionKey, key) + + buf := captureLogOutput(t) + cfg := DefaultConfig() + applyServerSecretEnv(cfg) + + out := buf.String() + assert.Contains(t, out, envRelayAuthSecret, "info log should name the relay auth env var") + assert.Contains(t, out, envStoreEncryptionKey, "info log should name the store encryption env var") + assert.NotContains(t, out, secret, "info log must not include the relay auth secret") + assert.NotContains(t, out, key, "info log must not include the store encryption key") + assert.Equal(t, secret, cfg.Server.AuthSecret, "auth secret should be taken from the environment") + assert.Equal(t, key, cfg.Server.Store.EncryptionKey, "encryption key should be taken from the environment") +} + +func TestLogEnvVarsMasksRelayAndStoreSecrets(t *testing.T) { + unsetSecretEnvs(t) + secret := "relay-secret-must-not-appear" + key := "store-key-must-not-appear" + t.Setenv(envRelayAuthSecret, secret) + t.Setenv(envStoreEncryptionKey, key) + + buf := captureLogOutput(t) + logEnvVars() + + out := buf.String() + assert.Contains(t, out, envRelayAuthSecret+"=", "logEnvVars should list the relay auth env var") + assert.Contains(t, out, envStoreEncryptionKey+"=", "logEnvVars should list the store encryption env var") + assert.NotContains(t, out, secret, "logEnvVars must not include the relay auth secret") + assert.NotContains(t, out, key, "logEnvVars must not include the store encryption key") + assert.Contains(t, out, maskSecret(secret), "logEnvVars should mask the relay auth secret") + assert.Contains(t, out, maskSecret(key), "logEnvVars should mask the store encryption key") +} + +func captureLogOutput(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + logger := log.StandardLogger() + origOut := logger.Out + origLevel := logger.GetLevel() + logger.SetOutput(&buf) + logger.SetLevel(log.DebugLevel) + t.Cleanup(func() { + logger.SetOutput(origOut) + logger.SetLevel(origLevel) + }) + return &buf +} diff --git a/combined/config.yaml.example b/combined/config.yaml.example index 7f2d85210..dfc13abea 100644 --- a/combined/config.yaml.example +++ b/combined/config.yaml.example @@ -45,7 +45,8 @@ server: email: "" awsRoute53: false - # Shared secret for relay authentication (required when running local relay) + # Shared secret for relay authentication (required when running local relay). + # Can also be set via NB_AUTH_SECRET; a non-empty value overrides this field. authSecret: "your-secret-key-here" # Data directory for all services @@ -112,6 +113,8 @@ server: store: engine: "sqlite" # sqlite or postgres dsn: "" # Connection string for postgres + # Store field encryption key. Can also be set via NB_STORE_ENCRYPTION_KEY; + # a non-empty value overrides this field. encryptionKey: "" # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/store.db)