From 59194f6ef22cf3804393421ce05948e31bfdbfb9 Mon Sep 17 00:00:00 2001 From: Harshavardhan Musanalli Date: Mon, 19 Feb 2024 09:58:44 +0100 Subject: [PATCH] Support for passing username, password & priv_password as env vars (#1074) * Support for passing username, password & priv_password as env vars --------- Signed-off-by: Harshavardhan Musanalli Signed-off-by: Harshavardhan Musanalli Co-authored-by: Ben Kochie --- README.md | 19 +++++++++++ config/config.go | 40 ++++++++++++++++++++-- config_test.go | 61 ++++++++++++++++++++++++++++++++-- main.go | 19 ++++++----- testdata/snmp-auth-envvars.yml | 9 +++++ 5 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 testdata/snmp-auth-envvars.yml diff --git a/README.md b/README.md index 59431eb0..d73cdfa0 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ using SNMP v2 GETBULK. The `--config.file` parameter can be used multiple times to load more than one file. It also supports [glob filename matching](https://pkg.go.dev/path/filepath#Glob), e.g. `snmp*.yml`. +The `--config.expand-environment-variables` parameter allows passing environment variables into some fields of the configuration file. The `username`, `password` & `priv_password` fields in the auths section are supported. Defaults to disabled. + Duplicate `module` or `auth` entries are treated as invalid and can not be loaded. ## Prometheus Configuration @@ -156,6 +158,23 @@ scrape_configs: - targets: ['localhost:9116'] ``` +You could pass `username`, `password` & `priv_password` via environment variables of your choice in below format. +If the variables exist in the environment, they are resolved on the fly otherwise the string in the config file is passed as-is. + +This requires the `--config.expand-environment-variables` flag be set. + +```YAML +auths: + example_with_envs: + community: mysecret + security_level: SomethingReadOnly + username: ${ARISTA_USERNAME} + password: ${ARISTA_PASSWORD} + auth_protocol: SHA256 + priv_protocol: AES + priv_password: ${ARISTA_PRIV_PASSWORD} +``` + Similarly to [blackbox_exporter](https://github.com/prometheus/blackbox_exporter), `snmp_exporter` is meant to run on a few central machines and can be thought of like a "Prometheus proxy". diff --git a/config/config.go b/config/config.go index f1f896f1..a41f664a 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ package config import ( + "errors" "fmt" "os" "path/filepath" @@ -24,7 +25,7 @@ import ( "gopkg.in/yaml.v2" ) -func LoadFile(paths []string) (*Config, error) { +func LoadFile(paths []string, expandEnvVars bool) (*Config, error) { cfg := &Config{} for _, p := range paths { files, err := filepath.Glob(p) @@ -42,6 +43,27 @@ func LoadFile(paths []string) (*Config, error) { } } } + + if expandEnvVars { + var err error + for i, auth := range cfg.Auths { + cfg.Auths[i].Username, err = substituteEnvVariables(auth.Username) + if err != nil { + return nil, err + } + password, err := substituteEnvVariables(string(auth.Password)) + if err != nil { + return nil, err + } + cfg.Auths[i].Password.Set(password) + privPassword, err := substituteEnvVariables(string(auth.PrivPassword)) + if err != nil { + return nil, err + } + cfg.Auths[i].PrivPassword.Set(privPassword) + } + } + return cfg, nil } @@ -131,7 +153,6 @@ func (c Auth) ConfigureSNMP(g *gosnmp.GoSNMP) { priv = true } if auth { - usm.AuthenticationPassphrase = string(c.Password) switch c.AuthProtocol { case "SHA": usm.AuthenticationProtocol = gosnmp.SHA @@ -148,7 +169,6 @@ func (c Auth) ConfigureSNMP(g *gosnmp.GoSNMP) { } } if priv { - usm.PrivacyPassphrase = string(c.PrivPassword) switch c.PrivProtocol { case "DES": usm.PrivacyProtocol = gosnmp.DES @@ -213,6 +233,10 @@ type Lookup struct { // Secret is a string that must not be revealed on marshaling. type Secret string +func (s *Secret) Set(value string) { + *s = Secret(value) +} + // Hack for creating snmp.yml with the secret. var ( DoNotHideSecrets = false @@ -317,3 +341,13 @@ func (re *Regexp) UnmarshalYAML(unmarshal func(interface{}) error) error { re.Regexp = regex return nil } + +func substituteEnvVariables(value string) (string, error) { + result := os.Expand(value, func(s string) string { + return os.Getenv(s) + }) + if result == "" { + return "", errors.New(value + " environment variable not found") + } + return result, nil +} diff --git a/config_test.go b/config_test.go index 31ae12f5..75c6de80 100644 --- a/config_test.go +++ b/config_test.go @@ -14,6 +14,7 @@ package main import ( + "os" "strings" "testing" @@ -22,7 +23,7 @@ import ( func TestHideConfigSecrets(t *testing.T) { sc := &SafeConfig{} - err := sc.ReloadConfig([]string{"testdata/snmp-auth.yml"}) + err := sc.ReloadConfig([]string{"testdata/snmp-auth.yml"}, false) if err != nil { t.Errorf("Error loading config %v: %v", "testdata/snmp-auth.yml", err) } @@ -41,7 +42,7 @@ func TestHideConfigSecrets(t *testing.T) { func TestLoadConfigWithOverrides(t *testing.T) { sc := &SafeConfig{} - err := sc.ReloadConfig([]string{"testdata/snmp-with-overrides.yml"}) + err := sc.ReloadConfig([]string{"testdata/snmp-with-overrides.yml"}, false) if err != nil { t.Errorf("Error loading config %v: %v", "testdata/snmp-with-overrides.yml", err) } @@ -56,7 +57,7 @@ func TestLoadConfigWithOverrides(t *testing.T) { func TestLoadMultipleConfigs(t *testing.T) { sc := &SafeConfig{} configs := []string{"testdata/snmp-auth.yml", "testdata/snmp-with-overrides.yml"} - err := sc.ReloadConfig(configs) + err := sc.ReloadConfig(configs, false) if err != nil { t.Errorf("Error loading configs %v: %v", configs, err) } @@ -67,3 +68,57 @@ func TestLoadMultipleConfigs(t *testing.T) { t.Errorf("Error marshaling config: %v", err) } } + +// When all environment variables are present +func TestEnvSecrets(t *testing.T) { + os.Setenv("ENV_USERNAME", "snmp_username") + os.Setenv("ENV_PASSWORD", "snmp_password") + os.Setenv("ENV_PRIV_PASSWORD", "snmp_priv_password") + defer os.Unsetenv("ENV_USERNAME") + defer os.Unsetenv("ENV_PASSWORD") + defer os.Unsetenv("ENV_PRIV_PASSWORD") + + sc := &SafeConfig{} + err := sc.ReloadConfig([]string{"testdata/snmp-auth-envvars.yml"}, true) + if err != nil { + t.Errorf("Error loading config %v: %v", "testdata/snmp-auth-envvars.yml", err) + } + + // String method must not reveal authentication credentials. + sc.RLock() + c, err := yaml.Marshal(sc.C) + sc.RUnlock() + if err != nil { + t.Errorf("Error marshaling config: %v", err) + } + + if strings.Contains(string(c), "mysecret") { + t.Fatal("config's String method reveals authentication credentials.") + } + + // we check whether vars we set are resolved correctly in config + for i := range sc.C.Auths { + if sc.C.Auths[i].Username != "snmp_username" || sc.C.Auths[i].Password != "snmp_password" || sc.C.Auths[i].PrivPassword != "snmp_priv_password" { + t.Fatal("failed to resolve secrets from env vars") + } + } +} + +// When environment variable(s) are absent +func TestEnvSecretsMissing(t *testing.T) { + os.Setenv("ENV_PASSWORD", "snmp_password") + os.Setenv("ENV_PRIV_PASSWORD", "snmp_priv_password") + defer os.Unsetenv("ENV_PASSWORD") + defer os.Unsetenv("ENV_PRIV_PASSWORD") + + sc := &SafeConfig{} + err := sc.ReloadConfig([]string{"testdata/snmp-auth-envvars.yml"}, true) + if err != nil { + // we check the error message pattern to determine the error + if strings.Contains(err.Error(), "environment variable not found") { + t.Logf("Error loading config as env var is not set/missing %v: %v", "testdata/snmp-auth-envvars.yml", err) + } else { + t.Errorf("Error loading config %v: %v", "testdata/snmp-auth-envvars.yml", err) + } + } +} diff --git a/main.go b/main.go index 0cc13f1d..43193d18 100644 --- a/main.go +++ b/main.go @@ -45,10 +45,11 @@ const ( ) var ( - configFile = kingpin.Flag("config.file", "Path to configuration file.").Default("snmp.yml").Strings() - dryRun = kingpin.Flag("dry-run", "Only verify configuration is valid and exit.").Default("false").Bool() - concurrency = kingpin.Flag("snmp.module-concurrency", "The number of modules to fetch concurrently per scrape").Default("1").Int() - metricsPath = kingpin.Flag( + configFile = kingpin.Flag("config.file", "Path to configuration file.").Default("snmp.yml").Strings() + dryRun = kingpin.Flag("dry-run", "Only verify configuration is valid and exit.").Default("false").Bool() + concurrency = kingpin.Flag("snmp.module-concurrency", "The number of modules to fetch concurrently per scrape").Default("1").Int() + expandEnvVars = kingpin.Flag("config.expand-environment-variables", "Expand environment variables to source secrets").Default("false").Bool() + metricsPath = kingpin.Flag( "web.telemetry-path", "Path under which to expose metrics.", ).Default("/metrics").String() @@ -165,8 +166,8 @@ type SafeConfig struct { C *config.Config } -func (sc *SafeConfig) ReloadConfig(configFile []string) (err error) { - conf, err := config.LoadFile(configFile) +func (sc *SafeConfig) ReloadConfig(configFile []string, expandEnvVars bool) (err error) { + conf, err := config.LoadFile(configFile, expandEnvVars) if err != nil { return err } @@ -197,7 +198,7 @@ func main() { prometheus.MustRegister(version.NewCollector("snmp_exporter")) // Bail early if the config is bad. - err := sc.ReloadConfig(*configFile) + err := sc.ReloadConfig(*configFile, *expandEnvVars) if err != nil { level.Error(logger).Log("msg", "Error parsing config file", "err", err) level.Error(logger).Log("msg", "Possible old config file, see https://github.com/prometheus/snmp_exporter/blob/main/auth-split-migration.md") @@ -217,13 +218,13 @@ func main() { for { select { case <-hup: - if err := sc.ReloadConfig(*configFile); err != nil { + if err := sc.ReloadConfig(*configFile, *expandEnvVars); err != nil { level.Error(logger).Log("msg", "Error reloading config", "err", err) } else { level.Info(logger).Log("msg", "Loaded config file") } case rc := <-reloadCh: - if err := sc.ReloadConfig(*configFile); err != nil { + if err := sc.ReloadConfig(*configFile, *expandEnvVars); err != nil { level.Error(logger).Log("msg", "Error reloading config", "err", err) rc <- err } else { diff --git a/testdata/snmp-auth-envvars.yml b/testdata/snmp-auth-envvars.yml new file mode 100644 index 00000000..deff2a94 --- /dev/null +++ b/testdata/snmp-auth-envvars.yml @@ -0,0 +1,9 @@ +auths: + with_secret: + community: mysecret + security_level: SomethingReadOnly + username: ${ENV_USERNAME} + password: ${ENV_PASSWORD} + auth_protocol: SHA256 + priv_protocol: AES + priv_password: ${ENV_PRIV_PASSWORD} \ No newline at end of file