Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for passing username, password & priv_password as env vars #1074

Merged
merged 5 commits into from
Feb 19, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 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`.

When `--config.expand-environment-variables` parameter is set to `true`, `username`, `password` & `priv_password` could be resolved from the environment variables. Defaults to false.
harshavmb marked this conversation as resolved.
Show resolved Hide resolved

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if "community" should be allowed too, for consistency? (Although this may interact with community having a default value of "public")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this and without diving deep think that SNMPv3 doesn't use community.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are correct. I mean for use with v1/v2c, which some people still do use, and requires a "secret" community (albeit sent over the wire in clear text)

Duplicate `module` or `auth` entries are treated as invalid and can not be loaded.

## Prometheus Configuration
Expand Down Expand Up @@ -156,6 +158,20 @@ 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.
harshavmb marked this conversation as resolved.
Show resolved Hide resolved
```YAML
auths:
with_secret:
community: mysecret
security_level: SomethingReadOnly
username: ${env.ARISTA_USERNAME}
password: ${env.ARISTA_PASSWORD}
auth_protocol: SHA256
priv_protocol: AES
priv_password: ${env.ARISTA_PRIV_PASSWORD}
harshavmb marked this conversation as resolved.
Show resolved Hide resolved
```

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}