diff --git a/combined/cmd/config.go b/combined/cmd/config.go index 890c86876..9ddb3ef52 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -5,20 +5,18 @@ import ( "fmt" "net" "net/netip" - "os" filePath "path/filepath" "strings" "time" log "github.com/sirupsen/logrus" - "gopkg.in/yaml.v3" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/util" + configloader "github.com/netbirdio/netbird/util/config" "github.com/netbirdio/netbird/util/crypt" - - nbconfig "github.com/netbirdio/netbird/management/internals/server/config" ) // CombinedConfig is the root configuration for the combined server. @@ -440,26 +438,17 @@ func (c *CombinedConfig) autoConfigureClientSettings(exposedProto, exposedHost, } } -// LoadConfig loads configuration from a YAML file +// LoadConfig loads the combined server configuration. func LoadConfig(configPath string) (*CombinedConfig, error) { - cfg := DefaultConfig() - - if configPath == "" { - return cfg, nil - } - - data, err := os.ReadFile(configPath) + cfg, err := configloader.Load(configPath, DefaultConfig(), configloader.Options{ + TagName: "yaml", + AllowMissing: configPath == "", + }) if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) + return nil, err } - if err := yaml.Unmarshal(data, cfg); err != nil { - return nil, fmt.Errorf("failed to parse config file: %w", err) - } - - // Populate internal configs from server settings cfg.ApplySimplifiedDefaults() - return cfg, nil } diff --git a/combined/cmd/config_test.go b/combined/cmd/config_test.go new file mode 100644 index 000000000..070416cfc --- /dev/null +++ b/combined/cmd/config_test.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadConfigEnvironmentWithoutFile(t *testing.T) { + t.Setenv("NB_SERVER_LOGLEVEL", "debug") + + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, "debug", cfg.Server.LogLevel, "Environment should override defaults without a file") +} + +func TestLoadConfigEnvironmentOverridesFileAndDefaults(t *testing.T) { + configPath := writeCombinedConfig(t, "config.yaml", ` +server: + exposedAddress: "https://netbird.example.com" + authSecret: "file-secret" + logLevel: "info" +relay: + logLevel: "ignored-relay-level" +signal: + logLevel: "ignored-signal-level" +management: + logLevel: "ignored-management-level" +`) + t.Setenv("NB_SERVER_LOGLEVEL", "debug") + t.Setenv("NB_SERVER_METRICSPORT", "9191") + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "debug", cfg.Server.LogLevel, "Environment should override the config file") + assert.Equal(t, 9191, cfg.Server.MetricsPort, "Environment should override a default absent from the file") + assert.Equal(t, ":443", cfg.Server.ListenAddress, "Unchanged defaults should be preserved") + assert.Equal(t, "debug", cfg.Relay.LogLevel, "Internal relay configuration should not be decoded") + assert.Equal(t, "debug", cfg.Signal.LogLevel, "Internal signal configuration should not be decoded") + assert.Equal(t, "debug", cfg.Management.LogLevel, "Internal management configuration should not be decoded") +} + +func TestLoadConfigEnvironmentCreatesOptionalConfig(t *testing.T) { + configPath := writeCombinedConfig(t, "config.yaml", ` +server: + exposedAddress: "https://netbird.example.com" + authSecret: "file-secret" +`) + t.Setenv("NB_SERVER_AUTH_OWNER_EMAIL", "owner@example.com") + t.Setenv("NB_SERVER_AUTH_OWNER_PASSWORD", "password-hash") + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + require.NotNil(t, cfg.Server.Auth.Owner, "Environment should create the optional owner configuration") + assert.Equal(t, "owner@example.com", cfg.Server.Auth.Owner.Email, "Owner email should come from the environment") + assert.Equal(t, "password-hash", cfg.Server.Auth.Owner.Password, "Owner password should come from the environment") +} + +func TestLoadConfigSupportsYAMLWithoutKnownExtension(t *testing.T) { + for _, name := range []string{"config", "config.conf"} { + t.Run(name, func(t *testing.T) { + configPath := writeCombinedConfig(t, name, ` +server: + exposedAddress: "https://netbird.example.com" + authSecret: "yaml-secret" +`) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "yaml-secret", cfg.Server.AuthSecret, "Unknown extensions should remain YAML-compatible") + }) + } +} + +func TestLoadConfigSupportsLegacyYAMLBooleans(t *testing.T) { + configPath := writeCombinedConfig(t, "config.yaml", ` +server: + exposedAddress: "https://netbird.example.com" + authSecret: "file-secret" + disableAnonymousMetrics: yes +`) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + assert.True(t, cfg.Server.DisableAnonymousMetrics, "Legacy YAML boolean values should remain supported") +} + +func TestLoadConfigSupportsTOML(t *testing.T) { + configPath := writeCombinedConfig(t, "config.toml", ` +[server] +exposedAddress = "https://netbird.example.com" +authSecret = "toml-secret" +logLevel = "warn" +`) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "https://netbird.example.com", cfg.Server.ExposedAddress, "Exposed address should be decoded from TOML") + assert.Equal(t, "toml-secret", cfg.Server.AuthSecret, "Auth secret should be decoded from TOML") + assert.Equal(t, "warn", cfg.Server.LogLevel, "Log level should be decoded from TOML") + assert.Equal(t, ":443", cfg.Server.ListenAddress, "Defaults should survive decoding") +} + +func writeCombinedConfig(t *testing.T, name, contents string) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(configPath, []byte(contents), 0o600)) + return configPath +} diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 7eac84ce5..793843595 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -56,7 +56,8 @@ var ( All services (Management, Signal, Relay) are multiplexed on a single port. Optional STUN server runs on separate UDP ports. -Configuration is loaded from a YAML file specified with --config.`, +Configuration is loaded from a file specified with --config. Values can be +overridden with NB_-prefixed environment variables, such as NB_SERVER_LOGLEVEL.`, SilenceUsage: true, SilenceErrors: true, RunE: execute, @@ -64,7 +65,7 @@ Configuration is loaded from a YAML file specified with --config.`, ) func init() { - rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)") + rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to configuration file (required)") _ = rootCmd.MarkPersistentFlagRequired("config") rootCmd.AddCommand(newAdminCommands()) diff --git a/go.mod b/go.mod index 265cd962f..ec1557096 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/gliderlabs/ssh v0.3.8 github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ole/go-ole v1.3.0 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gobwas/ws v1.4.0 github.com/goccy/go-yaml v1.18.0 github.com/godbus/dbus/v5 v5.2.2 @@ -105,6 +106,7 @@ require ( github.com/shirou/gopsutil/v4 v4.25.8 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.37.0 github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 @@ -209,7 +211,6 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/webauthn v0.16.4 // indirect github.com/go-webauthn/x v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect @@ -279,6 +280,7 @@ require ( github.com/openbao/openbao/api/v2 v2.5.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/dtls/v2 v2.2.10 // indirect github.com/pion/dtls/v3 v3.0.9 // indirect @@ -295,9 +297,13 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect @@ -313,6 +319,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index d9d880ede..4db0c0806 100644 --- a/go.sum +++ b/go.sum @@ -595,6 +595,8 @@ github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBe github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= @@ -605,6 +607,10 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -612,6 +618,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -630,6 +638,8 @@ github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI= @@ -714,6 +724,7 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4= goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U= diff --git a/management/cmd/admin.go b/management/cmd/admin.go index e5c0f6ac9..695a51751 100644 --- a/management/cmd/admin.go +++ b/management/cmd/admin.go @@ -114,8 +114,8 @@ func withAdminConfig(cmd *cobra.Command, applyIDPDefaults bool, fn func(ctx cont } func loadAdminMgmtConfig(ctx context.Context, applyIDPDefaults bool) (*nbconfig.Config, string, error) { - config := &nbconfig.Config{} - if _, err := util.ReadJsonWithEnvSub(nbconfig.MgmtConfigPath, config); err != nil { + config, err := loadManagementConfig(nbconfig.MgmtConfigPath) + if err != nil { return nil, "", err } diff --git a/management/cmd/config.go b/management/cmd/config.go new file mode 100644 index 000000000..b41c8addb --- /dev/null +++ b/management/cmd/config.go @@ -0,0 +1,13 @@ +package cmd + +import ( + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + configloader "github.com/netbirdio/netbird/util/config" +) + +func loadManagementConfig(configPath string) (*nbconfig.Config, error) { + return configloader.Load(configPath, &nbconfig.Config{Datadir: defaultMgmtDataDir}, configloader.Options{ + TagName: "json", + Transform: configloader.ExpandEnvTemplate, + }) +} diff --git a/management/cmd/config_test.go b/management/cmd/config_test.go new file mode 100644 index 000000000..9a78c5a00 --- /dev/null +++ b/management/cmd/config_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadManagementConfigSources(t *testing.T) { + t.Setenv("MANAGEMENT_DATA_DIR", "/template-data") + t.Setenv("MANAGEMENT_ENCRYPTION_KEY", "template-key") + t.Setenv("NB_DATADIR", "/environment-data") + t.Setenv("NB_HTTPCONFIG_AUTHAUDIENCE", "environment-audience") + configPath := filepath.Join(t.TempDir(), "management.json") + require.NoError(t, os.WriteFile(configPath, []byte(`{ + "Datadir": "{{ .MANAGEMENT_DATA_DIR }}", + "DataStoreEncryptionKey": "{{ .MANAGEMENT_ENCRYPTION_KEY }}", + "HttpConfig": { + "AuthAudience": "file-audience" + }, + "TURNConfig": { + "CredentialsTTL": "1h" + } +}`), 0o600)) + + cfg, err := loadManagementConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "/environment-data", cfg.Datadir, "Bound environment values should override template values") + assert.Equal(t, "template-key", cfg.DataStoreEncryptionKey, "Template environment values should be expanded") + require.NotNil(t, cfg.HttpConfig, "Nested configuration should be decoded") + assert.Equal(t, "environment-audience", cfg.HttpConfig.AuthAudience, "Bound environment values should override the file") + require.NotNil(t, cfg.TURNConfig, "TURN configuration should be decoded") + assert.Equal(t, time.Hour, cfg.TURNConfig.CredentialsTTL.Duration, "JSON duration types should be decoded") + + datadirFlag := mgmtCmd.Flags().Lookup("datadir") + oldDatadir := datadirFlag.Value.String() + oldChanged := datadirFlag.Changed + t.Cleanup(func() { + require.NoError(t, datadirFlag.Value.Set(oldDatadir)) + datadirFlag.Changed = oldChanged + }) + require.NoError(t, datadirFlag.Value.Set("/flag-data")) + datadirFlag.Changed = true + ApplyCommandLineOverrides(cfg, mgmtCmd.Flags()) + assert.Equal(t, "/flag-data", cfg.Datadir, "Flags should override environment values") +} diff --git a/management/cmd/management.go b/management/cmd/management.go index 147985314..9178150a1 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -19,6 +19,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/netbirdio/netbird/management/server/types" @@ -60,7 +61,7 @@ var ( // detect whether user specified a port userPort := cmd.Flag("port").Changed - config, err = LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath) + config, err = LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath, cmd.Flags()) if err != nil { return fmt.Errorf("failed reading provided config file: %s: %v", nbconfig.MgmtConfigPath, err) } @@ -70,7 +71,7 @@ var ( } var tlsEnabled bool - if mgmtLetsencryptDomain != "" || (config.HttpConfig.CertFile != "" && config.HttpConfig.CertKey != "") { + if config.HttpConfig.LetsEncryptDomain != "" || (config.HttpConfig.CertFile != "" && config.HttpConfig.CertKey != "") { tlsEnabled = true } @@ -171,15 +172,15 @@ var ( } ) -func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Config, error) { - loadedConfig := &nbconfig.Config{} - if _, err := util.ReadJsonWithEnvSub(mgmtConfigPath, loadedConfig); err != nil { +func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string, flags *pflag.FlagSet) (*nbconfig.Config, error) { + loadedConfig, err := loadManagementConfig(mgmtConfigPath) + if err != nil { return nil, err } - ApplyCommandLineOverrides(loadedConfig) + ApplyCommandLineOverrides(loadedConfig, flags) - err := grpc.ValidateSyncMessageVersion(loadedConfig.HighestSupportedSyncMessageVersion) + err = grpc.ValidateSyncMessageVersion(loadedConfig.HighestSupportedSyncMessageVersion) if err != nil { return nil, err } @@ -211,14 +212,18 @@ func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Confi } // ApplyCommandLineOverrides applies command-line flag overrides to the config -func ApplyCommandLineOverrides(cfg *nbconfig.Config) { - if mgmtLetsencryptDomain != "" { +func ApplyCommandLineOverrides(cfg *nbconfig.Config, flags *pflag.FlagSet) { + hasCertOverride := flags.Changed("cert-key") && flags.Changed("cert-file") + if (flags.Changed("letsencrypt-domain") || hasCertOverride) && cfg.HttpConfig == nil { + cfg.HttpConfig = &nbconfig.HttpServerConfig{} + } + if flags.Changed("letsencrypt-domain") { cfg.HttpConfig.LetsEncryptDomain = mgmtLetsencryptDomain } - if mgmtDataDir != "" { + if flags.Changed("datadir") { cfg.Datadir = mgmtDataDir } - if certKey != "" && certFile != "" { + if hasCertOverride { cfg.HttpConfig.CertFile = certFile cfg.HttpConfig.CertKey = certKey } diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go index 2c3481213..c95986ce2 100644 --- a/management/cmd/management_test.go +++ b/management/cmd/management_test.go @@ -36,7 +36,7 @@ func Test_LoadMgmtConfig(t *testing.T) { tmpFile, err := createConfig(exampleConfig) assert.NoError(t, err) - cfg, err := LoadMgmtConfig(context.Background(), tmpFile) + cfg, err := LoadMgmtConfig(context.Background(), tmpFile, mgmtCmd.Flags()) assert.NoError(t, err) assert.NotEmpty(t, cfg.Relay) assert.NotEmpty(t, cfg.Relay.Addresses) @@ -54,7 +54,7 @@ func Test_LoadMgmtConfig_Empty(t *testing.T) { }`) assert.NoError(t, err) - cfg, err := LoadMgmtConfig(context.Background(), tmpFile) + cfg, err := LoadMgmtConfig(context.Background(), tmpFile, mgmtCmd.Flags()) assert.NoError(t, err) assert.Nil(t, cfg.HighestSupportedSyncMessageVersion) assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion) diff --git a/proxy/cmd/proxy/cmd/config.go b/proxy/cmd/proxy/cmd/config.go new file mode 100644 index 000000000..b6b682486 --- /dev/null +++ b/proxy/cmd/proxy/cmd/config.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "golang.org/x/crypto/acme" + + "github.com/netbirdio/netbird/proxy" + configloader "github.com/netbirdio/netbird/util/config" +) + +type commandConfig struct { + proxy.Config `yaml:",squash"` + + LogLevel string `yaml:"logLevel" env:"NB_PROXY_LOG_LEVEL" flag:"log-level"` + PreallocatedBuffers *uint32 `yaml:"preallocatedBuffers" env:"NB_PROXY_PREALLOCATED_BUFFERS"` + MaxBatchSize *uint32 `yaml:"maxBatchSize" env:"NB_PROXY_MAX_BATCH_SIZE"` +} + +func defaultConfig() *commandConfig { + return &commandConfig{ + Config: proxy.Config{ + ListenAddr: ":443", + ManagementAddress: DefaultManagementURL, + CertificateDirectory: "./certs", + CertificateFile: "tls.crt", + CertificateKeyFile: "tls.key", + ACMEChallengeAddress: ":80", + ACMEDirectory: acme.LetsEncryptURL, + ACMEChallengeType: "tls-alpn-01", + CertLockMethod: "auto", + HealthAddr: "localhost:8080", + ForwardedProto: "auto", + SupportsCustomPorts: true, + GeoDataDir: "/var/lib/netbird/geolocation", + }, + LogLevel: "info", + } +} + +func loadConfig(cmd *cobra.Command, configPath string) (*commandConfig, error) { + return configloader.Load(configPath, defaultConfig(), configloader.Options{ + TagName: "yaml", + AllowMissing: configPath == "", + FlagSet: cmd.Flags(), + Strict: true, + }) +} diff --git a/proxy/cmd/proxy/cmd/config_test.go b/proxy/cmd/proxy/cmd/config_test.go new file mode 100644 index 000000000..840628f4e --- /dev/null +++ b/proxy/cmd/proxy/cmd/config_test.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadConfigPrecedence(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "proxy.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(` +domain: file.example.com +trustedProxies: 192.0.2.0/24 +maxDialTimeout: 5s +`), 0o600)) + t.Setenv("NB_PROXY_TOKEN", "environment-token") + require.NoError(t, rootCmd.ParseFlags(nil)) + + domainFlag := rootCmd.Flags().Lookup("domain") + oldDomain := domainFlag.Value.String() + oldChanged := domainFlag.Changed + t.Cleanup(func() { + require.NoError(t, domainFlag.Value.Set(oldDomain)) + domainFlag.Changed = oldChanged + }) + require.NoError(t, domainFlag.Value.Set("flag.example.com")) + domainFlag.Changed = true + + cfg, err := loadConfig(rootCmd, configPath) + require.NoError(t, err) + assert.Equal(t, "flag.example.com", cfg.ProxyURL, "Flags should override the configuration file") + assert.Equal(t, "environment-token", cfg.ProxyToken, "Environment should populate secrets") + assert.Equal(t, 5*time.Second, cfg.MaxDialTimeout, "Durations should be decoded from the file") + require.NotNil(t, cfg.TrustedProxies, "Trusted proxies should be decoded") + assert.False(t, cfg.TrustedProxies.Empty(), "Configured trusted proxies should not be empty") +} diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index 9b180a5c4..9aad3d310 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -15,22 +15,13 @@ import ( "github.com/netbirdio/netbird/shared/management/domain" - "github.com/netbirdio/netbird/client/embed" "github.com/netbirdio/netbird/proxy" - nbacme "github.com/netbirdio/netbird/proxy/internal/acme" - "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util" ) -const ( - // envPreallocatedBuffers caps the per-tunnel buffer pool. Zero (unset) - // keeps the upstream uncapped default. - envPreallocatedBuffers = "NB_PROXY_PREALLOCATED_BUFFERS" - // envMaxBatchSize overrides the per-tunnel batch size, which controls - // how many buffers each receive/TUN worker eagerly allocates. Zero - // (unset) keeps the platform default. - envMaxBatchSize = "NB_PROXY_MAX_BATCH_SIZE" -) +// envPreallocatedBuffers caps the per-tunnel buffer pool. Zero (unset) +// keeps the upstream uncapped default. +const envPreallocatedBuffers = "NB_PROXY_PREALLOCATED_BUFFERS" const DefaultManagementURL = "https://api.netbird.io:443" @@ -79,6 +70,7 @@ var ( geoDataDir string crowdsecAPIURL string crowdsecAPIKey string + configPath string ) var rootCmd = &cobra.Command{ @@ -91,6 +83,7 @@ var rootCmd = &cobra.Command{ } func init() { + rootCmd.Flags().StringVar(&configPath, "config", "", "path to configuration file") rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", envStringOrDefault("NB_PROXY_LOG_LEVEL", "info"), "Log level: panic, fatal, error, warn, info, debug, trace") rootCmd.PersistentFlags().BoolVar(&debugLogs, "debug", envBoolOrDefault("NB_PROXY_DEBUG_LOGS", false), "Enable debug logs") _ = rootCmd.PersistentFlags().MarkDeprecated("debug", "use --log-level instead") @@ -145,118 +138,68 @@ func SetVersionInfo(version, commit, buildDate, goVersion string) { } func runServer(cmd *cobra.Command, args []string) error { - proxyToken := os.Getenv(envProxyToken) - if proxyToken == "" { - return fmt.Errorf("proxy token is required: set %s environment variable", envProxyToken) + cfg, err := loadConfig(cmd, configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + if cfg.ProxyToken == "" { + return fmt.Errorf("proxy token is required: set proxyToken or %s", envProxyToken) } - level := logLevel - if debugLogs { + level := cfg.LogLevel + if debugLogs && (!cmd.Flags().Changed("log-level") || cmd.Flags().Changed("debug")) { level = "debug" } logger := log.New() - _ = util.InitLogger(logger, level, util.LogConsole) - logger.Infof("configured log level: %s", level) - var wgPool, wgBatch uint64 - var perf embed.Performance - if raw := os.Getenv(envPreallocatedBuffers); raw != "" { - n, err := strconv.ParseUint(raw, 10, 32) - if err != nil { - return fmt.Errorf("invalid %s %q: %w", envPreallocatedBuffers, raw, err) - } - wgPool = n - v := uint32(n) - perf.PreallocatedBuffersPerPool = &v - logger.Infof("tunnel preallocated buffers per pool: %d", n) - } - if raw := os.Getenv(envMaxBatchSize); raw != "" { - n, err := strconv.ParseUint(raw, 10, 32) - if err != nil { - return fmt.Errorf("invalid %s %q: %w", envMaxBatchSize, raw, err) - } - wgBatch = n - v := uint32(n) - perf.MaxBatchSize = &v - logger.Infof("tunnel max batch size override: %d", n) - } - if wgPool > 0 { - // Each bind recv goroutine (IPv4 + IPv6 + ICE relay) plus - // RoutineReadFromTUN eagerly reserves `batch` message buffers for - // the lifetime of the Device. A pool cap below that floor blocks - // the receive pipeline at startup. - batch := wgBatch - if batch == 0 { - batch = 128 - } - const recvGoroutines = 4 - floor := batch * recvGoroutines - if wgPool < floor { - logger.Warnf("%s=%d is below the eager-allocation floor (~%d for batch=%d); startup may deadlock", - envPreallocatedBuffers, wgPool, floor, batch) - } - } + proxyConfig := cfg.Config + proxyConfig.Logger = logger + proxyConfig.Version = Version + applyPerformanceConfig(&proxyConfig, cfg, logger) - switch forwardedProto { + switch proxyConfig.ForwardedProto { case "auto", "http", "https": default: - return fmt.Errorf("invalid --forwarded-proto value %q: must be auto, http, or https", forwardedProto) + return fmt.Errorf("invalid --forwarded-proto value %q: must be auto, http, or https", proxyConfig.ForwardedProto) } - _, err := domain.ValidateDomains([]string{proxyDomain}) - if err != nil { - return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err) - } - - parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies) - if err != nil { - return fmt.Errorf("invalid --trusted-proxies: %w", err) + if _, err := domain.ValidateDomains([]string{proxyConfig.ProxyURL}); err != nil { + return fmt.Errorf("invalid domain value %q: %w", proxyConfig.ProxyURL, err) } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() - srv := proxy.New(ctx, proxy.Config{ - ListenAddr: addr, - Logger: logger, - Version: Version, - ManagementAddress: mgmtAddr, - ProxyURL: proxyDomain, - ProxyToken: proxyToken, - CertificateDirectory: certDir, - CertificateFile: certFile, - CertificateKeyFile: certKeyFile, - GenerateACMECertificates: acmeCerts, - ACMEChallengeAddress: acmeAddr, - ACMEDirectory: acmeDir, - ACMEEABKID: acmeEABKID, - ACMEEABHMACKey: acmeEABHMACKey, - ACMEChallengeType: acmeChallengeType, - DebugEndpointEnabled: debugEndpoint, - DebugEndpointAddress: debugEndpointAddr, - HealthAddr: healthAddr, - ForwardedProto: forwardedProto, - TrustedProxies: parsedTrustedProxies, - CertLockMethod: nbacme.CertLockMethod(certLockMethod), - WildcardCertDir: wildcardCertDir, - WireguardPort: wgPort, - Performance: perf, - ProxyProtocol: proxyProtocol, - PreSharedKey: preSharedKey, - SupportsCustomPorts: supportsCustomPorts, - RequireSubdomain: requireSubdomain, - Private: private, - MaxDialTimeout: maxDialTimeout, - MaxSessionIdleTimeout: maxSessionIdleTimeout, - MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0), - GeoDataDir: geoDataDir, - CrowdSecAPIURL: crowdsecAPIURL, - CrowdSecAPIKey: crowdsecAPIKey, - }) + srv := proxy.New(ctx, proxyConfig) + return srv.ListenAndServe(ctx, proxyConfig.ListenAddr) +} - return srv.ListenAndServe(ctx, addr) +func applyPerformanceConfig(proxyConfig *proxy.Config, cfg *commandConfig, logger *log.Logger) { + if cfg.PreallocatedBuffers != nil { + proxyConfig.Performance.PreallocatedBuffersPerPool = cfg.PreallocatedBuffers + logger.Infof("tunnel preallocated buffers per pool: %d", *cfg.PreallocatedBuffers) + } + if cfg.MaxBatchSize != nil { + proxyConfig.Performance.MaxBatchSize = cfg.MaxBatchSize + logger.Infof("tunnel max batch size override: %d", *cfg.MaxBatchSize) + } + if cfg.PreallocatedBuffers == nil || *cfg.PreallocatedBuffers == 0 { + return + } + + batch := uint64(128) + if cfg.MaxBatchSize != nil && *cfg.MaxBatchSize > 0 { + batch = uint64(*cfg.MaxBatchSize) + } + const recvGoroutines = 4 + floor := batch * recvGoroutines + pool := uint64(*cfg.PreallocatedBuffers) + if pool < floor { + logger.Warnf("%s=%d is below the eager-allocation floor (~%d for batch=%d); startup may deadlock", + envPreallocatedBuffers, pool, floor, batch) + } } func envBoolOrDefault(key string, def bool) bool { diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go index f8c74d8b5..76657a988 100644 --- a/proxy/lifecycle.go +++ b/proxy/lifecycle.go @@ -20,112 +20,112 @@ import ( // adding fields here must not change the zero-value behaviour of Server. type Config struct { // ListenAddr is the TCP address the main listener binds. Required. - ListenAddr string + ListenAddr string `yaml:"listenAddress" env:"NB_PROXY_ADDRESS" flag:"addr"` // ID identifies this proxy instance to management. Empty values are // replaced with a timestamped default at Server.Start time (see // initDefaults), not in New. - ID string + ID string `yaml:"id" env:"NB_PROXY_ID"` // Logger is the logrus logger used everywhere. Empty values fall // back to log.StandardLogger() at Server.Start time (see // initDefaults), not in New. - Logger *log.Logger + Logger *log.Logger `yaml:"-" env:"-" flag:"-"` // Version is the build version string reported to management. Empty // values are replaced with "dev" at Server.Start time (see // initDefaults), not in New. - Version string + Version string `yaml:"-" env:"-" flag:"-"` // ProxyURL is the public address operators use to reach this proxy. - ProxyURL string + ProxyURL string `yaml:"domain" env:"NB_PROXY_DOMAIN" flag:"domain"` // ManagementAddress is the gRPC URL of the management server. - ManagementAddress string + ManagementAddress string `yaml:"managementAddress" env:"NB_PROXY_MANAGEMENT_ADDRESS" flag:"mgmt"` // ProxyToken authenticates this proxy with the management server. - ProxyToken string + ProxyToken string `yaml:"proxyToken" env:"NB_PROXY_TOKEN"` // CertificateDirectory is the directory holding TLS certificate // material (static or ACME-provisioned). - CertificateDirectory string + CertificateDirectory string `yaml:"certificateDirectory" env:"NB_PROXY_CERTIFICATE_DIRECTORY" flag:"cert-dir"` // CertificateFile is the certificate filename within // CertificateDirectory. - CertificateFile string + CertificateFile string `yaml:"certificateFile" env:"NB_PROXY_CERTIFICATE_FILE" flag:"cert-file"` // CertificateKeyFile is the private key filename within // CertificateDirectory. - CertificateKeyFile string + CertificateKeyFile string `yaml:"certificateKeyFile" env:"NB_PROXY_CERTIFICATE_KEY_FILE" flag:"cert-key-file"` // GenerateACMECertificates toggles ACME certificate provisioning. - GenerateACMECertificates bool + GenerateACMECertificates bool `yaml:"generateACMECertificates" env:"NB_PROXY_ACME_CERTIFICATES" flag:"acme-certs"` // ACMEChallengeAddress is the listen address for HTTP-01 challenges. - ACMEChallengeAddress string + ACMEChallengeAddress string `yaml:"acmeChallengeAddress" env:"NB_PROXY_ACME_ADDRESS" flag:"acme-addr"` // ACMEDirectory is the ACME directory URL (Let's Encrypt by default). - ACMEDirectory string + ACMEDirectory string `yaml:"acmeDirectory" env:"NB_PROXY_ACME_DIRECTORY" flag:"acme-dir"` // ACMEEABKID is the External Account Binding Key ID for CAs that // require EAB (e.g. ZeroSSL). - ACMEEABKID string + ACMEEABKID string `yaml:"acmeEABKID" env:"NB_PROXY_ACME_EAB_KID" flag:"acme-eab-kid"` // ACMEEABHMACKey is the External Account Binding HMAC key for CAs // that require EAB. - ACMEEABHMACKey string + ACMEEABHMACKey string `yaml:"acmeEABHMACKey" env:"NB_PROXY_ACME_EAB_HMAC_KEY" flag:"acme-eab-hmac-key"` // ACMEChallengeType is the ACME challenge type ("tls-alpn-01" or // "http-01"). Empty defaults to "tls-alpn-01". - ACMEChallengeType string + ACMEChallengeType string `yaml:"acmeChallengeType" env:"NB_PROXY_ACME_CHALLENGE_TYPE" flag:"acme-challenge-type"` // CertLockMethod controls how ACME certificate locks are coordinated // across replicas. - CertLockMethod acme.CertLockMethod + CertLockMethod acme.CertLockMethod `yaml:"certLockMethod" env:"NB_PROXY_CERT_LOCK_METHOD" flag:"cert-lock-method"` // WildcardCertDir is an optional directory containing static wildcard // certificates that override ACME for matching domains. - WildcardCertDir string + WildcardCertDir string `yaml:"wildcardCertDir" env:"NB_PROXY_WILDCARD_CERT_DIR" flag:"wildcard-cert-dir"` // DebugEndpointEnabled toggles the debug HTTP endpoint. - DebugEndpointEnabled bool + DebugEndpointEnabled bool `yaml:"debugEndpointEnabled" env:"NB_PROXY_DEBUG_ENDPOINT" flag:"debug-endpoint"` // DebugEndpointAddress is the bind address for the debug endpoint. - DebugEndpointAddress string + DebugEndpointAddress string `yaml:"debugEndpointAddress" env:"NB_PROXY_DEBUG_ENDPOINT_ADDRESS" flag:"debug-endpoint-addr"` // HealthAddr is the bind address for the health probe and metrics // surface. Empty disables the health probe entirely (library callers // can attach their own). - HealthAddr string + HealthAddr string `yaml:"healthAddress" env:"NB_PROXY_HEALTH_ADDRESS" flag:"health-addr"` // ForwardedProto overrides the X-Forwarded-Proto value sent to // backends. Valid values: "auto", "http", "https". - ForwardedProto string + ForwardedProto string `yaml:"forwardedProto" env:"NB_PROXY_FORWARDED_PROTO" flag:"forwarded-proto"` // TrustedProxies is the set of trusted upstream proxies that may set // forwarding headers. - TrustedProxies *trustedproxy.List + TrustedProxies *trustedproxy.List `yaml:"trustedProxies" env:"NB_PROXY_TRUSTED_PROXIES" flag:"trusted-proxies"` // WireguardPort is the UDP port for the embedded NetBird tunnel. // Zero asks the OS for a random port. - WireguardPort uint16 + WireguardPort uint16 `yaml:"wireguardPort" env:"NB_PROXY_WG_PORT" flag:"wg-port"` // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners. - ProxyProtocol bool + ProxyProtocol bool `yaml:"proxyProtocol" env:"NB_PROXY_PROXY_PROTOCOL" flag:"proxy-protocol"` // PreSharedKey is the WireGuard pre-shared key used between the // proxy's embedded clients and peers. - PreSharedKey string + PreSharedKey string `yaml:"preSharedKey" env:"NB_PROXY_PRESHARED_KEY" flag:"preshared-key"` // Performance configures the tunnel pool/batch sizes for every // embedded client this proxy creates. Zero values fall back to // upstream defaults. - Performance embed.Performance + Performance embed.Performance `yaml:"performance" env:"-" flag:"-"` // SupportsCustomPorts indicates whether the proxy can bind arbitrary // ports for TCP/UDP/TLS services. - SupportsCustomPorts bool + SupportsCustomPorts bool `yaml:"supportsCustomPorts" env:"NB_PROXY_SUPPORTS_CUSTOM_PORTS" flag:"supports-custom-ports"` // RequireSubdomain forces accounts to use a subdomain in front of // the proxy's cluster domain. - RequireSubdomain bool + RequireSubdomain bool `yaml:"requireSubdomain" env:"NB_PROXY_REQUIRE_SUBDOMAIN" flag:"require-subdomain"` // Private flags this proxy as embedded in a netbird client and // serving exclusively over the WireGuard tunnel. Also enables // per-account inbound listeners on each embedded client's netstack. - Private bool + Private bool `yaml:"private" env:"NB_PROXY_PRIVATE" flag:"private"` // MaxDialTimeout caps the per-service backend dial timeout. - MaxDialTimeout time.Duration + MaxDialTimeout time.Duration `yaml:"maxDialTimeout" env:"NB_PROXY_MAX_DIAL_TIMEOUT" flag:"max-dial-timeout"` // MaxSessionIdleTimeout caps the per-service session idle timeout. - MaxSessionIdleTimeout time.Duration + MaxSessionIdleTimeout time.Duration `yaml:"maxSessionIdleTimeout" env:"NB_PROXY_MAX_SESSION_IDLE_TIMEOUT" flag:"max-session-idle-timeout"` // MappingBatchWatchdog bounds how long a single mapping batch may spend // being applied before the receive loop reconnects to resync. Zero falls // back to the internal default. - MappingBatchWatchdog time.Duration + MappingBatchWatchdog time.Duration `yaml:"mappingBatchWatchdog" env:"NB_PROXY_MAPPING_BATCH_WATCHDOG"` // GeoDataDir is the directory containing GeoLite2 MMDB files. - GeoDataDir string + GeoDataDir string `yaml:"geoDataDir" env:"NB_PROXY_GEO_DATA_DIR" flag:"geo-data-dir"` // CrowdSecAPIURL is the CrowdSec LAPI URL. Empty disables CrowdSec. - CrowdSecAPIURL string + CrowdSecAPIURL string `yaml:"crowdSecAPIURL" env:"NB_PROXY_CROWDSEC_API_URL" flag:"crowdsec-api-url"` // CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables // CrowdSec. - CrowdSecAPIKey string + CrowdSecAPIKey string `yaml:"crowdSecAPIKey" env:"NB_PROXY_CROWDSEC_API_KEY" flag:"crowdsec-api-key"` } // New builds a Server from cfg without performing any I/O. No goroutines diff --git a/relay/cmd/config_test.go b/relay/cmd/config_test.go new file mode 100644 index 000000000..1625e1908 --- /dev/null +++ b/relay/cmd/config_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadConfigPrecedence(t *testing.T) { + path := filepath.Join(t.TempDir(), "relay.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +listenAddress: ":8443" +exposedAddress: "relay.example.com:443" +authSecret: file-secret +metricsPort: 9091 +enableSTUN: true +stunPorts: [3478, 3479] +`), 0o600)) + t.Setenv("NB_METRICS_PORT", "9191") + require.NoError(t, rootCmd.ParseFlags(nil)) + + listenFlag := rootCmd.Flags().Lookup("listen-address") + oldListen := listenFlag.Value.String() + oldChanged := listenFlag.Changed + t.Cleanup(func() { + require.NoError(t, listenFlag.Value.Set(oldListen)) + listenFlag.Changed = oldChanged + }) + require.NoError(t, listenFlag.Value.Set(":7443")) + listenFlag.Changed = true + + oldConfigPath := configPath + configPath = path + t.Cleanup(func() { + configPath = oldConfigPath + }) + + cfg, err := loadConfig(rootCmd) + require.NoError(t, err) + assert.Equal(t, ":7443", cfg.ListenAddress, "Flags should override the configuration file") + assert.Equal(t, 9191, cfg.MetricsPort, "Environment should override the configuration file") + assert.Equal(t, "relay.example.com:443", cfg.ExposedAddress, "File values should override defaults") + assert.True(t, cfg.EnableSTUN, "STUN should be configurable from the file") + assert.Equal(t, []int{3478, 3479}, cfg.STUNPorts, "STUN ports should load from the file") +} diff --git a/relay/cmd/env.go b/relay/cmd/env.go deleted file mode 100644 index 3c15ebe1f..000000000 --- a/relay/cmd/env.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "os" - "strings" - - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -// setFlagsFromEnvVars reads and updates flag values from environment variables with prefix NB_ -func setFlagsFromEnvVars(cmd *cobra.Command) { - flags := cmd.PersistentFlags() - flags.VisitAll(func(f *pflag.Flag) { - newEnvVar := flagNameToEnvVar(f.Name, "NB_") - value, present := os.LookupEnv(newEnvVar) - if !present { - return - } - - err := flags.Set(f.Name, value) - if err != nil { - log.Infof("unable to configure flag %s using variable %s, err: %v", f.Name, newEnvVar, err) - } - }) -} - -// flagNameToEnvVar converts flag name to environment var name adding a prefix, -// replacing dashes and making all uppercase (e.g. setup-keys is converted to NB_SETUP_KEYS according to the input prefix) -func flagNameToEnvVar(cmdFlag string, prefix string) string { - parsed := strings.ReplaceAll(cmdFlag, "-", "_") - upper := strings.ToUpper(parsed) - return prefix + upper -} diff --git a/relay/cmd/root.go b/relay/cmd/root.go index a64812d4d..a6b6b36d7 100644 --- a/relay/cmd/root.go +++ b/relay/cmd/root.go @@ -26,33 +26,47 @@ import ( "github.com/netbirdio/netbird/stun" "github.com/netbirdio/netbird/trustedproxy" "github.com/netbirdio/netbird/util" + configloader "github.com/netbirdio/netbird/util/config" ) +// Config contains relay service startup configuration. type Config struct { - ListenAddress string + ListenAddress string `yaml:"listenAddress" env:"NB_LISTEN_ADDRESS" flag:"listen-address"` // in HA every peer connect to a common domain, the instance domain has been distributed during the p2p connection // it is a domain:port or ip:port - ExposedAddress string - MetricsPort int - LetsencryptEmail string - LetsencryptDataDir string - LetsencryptDomains []string + ExposedAddress string `yaml:"exposedAddress" env:"NB_EXPOSED_ADDRESS" flag:"exposed-address"` + MetricsPort int `yaml:"metricsPort" env:"NB_METRICS_PORT" flag:"metrics-port"` + LetsencryptEmail string `yaml:"letsencryptEmail" env:"NB_LETSENCRYPT_EMAIL" flag:"letsencrypt-email"` + LetsencryptDataDir string `yaml:"letsencryptDataDir" env:"NB_LETSENCRYPT_DATA_DIR" flag:"letsencrypt-data-dir"` + LetsencryptDomains []string `yaml:"letsencryptDomains" env:"NB_LETSENCRYPT_DOMAINS" flag:"letsencrypt-domains"` // in case of using Route 53 for DNS challenge the credentials should be provided in the environment variables or // in the AWS credentials file - LetsencryptAWSRoute53 bool - TlsCertFile string - TlsKeyFile string - AuthSecret string - LogLevel string - LogFile string - HealthcheckListenAddress string + LetsencryptAWSRoute53 bool `yaml:"letsencryptAWSRoute53" env:"NB_LETSENCRYPT_AWS_ROUTE53" flag:"letsencrypt-aws-route53"` + TlsCertFile string `yaml:"tlsCertFile" env:"NB_TLS_CERT_FILE" flag:"tls-cert-file"` + TlsKeyFile string `yaml:"tlsKeyFile" env:"NB_TLS_KEY_FILE" flag:"tls-key-file"` + AuthSecret string `yaml:"authSecret" env:"NB_AUTH_SECRET" flag:"auth-secret"` + LogLevel string `yaml:"logLevel" env:"NB_LOG_LEVEL" flag:"log-level"` + LogFile string `yaml:"logFile" env:"NB_LOG_FILE" flag:"log-file"` + HealthcheckListenAddress string `yaml:"healthcheckListenAddress" env:"NB_HEALTH_LISTEN_ADDRESS" flag:"health-listen-address"` // TrustedProxies is a comma-separated list of upstream proxy CIDRs/IPs whose // X-Real-Ip/X-Real-Port headers are trusted. Empty means never trust these headers. - TrustedProxies string + TrustedProxies string `yaml:"trustedProxies" env:"NB_TRUSTED_PROXIES" flag:"trusted-proxies"` // STUN server configuration - EnableSTUN bool - STUNPorts []int - STUNLogLevel string + EnableSTUN bool `yaml:"enableSTUN" env:"NB_ENABLE_STUN" flag:"enable-stun"` + STUNPorts []int `yaml:"stunPorts" env:"NB_STUN_PORTS" flag:"stun-ports"` + STUNLogLevel string `yaml:"stunLogLevel" env:"NB_STUN_LOG_LEVEL" flag:"stun-log-level"` +} + +func defaultConfig() *Config { + return &Config{ + ListenAddress: ":443", + MetricsPort: 9090, + LogLevel: "info", + LogFile: "console", + HealthcheckListenAddress: ":9000", + STUNPorts: []int{3478}, + STUNLogLevel: "info", + } } func (c Config) Validate() error { @@ -93,6 +107,7 @@ func (c Config) HasLetsEncrypt() bool { } var ( + configPath string cobraConfig *Config rootCmd = &cobra.Command{ Use: "relay", @@ -106,10 +121,11 @@ var ( func init() { _ = util.InitLog("trace", util.LogConsole) - cobraConfig = &Config{} - rootCmd.PersistentFlags().StringVarP(&cobraConfig.ListenAddress, "listen-address", "l", ":443", "listen address") + cobraConfig = defaultConfig() + rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to configuration file") + rootCmd.PersistentFlags().StringVarP(&cobraConfig.ListenAddress, "listen-address", "l", cobraConfig.ListenAddress, "listen address") rootCmd.PersistentFlags().StringVarP(&cobraConfig.ExposedAddress, "exposed-address", "e", "", "instance domain address (or ip) and port, it will be distributes between peers") - rootCmd.PersistentFlags().IntVar(&cobraConfig.MetricsPort, "metrics-port", 9090, "metrics endpoint http port. Metrics are accessible under host:metrics-port/metrics") + rootCmd.PersistentFlags().IntVar(&cobraConfig.MetricsPort, "metrics-port", cobraConfig.MetricsPort, "metrics endpoint http port. Metrics are accessible under host:metrics-port/metrics") rootCmd.PersistentFlags().StringVarP(&cobraConfig.LetsencryptDataDir, "letsencrypt-data-dir", "d", "", "a directory to store Let's Encrypt data. Required if Let's Encrypt is enabled.") rootCmd.PersistentFlags().StringSliceVarP(&cobraConfig.LetsencryptDomains, "letsencrypt-domains", "a", nil, "list of domains to issue Let's Encrypt certificate for. Enables TLS using Let's Encrypt. Will fetch and renew certificate, and run the server with TLS") rootCmd.PersistentFlags().StringVar(&cobraConfig.LetsencryptEmail, "letsencrypt-email", "", "email address to use for Let's Encrypt certificate registration") @@ -117,15 +133,13 @@ func init() { rootCmd.PersistentFlags().StringVarP(&cobraConfig.TlsCertFile, "tls-cert-file", "c", "", "") rootCmd.PersistentFlags().StringVarP(&cobraConfig.TlsKeyFile, "tls-key-file", "k", "", "") rootCmd.PersistentFlags().StringVarP(&cobraConfig.AuthSecret, "auth-secret", "s", "", "auth secret") - rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level") - rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file") - rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server") + rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", cobraConfig.LogLevel, "log level") + rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", cobraConfig.LogFile, "log file") + rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", cobraConfig.HealthcheckListenAddress, "listen address of healthcheck server") rootCmd.PersistentFlags().StringVar(&cobraConfig.TrustedProxies, "trusted-proxies", "", "comma-separated list of upstream proxy CIDRs or IPs whose X-Real-Ip/X-Real-Port headers are trusted; leave empty to always use the direct connection address") rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server") - rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)") - rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)") - - setFlagsFromEnvVars(rootCmd) + rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", cobraConfig.STUNPorts, "ports for the embedded STUN server (can be specified multiple times or comma-separated)") + rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", cobraConfig.STUNLogLevel, "log level for STUN server (panic, fatal, error, warn, info, debug, trace)") } func Execute() error { @@ -139,8 +153,14 @@ func waitForExitSignal() { } func execute(cmd *cobra.Command, args []string) error { + loadedConfig, err := loadConfig(cmd) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + cobraConfig = loadedConfig + wg := sync.WaitGroup{} - err := cobraConfig.Validate() + err = cobraConfig.Validate() if err != nil { log.Debugf("invalid config: %s", err) return fmt.Errorf("invalid config: %s", err) @@ -228,6 +248,15 @@ func execute(cmd *cobra.Command, args []string) error { return err } +func loadConfig(cmd *cobra.Command) (*Config, error) { + return configloader.Load(configPath, defaultConfig(), configloader.Options{ + TagName: "yaml", + AllowMissing: configPath == "", + FlagSet: cmd.Flags(), + Strict: true, + }) +} + func startServers(wg *sync.WaitGroup, metricsServer *metrics.Metrics, srv *server.Server, srvListenerCfg server.ListenerConfig, httpHealthcheck *healthcheck.Server, stunServer *stun.Server) { wg.Add(1) go func() { diff --git a/signal/cmd/config.go b/signal/cmd/config.go new file mode 100644 index 000000000..ed6ac2050 --- /dev/null +++ b/signal/cmd/config.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "os" + "runtime" + + "github.com/spf13/cobra" + + configloader "github.com/netbirdio/netbird/util/config" +) + +// Config contains Signal service startup configuration. +type Config struct { + Port int `yaml:"port" env:"NB_PORT" flag:"port"` + MetricsPort int `yaml:"metricsPort" env:"NB_METRICS_PORT" flag:"metrics-port"` + LetsencryptDomain string `yaml:"letsencryptDomain" env:"NB_LETSENCRYPT_DOMAIN" flag:"letsencrypt-domain"` + LetsencryptEmail string `yaml:"letsencryptEmail" env:"NB_LETSENCRYPT_EMAIL" flag:"letsencrypt-email"` + LetsencryptDataDir string `yaml:"letsencryptDataDir" env:"NB_LETSENCRYPT_DATA_DIR,NB_SSL_DIR" flag:"letsencrypt-data-dir,ssl-dir"` + CertFile string `yaml:"certFile" env:"NB_CERT_FILE" flag:"cert-file"` + CertKey string `yaml:"certKey" env:"NB_CERT_KEY" flag:"cert-key"` + LogLevel string `yaml:"logLevel" env:"NB_LOG_LEVEL" flag:"log-level"` + LogFile string `yaml:"logFile" env:"NB_LOG_FILE" flag:"log-file"` + PprofAddress string `yaml:"pprofAddress" env:"NB_PPROF_ADDR"` +} + +func defaultConfig() *Config { + logFile := "/var/log/netbird/signal.log" + if runtime.GOOS == "windows" { + logFile = os.Getenv("PROGRAMDATA") + "\\Netbird\\signal.log" + } + return &Config{ + MetricsPort: 9090, + LogLevel: "info", + LogFile: logFile, + } +} + +func loadConfig(cmd *cobra.Command, configPath string) (*Config, error) { + return configloader.Load(configPath, defaultConfig(), configloader.Options{ + TagName: "yaml", + AllowMissing: configPath == "", + FlagSet: cmd.Flags(), + Strict: true, + }) +} diff --git a/signal/cmd/config_test.go b/signal/cmd/config_test.go new file mode 100644 index 000000000..823471548 --- /dev/null +++ b/signal/cmd/config_test.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadConfigPrecedence(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "signal.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(` +port: 10001 +metricsPort: 9091 +logLevel: warn +pprofAddress: localhost:6060 +`), 0o600)) + t.Setenv("NB_METRICS_PORT", "9191") + t.Setenv("NB_SSL_DIR", "/legacy-certs") + require.NoError(t, runCmd.ParseFlags(nil)) + + portFlag := runCmd.Flags().Lookup("port") + oldPort := portFlag.Value.String() + oldChanged := portFlag.Changed + t.Cleanup(func() { + require.NoError(t, portFlag.Value.Set(oldPort)) + portFlag.Changed = oldChanged + }) + require.NoError(t, portFlag.Value.Set("10002")) + portFlag.Changed = true + + cfg, err := loadConfig(runCmd, configPath) + require.NoError(t, err) + assert.Equal(t, 10002, cfg.Port, "Flags should override the configuration file") + assert.Equal(t, 9191, cfg.MetricsPort, "Environment should override the configuration file") + assert.Equal(t, "warn", cfg.LogLevel, "File values should override defaults") + assert.Equal(t, "/legacy-certs", cfg.LetsencryptDataDir, "Legacy environment aliases should remain supported") + assert.Equal(t, "localhost:6060", cfg.PprofAddress, "Environment-only settings should load from the file") +} diff --git a/signal/cmd/env.go b/signal/cmd/env.go deleted file mode 100644 index 3c15ebe1f..000000000 --- a/signal/cmd/env.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "os" - "strings" - - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -// setFlagsFromEnvVars reads and updates flag values from environment variables with prefix NB_ -func setFlagsFromEnvVars(cmd *cobra.Command) { - flags := cmd.PersistentFlags() - flags.VisitAll(func(f *pflag.Flag) { - newEnvVar := flagNameToEnvVar(f.Name, "NB_") - value, present := os.LookupEnv(newEnvVar) - if !present { - return - } - - err := flags.Set(f.Name, value) - if err != nil { - log.Infof("unable to configure flag %s using variable %s, err: %v", f.Name, newEnvVar, err) - } - }) -} - -// flagNameToEnvVar converts flag name to environment var name adding a prefix, -// replacing dashes and making all uppercase (e.g. setup-keys is converted to NB_SETUP_KEYS according to the input prefix) -func flagNameToEnvVar(cmdFlag string, prefix string) string { - parsed := strings.ReplaceAll(cmdFlag, "-", "_") - upper := strings.ToUpper(parsed) - return prefix + upper -} diff --git a/signal/cmd/root.go b/signal/cmd/root.go index 155790482..11757c145 100644 --- a/signal/cmd/root.go +++ b/signal/cmd/root.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "os/signal" - "runtime" "github.com/spf13/cobra" @@ -17,9 +16,8 @@ const ( ) var ( - logLevel string - defaultLogFile string - logFile string + logLevel string + logFile string rootCmd = &cobra.Command{ Use: "netbird-signal", @@ -39,14 +37,9 @@ func Execute() error { func init() { stopCh = make(chan int) - defaultLogFile = "/var/log/netbird/signal.log" - - if runtime.GOOS == "windows" { - defaultLogFile = os.Getenv("PROGRAMDATA") + "\\Netbird\\" + "signal.log" - } - - rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "") - rootCmd.PersistentFlags().StringVar(&logFile, "log-file", defaultLogFile, "sets Netbird log path. If console is specified the log will be output to stdout") + defaults := defaultConfig() + rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", defaults.LogLevel, "") + rootCmd.PersistentFlags().StringVar(&logFile, "log-file", defaults.LogFile, "sets Netbird log path. If console is specified the log will be output to stdout") rootCmd.AddCommand(runCmd) } diff --git a/signal/cmd/run.go b/signal/cmd/run.go index a36623c6b..9c6910312 100644 --- a/signal/cmd/run.go +++ b/signal/cmd/run.go @@ -10,7 +10,6 @@ import ( "net/http" // nolint:gosec _ "net/http/pprof" - "os" "time" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" @@ -46,6 +45,8 @@ var ( signalLetsencryptDataDir string signalCertFile string signalCertKey string + signalConfigPath string + signalPprofAddress string signalKaep = grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ MinTime: 5 * time.Second, @@ -64,30 +65,25 @@ var ( Short: "start NetBird Signal Server daemon", SilenceUsage: true, PreRunE: func(cmd *cobra.Command, args []string) error { - err := util.InitLog(logLevel, logFile) + cfg, err := loadConfig(cmd, signalConfigPath) if err != nil { - return fmt.Errorf("failed initializing log: %w", err) + return fmt.Errorf("load config: %w", err) } + applyConfig(cfg) - flag.Parse() - - // detect whether user specified a port - userPort := cmd.Flag("port").Changed - - var tlsEnabled bool - if signalLetsencryptDomain != "" || (signalCertFile != "" && signalCertKey != "") { - tlsEnabled = true - } - - if !userPort { - // different defaults for signalPort - if tlsEnabled { + if signalPort == 0 { + if signalLetsencryptDomain != "" || (signalCertFile != "" && signalCertKey != "") { signalPort = 443 } else { signalPort = 80 } } + if err := util.InitLog(logLevel, logFile); err != nil { + return fmt.Errorf("initialize log: %w", err) + } + + flag.Parse() return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -196,10 +192,10 @@ var ( ) func startPprof() { - if pprofAddr := os.Getenv("NB_PPROF_ADDR"); pprofAddr != "" { - log.Infof("pprof enabled, listening on: %s", pprofAddr) + if signalPprofAddress != "" { + log.Infof("pprof enabled, listening on: %s", signalPprofAddress) go func() { - if err := http.ListenAndServe(pprofAddr, nil); err != nil { + if err := http.ListenAndServe(signalPprofAddress, nil); err != nil { log.Fatalf("pprof server failed: %v", err) } }() @@ -328,13 +324,27 @@ func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) { } func init() { - runCmd.PersistentFlags().IntVar(&signalPort, "port", 80, "Server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise") - runCmd.Flags().IntVar(&metricsPort, "metrics-port", 9090, "metrics endpoint http port. Metrics are accessible under host:metrics-port/metrics") + defaults := defaultConfig() + runCmd.PersistentFlags().StringVar(&signalConfigPath, "config", "", "path to configuration file") + runCmd.PersistentFlags().IntVar(&signalPort, "port", defaults.Port, "Server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise)") + runCmd.Flags().IntVar(&metricsPort, "metrics-port", defaults.MetricsPort, "metrics endpoint http port. Metrics are accessible under host:metrics-port/metrics") runCmd.PersistentFlags().StringVar(&signalLetsencryptDataDir, "letsencrypt-data-dir", "", "a directory to store Let's Encrypt data. Required if Let's Encrypt is enabled.") runCmd.PersistentFlags().StringVar(&signalLetsencryptDataDir, "ssl-dir", "", "server ssl directory location. *Required only for Let's Encrypt certificates. Deprecated: use --letsencrypt-data-dir") runCmd.PersistentFlags().StringVar(&signalLetsencryptDomain, "letsencrypt-domain", "", "a domain to issue Let's Encrypt certificate for. Enables TLS using Let's Encrypt. Will fetch and renew certificate, and run the server with TLS") runCmd.PersistentFlags().StringVar(&signalLetsencryptEmail, "letsencrypt-email", "", "email address to use for Let's Encrypt certificate registration") runCmd.PersistentFlags().StringVar(&signalCertFile, "cert-file", "", "Location of your SSL certificate. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect") runCmd.PersistentFlags().StringVar(&signalCertKey, "cert-key", "", "Location of your SSL certificate private key. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect") - setFlagsFromEnvVars(runCmd) +} + +func applyConfig(cfg *Config) { + signalPort = cfg.Port + metricsPort = cfg.MetricsPort + signalLetsencryptDomain = cfg.LetsencryptDomain + signalLetsencryptEmail = cfg.LetsencryptEmail + signalLetsencryptDataDir = cfg.LetsencryptDataDir + signalCertFile = cfg.CertFile + signalCertKey = cfg.CertKey + logLevel = cfg.LogLevel + logFile = cfg.LogFile + signalPprofAddress = cfg.PprofAddress } diff --git a/trustedproxy/trustedproxy.go b/trustedproxy/trustedproxy.go index 70df01d92..3db9e1ce6 100644 --- a/trustedproxy/trustedproxy.go +++ b/trustedproxy/trustedproxy.go @@ -49,6 +49,16 @@ func Parse(raw string) (*List, error) { return &List{prefixes: prefixes}, nil } +// UnmarshalText parses trusted proxy prefixes from text configuration. +func (l *List) UnmarshalText(text []byte) error { + parsed, err := Parse(string(text)) + if err != nil { + return err + } + *l = *parsed + return nil +} + // FromPrefixes wraps an already-parsed set of prefixes in a List. func FromPrefixes(prefixes []netip.Prefix) *List { return &List{prefixes: prefixes} diff --git a/util/config/loader.go b/util/config/loader.go new file mode 100644 index 000000000..69801a375 --- /dev/null +++ b/util/config/loader.go @@ -0,0 +1,384 @@ +// Package config loads service configuration from defaults, files, environment +// variables, and command-line flags. Values are applied in that order, so an +// explicitly changed flag has the highest precedence. +// +// Configuration keys come from the struct tag selected by [Options.TagName], +// which defaults to "mapstructure". Environment variable names are inferred +// from those keys with the NB prefix. For example, server.listen-address maps +// to NB_SERVER_LISTEN_ADDRESS. The env and flag tags can provide explicit names, +// comma-separated compatibility aliases, or "-" to disable a source. +// +// A typical service configuration can be loaded as follows: +// +// type Config struct { +// Address string `yaml:"address" env:"NB_ADDRESS" flag:"address"` +// Timeout time.Duration `yaml:"timeout"` +// } +// +// flags := pflag.NewFlagSet("service", pflag.ContinueOnError) +// flags.String("address", ":443", "service listen address") +// +// cfg, err := config.Load("config.yaml", &Config{ +// Address: ":443", +// Timeout: 30 * time.Second, +// }, config.Options{ +// TagName: "yaml", +// FlagSet: flags, +// Strict: true, +// }) +// +// Set [Options.AllowMissing] when the service must start without a configuration +// file. [Options.Transform] can preprocess file contents before decoding, such +// as with [ExpandEnvTemplate]. +package config + +import ( + "bytes" + "encoding" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + + "github.com/go-viper/mapstructure/v2" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const envPrefix = "NB" + +var ( + textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() + jsonUnmarshalerType = reflect.TypeFor[json.Unmarshaler]() +) + +// Options controls how Load resolves files and fields. +type Options struct { + // TagName selects the struct tag used for configuration keys. + TagName string + // AllowMissing permits an empty path or a file that does not exist. + AllowMissing bool + // FlagSet provides command-line flags referenced by `flag` struct tags. + FlagSet *pflag.FlagSet + // Transform rewrites configuration file contents before decoding. + Transform func([]byte) ([]byte, error) + // Strict rejects configuration keys that are not represented by the target type. + Strict bool +} + +// Load reads configuration into a default-initialized value. Environment values +// override file values, and file values override defaults. +func Load[T any](configPath string, cfg *T, options Options) (*T, error) { + if cfg == nil { + return nil, fmt.Errorf("default config is nil") + } + + configType := reflect.TypeFor[T]() + if configType.Kind() != reflect.Struct { + return nil, fmt.Errorf("config type %s must be a struct", configType) + } + + if configPath == "" && !options.AllowMissing { + return nil, errors.New("config file path is required") + } + + tagName := options.TagName + if tagName == "" { + tagName = "mapstructure" + } + + configData, err := readConfigFile(configPath, options.AllowMissing) + if err != nil { + return nil, err + } + + configFormat := "" + if configData != nil { + configFormat, err = resolveConfigType(configPath, tagName) + if err != nil { + return nil, err + } + if options.Transform != nil { + configData, err = options.Transform(configData) + if err != nil { + return nil, fmt.Errorf("transform config: %w", err) + } + } + } + + v := viper.New() + if configFormat != "" { + v.SetConfigType(configFormat) + } + v.SetEnvPrefix(envPrefix) + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + v.AllowEmptyEnv(true) + v.AutomaticEnv() + + if err := bindConfigSources( + v, + configType, + "", + tagName, + options.FlagSet, + true, + true, + make(map[reflect.Type]bool), + ); err != nil { + return nil, fmt.Errorf("bind config sources: %w", err) + } + + if configData != nil { + if err := v.ReadConfig(bytes.NewReader(configData)); err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + } + + var unmarshalErr error + if options.Strict { + unmarshalErr = v.UnmarshalExact(cfg, decoderConfig(tagName)) + } else { + unmarshalErr = v.Unmarshal(cfg, decoderConfig(tagName)) + } + if unmarshalErr != nil { + return nil, fmt.Errorf("unmarshal config: %w", unmarshalErr) + } + return cfg, nil +} + +func readConfigFile(configPath string, allowMissing bool) ([]byte, error) { + if configPath == "" { + return nil, nil + } + + data, err := os.ReadFile(configPath) + if err == nil { + return data, nil + } + if allowMissing && errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read config file: %w", err) +} + +func resolveConfigType(configPath, fallbackType string) (string, error) { + extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(configPath)), ".") + if slices.Contains(viper.SupportedExts, extension) { + return extension, nil + } + + fallbackType = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(fallbackType)), ".") + if fallbackType != "" { + if !slices.Contains(viper.SupportedExts, fallbackType) { + return "", fmt.Errorf("unsupported default config type %q", fallbackType) + } + return fallbackType, nil + } + + if extension == "" { + return "", errors.New("config file extension is required") + } + return "", fmt.Errorf("unsupported config file extension %q", extension) +} + +func decoderConfig(tagName string) viper.DecoderConfigOption { + return func(config *mapstructure.DecoderConfig) { + config.TagName = tagName + config.DecodeHook = mapstructure.ComposeDecodeHookFunc( + decodeLegacyBoolean, + mapstructure.TextUnmarshallerHookFunc(), + jsonUnmarshallerHook, + config.DecodeHook, + ) + } +} + +func bindConfigSources( + v *viper.Viper, + configType reflect.Type, + prefix string, + tagName string, + flagSet *pflag.FlagSet, + bindEnvironment bool, + bindFlags bool, + visiting map[reflect.Type]bool, +) error { + for configType.Kind() == reflect.Pointer { + configType = configType.Elem() + } + visiting[configType] = true + defer delete(visiting, configType) + + for i := range configType.NumField() { + field := configType.Field(i) + if !field.IsExported() { + continue + } + + key, inline, skip := configFieldKey(field, tagName) + if skip { + continue + } + if inline { + key = prefix + } else if prefix != "" { + key = prefix + "." + key + } + + fieldEnvironment := field.Tag.Get("env") + fieldFlags := field.Tag.Get("flag") + bindFieldEnvironment := bindEnvironment && fieldEnvironment != "-" + bindFieldFlags := bindFlags && fieldFlags != "-" + + fieldType := field.Type + for fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + if fieldType.Kind() == reflect.Struct && !isScalarUnmarshaler(fieldType) { + if !visiting[fieldType] { + if err := bindConfigSources( + v, + fieldType, + key, + tagName, + flagSet, + bindFieldEnvironment, + bindFieldFlags, + visiting, + ); err != nil { + return err + } + } + continue + } + + if key == "" { + return fmt.Errorf("empty config key for field %s", field.Name) + } + if bindFieldEnvironment { + if err := bindEnvironmentVariable(v, key, fieldEnvironment); err != nil { + return err + } + } + if bindFieldFlags && flagSet != nil && fieldFlags != "" { + flagName, flag, err := selectFlag(flagSet, fieldFlags) + if err != nil { + return fmt.Errorf("config field %s: %w", field.Name, err) + } + if err := v.BindPFlag(key, flag); err != nil { + return fmt.Errorf("bind flag %s: %w", flagName, err) + } + } + } + return nil +} + +func configFieldKey(field reflect.StructField, tagName string) (key string, inline, skip bool) { + tagParts := strings.Split(field.Tag.Get(tagName), ",") + key = tagParts[0] + if key == "-" { + return "", false, true + } + if key == "" { + key = field.Name + } + for _, option := range tagParts[1:] { + if option == "inline" || option == "squash" { + inline = true + break + } + } + return key, inline, false +} + +func selectFlag(flagSet *pflag.FlagSet, names string) (string, *pflag.Flag, error) { + var selected *pflag.Flag + selectedName := "" + for _, name := range strings.Split(names, ",") { + flag := flagSet.Lookup(name) + if flag == nil { + return "", nil, fmt.Errorf("references unknown flag %q", name) + } + if selected == nil || flag.Changed { + selected = flag + selectedName = name + } + if flag.Changed { + break + } + } + return selectedName, selected, nil +} + +func bindEnvironmentVariable(v *viper.Viper, key, environmentName string) error { + var err error + if environmentName == "" { + err = v.BindEnv(key) + } else { + names := strings.Split(environmentName, ",") + arguments := append([]string{key}, names...) + err = v.BindEnv(arguments...) + } + if err != nil { + return fmt.Errorf("bind environment for %s: %w", key, err) + } + return nil +} + +func isScalarUnmarshaler(configType reflect.Type) bool { + return implements(configType, textUnmarshalerType) || + implements(configType, jsonUnmarshalerType) +} + +func implements(configType, interfaceType reflect.Type) bool { + return configType.Implements(interfaceType) || + reflect.PointerTo(configType).Implements(interfaceType) +} + +func jsonUnmarshallerHook(from, to reflect.Type, data any) (any, error) { + if !implements(to, jsonUnmarshalerType) { + return data, nil + } + + raw, err := json.Marshal(data) + if err != nil { + return nil, err + } + + targetType := to + if targetType.Kind() == reflect.Pointer { + targetType = targetType.Elem() + } + target := reflect.New(targetType) + unmarshaler, ok := target.Interface().(json.Unmarshaler) + if !ok { + return data, nil + } + if err := unmarshaler.UnmarshalJSON(raw); err != nil { + return nil, err + } + if to.Kind() == reflect.Pointer { + return target.Interface(), nil + } + return target.Elem().Interface(), nil +} + +func decodeLegacyBoolean(from, to reflect.Kind, data any) (any, error) { + if from != reflect.String || to != reflect.Bool { + return data, nil + } + + switch strings.ToLower(data.(string)) { + case "y", "yes", "on": + return true, nil + case "n", "no", "off": + return false, nil + default: + return data, nil + } +} diff --git a/util/config/loader_test.go b/util/config/loader_test.go new file mode 100644 index 000000000..82da27c48 --- /dev/null +++ b/util/config/loader_test.go @@ -0,0 +1,238 @@ +package config + +import ( + "encoding/json" + "net/netip" + "os" + "path/filepath" + "testing" + "time" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testConfig struct { + Server testServerConfig `yaml:"server"` + Internal string `yaml:"-"` +} + +type testServerConfig struct { + Address string `yaml:"address" env:"APP_SERVER_ADDRESS" flag:"address,legacy-address"` + BindAddress netip.Addr `yaml:"bindAddress"` + AdvertisedAddress netip.Addr `yaml:"advertisedAddress"` + Enabled bool `yaml:"enabled"` + LogLevel string `yaml:"logLevel"` + Timeout time.Duration `yaml:"timeout"` + Ports []int `yaml:"ports"` + Owner *testOwnerConfig `yaml:"owner,omitempty"` + TLS testTLSConfig `yaml:"tls"` + JSONValue testJSONValue `yaml:"jsonValue"` +} + +type testOwnerConfig struct { + Email string `yaml:"email"` +} + +type testTLSConfig struct { + Enabled bool `yaml:"enabled"` +} + +type testJSONValue struct { + Value string +} + +func (v *testJSONValue) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.Value) +} + +type recursiveConfig struct { + Value string `yaml:"value"` + Next *recursiveConfig `yaml:"next,omitempty"` +} + +func defaultTestConfig() *testConfig { + return &testConfig{ + Server: testServerConfig{ + Address: ":443", + LogLevel: "info", + Timeout: 30 * time.Second, + Ports: []int{443}, + }, + Internal: "default-internal", + } +} + +func TestLoadAppliesFileEnvironmentAndDefaults(t *testing.T) { + configPath := writeConfigFile(t, "config.conf", ` +server: + bindAddress: 192.0.2.1 + enabled: yes + logLevel: warn + timeout: 5s + ports: [80, 443] + jsonValue: decoded +internal: ignored +`) + t.Setenv("NB_SERVER_LOGLEVEL", "debug") + t.Setenv("NB_SERVER_OWNER_EMAIL", "owner@example.com") + t.Setenv("NB_SERVER_ADVERTISEDADDRESS", "198.51.100.1") + + cfg, err := Load(configPath, defaultTestConfig(), Options{ + TagName: "yaml", + }) + require.NoError(t, err) + assert.Equal(t, ":443", cfg.Server.Address, "Defaults should survive decoding") + assert.Equal(t, netip.MustParseAddr("192.0.2.1"), cfg.Server.BindAddress, "Text values from files should be decoded") + assert.Equal(t, netip.MustParseAddr("198.51.100.1"), cfg.Server.AdvertisedAddress, "Text values from the environment should be decoded") + assert.True(t, cfg.Server.Enabled, "Legacy YAML booleans should be decoded") + assert.Equal(t, "debug", cfg.Server.LogLevel, "Environment should override the file") + assert.Equal(t, 5*time.Second, cfg.Server.Timeout, "Durations should be decoded") + assert.Equal(t, []int{80, 443}, cfg.Server.Ports, "Slices should be decoded") + assert.Equal(t, "decoded", cfg.Server.JSONValue.Value, "JSON unmarshalers should be decoded") + require.NotNil(t, cfg.Server.Owner, "Environment should create optional nested configuration") + assert.Equal(t, "owner@example.com", cfg.Server.Owner.Email, "Nested environment values should be decoded") + assert.Equal(t, "default-internal", cfg.Internal, "Ignored fields should retain their defaults") +} + +func TestLoadFlagPrecedence(t *testing.T) { + configPath := writeConfigFile(t, "config.yaml", ` +server: + address: ":8443" +`) + t.Setenv("APP_SERVER_ADDRESS", ":9443") + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("address", ":443", "") + flags.String("legacy-address", ":443", "") + require.NoError(t, flags.Set("legacy-address", ":7443")) + + cfg, err := Load(configPath, defaultTestConfig(), Options{ + TagName: "yaml", + FlagSet: flags, + }) + require.NoError(t, err) + assert.Equal(t, ":7443", cfg.Server.Address, "Flags should override environment and file values") +} + +func TestLoadAllowsEmptyEnvironmentOverrides(t *testing.T) { + configPath := writeConfigFile(t, "config.yaml", ` +server: + address: ":8443" +`) + t.Setenv("APP_SERVER_ADDRESS", "") + + cfg, err := Load(configPath, defaultTestConfig(), Options{ + TagName: "yaml", + }) + require.NoError(t, err) + assert.Empty(t, cfg.Server.Address, "An explicitly empty environment value should clear the file value") +} + +func TestLoadTransformsConfig(t *testing.T) { + t.Setenv("CONFIG_ADDRESS", ":8443") + configPath := writeConfigFile(t, "config.yaml", ` +server: + address: "{{ .CONFIG_ADDRESS }}" +`) + + cfg, err := Load(configPath, defaultTestConfig(), Options{ + TagName: "yaml", + Transform: ExpandEnvTemplate, + }) + require.NoError(t, err) + assert.Equal(t, ":8443", cfg.Server.Address, "The transform should run before decoding") +} + +func TestLoadUsesRecognizedFileType(t *testing.T) { + configPath := writeConfigFile(t, "config.toml", ` +[server] +address = ":8443" +enabled = true +`) + + cfg, err := Load(configPath, defaultTestConfig(), Options{TagName: "yaml"}) + require.NoError(t, err) + assert.Equal(t, ":8443", cfg.Server.Address, "The file extension should select the decoder") + assert.True(t, cfg.Server.Enabled, "TOML booleans should be decoded") +} + +func TestLoadAllowsMissingFile(t *testing.T) { + testCases := []struct { + name string + path string + }{ + {name: "empty path"}, + {name: "unknown extension", path: filepath.Join(t.TempDir(), "missing.conf")}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv("APP_SERVER_ADDRESS", ":9443") + + cfg, err := Load(testCase.path, defaultTestConfig(), Options{ + TagName: "yaml", + AllowMissing: true, + }) + require.NoError(t, err) + assert.Equal(t, ":9443", cfg.Server.Address, "Environment should override defaults without a file") + }) + } +} + +func TestLoadSupportsRecursiveConfigTypes(t *testing.T) { + configPath := writeConfigFile(t, "config.yaml", ` +value: first +next: + value: second +`) + + cfg, err := Load(configPath, &recursiveConfig{}, Options{ + TagName: "yaml", + }) + require.NoError(t, err) + assert.Equal(t, "first", cfg.Value, "Root values should be decoded") + require.NotNil(t, cfg.Next, "Recursive configuration should be decoded from the file") + assert.Equal(t, "second", cfg.Next.Value, "Nested recursive values should be decoded") +} + +func TestLoadRejectsMissingFileByDefault(t *testing.T) { + _, err := Load(filepath.Join(t.TempDir(), "missing.yaml"), defaultTestConfig(), Options{ + TagName: "yaml", + }) + require.Error(t, err) +} + +func TestLoadStrictRejectsUnknownKeys(t *testing.T) { + configPath := writeConfigFile(t, "config.yaml", "unknown: true\n") + + _, err := Load(configPath, defaultTestConfig(), Options{ + TagName: "yaml", + Strict: true, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid keys") +} + +func TestLoadUsesTagAsDefaultFileType(t *testing.T) { + configPath := writeConfigFile(t, "config.conf", "server:\n address: :8443\n") + + cfg, err := Load(configPath, defaultTestConfig(), Options{TagName: "yaml"}) + require.NoError(t, err) + assert.Equal(t, ":8443", cfg.Server.Address, "The struct tag should select the fallback decoder") +} + +func TestLoadRejectsNilDefault(t *testing.T) { + configPath := writeConfigFile(t, "config.yaml", "server: {}\n") + + _, err := Load(configPath, (*testConfig)(nil), Options{TagName: "yaml"}) + require.Error(t, err) + assert.ErrorContains(t, err, "default config is nil") +} + +func writeConfigFile(t *testing.T, name, contents string) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(configPath, []byte(contents), 0o600)) + return configPath +} diff --git a/util/config/template.go b/util/config/template.go new file mode 100644 index 000000000..618c3d6f5 --- /dev/null +++ b/util/config/template.go @@ -0,0 +1,34 @@ +package config + +import ( + "bytes" + "fmt" + "os" + "strings" + "text/template" +) + +// ExpandEnvTemplate substitutes Go-template references with environment values. +func ExpandEnvTemplate(data []byte) ([]byte, error) { + tmpl, err := template.New("config").Parse(string(data)) + if err != nil { + return nil, fmt.Errorf("parse environment template: %w", err) + } + + var output bytes.Buffer + if err := tmpl.Execute(&output, environmentMap()); err != nil { + return nil, fmt.Errorf("execute environment template: %w", err) + } + return output.Bytes(), nil +} + +func environmentMap() map[string]string { + environment := make(map[string]string) + for _, entry := range os.Environ() { + key, value, ok := strings.Cut(entry, "=") + if ok { + environment[key] = value + } + } + return environment +} diff --git a/util/file.go b/util/file.go index 926904f9f..7d3944bf2 100644 --- a/util/file.go +++ b/util/file.go @@ -1,7 +1,6 @@ package util import ( - "bytes" "context" "encoding/json" "errors" @@ -10,10 +9,10 @@ import ( "os" "path/filepath" "sort" - "strings" - "text/template" log "github.com/sirupsen/logrus" + + configloader "github.com/netbirdio/netbird/util/config" ) func WriteBytesWithRestrictedPermission(ctx context.Context, file string, bs []byte) error { @@ -233,8 +232,6 @@ func ListFiles(dir, pattern string) ([]string, error) { // ReadJsonWithEnvSub reads JSON config file and maps to a provided interface with environment variable substitution func ReadJsonWithEnvSub(file string, res interface{}) (interface{}, error) { - envVars := getEnvMap() - f, err := os.Open(file) if err != nil { return nil, err @@ -246,19 +243,12 @@ func ReadJsonWithEnvSub(file string, res interface{}) (interface{}, error) { return nil, err } - t, err := template.New("").Parse(string(bs)) + output, err := configloader.ExpandEnvTemplate(bs) if err != nil { - return nil, fmt.Errorf("error parsing template: %v", err) + return nil, err } - var output bytes.Buffer - // Execute the template, substituting environment variables - err = t.Execute(&output, envVars) - if err != nil { - return nil, fmt.Errorf("error executing template: %v", err) - } - - err = json.Unmarshal(output.Bytes(), &res) + err = json.Unmarshal(output, &res) if err != nil { return nil, fmt.Errorf("failed parsing Json file after template was executed, err: %v", err) } @@ -266,20 +256,6 @@ func ReadJsonWithEnvSub(file string, res interface{}) (interface{}, error) { return res, nil } -// getEnvMap Convert the output of os.Environ() to a map -func getEnvMap() map[string]string { - envMap := make(map[string]string) - - for _, env := range os.Environ() { - parts := strings.SplitN(env, "=", 2) - if len(parts) == 2 { - envMap[parts[0]] = parts[1] - } - } - - return envMap -} - // CopyFileContents copies contents of the given src file to the dst file func CopyFileContents(src, dst string) (err error) { in, err := os.Open(src)