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)