Skip to content

Commit

Permalink
Support for passing username, password & priv_password as env vars (#…
Browse files Browse the repository at this point in the history
…1074)

* Support for passing username, password & priv_password as env vars

---------

Signed-off-by: Harshavardhan Musanalli <[email protected]>
Signed-off-by: Harshavardhan Musanalli <[email protected]>
Co-authored-by: Ben Kochie <[email protected]>
  • Loading branch information
harshavmb and SuperQ authored Feb 19, 2024
1 parent 5512d84 commit 59194f6
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 15 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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".
Expand Down
40 changes: 37 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package config

import (
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
61 changes: 58 additions & 3 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package main

import (
"os"
"strings"
"testing"

Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
}
}
19 changes: 10 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions testdata/snmp-auth-envvars.yml
Original file line number Diff line number Diff line change
@@ -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}

0 comments on commit 59194f6

Please sign in to comment.