diff --git a/config/secret.go b/config/secret.go index 25f069e66..f06abe689 100644 --- a/config/secret.go +++ b/config/secret.go @@ -184,6 +184,7 @@ func (s *Secret) Set(value []byte) error { // Set the new secret s.enclave = memguard.NewEnclave(secret) s.resolvers = res + s.notempty = len(value) > 0 return nil } diff --git a/plugins/secretstores/all/http.go b/plugins/secretstores/all/http.go new file mode 100644 index 000000000..ac2fede51 --- /dev/null +++ b/plugins/secretstores/all/http.go @@ -0,0 +1,5 @@ +//go:build !custom || secretstores || secretstores.http + +package all + +import _ "github.com/influxdata/telegraf/plugins/secretstores/http" // register plugin diff --git a/plugins/secretstores/http/README.md b/plugins/secretstores/http/README.md new file mode 100644 index 000000000..6f02ed3a0 --- /dev/null +++ b/plugins/secretstores/http/README.md @@ -0,0 +1,160 @@ +# HTTP Secret-store Plugin + +The `http` plugin allows to query secrets from an HTTP endpoint. The secrets +can be transmitted plain-text or in an encrypted fashion. + +To manage your secrets of this secret-store, you should use Telegraf. Run + +```shell +telegraf secrets help +``` + +to get more information on how to do this. + +## Configuration + +```toml @sample.conf +# Read secrets from a HTTP endpoint +[[secretstores.http]] + ## URLs from which to read the secrets + url = "http://localhost/secrets" + + ## Optional HTTP headers + # headers = {"X-Special-Header" = "Special-Value"} + + ## Optional Token for Bearer Authentication via + ## "Authorization: Bearer " header + # token = "your-token" + + ## Optional Credentials for HTTP Basic Authentication + # username = "username" + # password = "pa$$word" + + ## OAuth2 Client Credentials. The options 'client_id', 'client_secret', and 'token_url' are required to use OAuth2. + # client_id = "clientid" + # client_secret = "secret" + # token_url = "https://indentityprovider/oauth2/v1/token" + # scopes = ["urn:opc:idm:__myscopes__"] + + ## HTTP Proxy support + # use_system_proxy = false + # http_proxy_url = "" + + ## Optional TLS Config + # tls_ca = "/etc/telegraf/ca.pem" + # tls_cert = "/etc/telegraf/cert.pem" + # tls_key = "/etc/telegraf/key.pem" + ## Minimal TLS version to accept by the client + # tls_min_version = "TLS12" + ## Use TLS but skip chain & host verification + # insecure_skip_verify = false + + ## Optional Cookie authentication + # cookie_auth_url = "https://localhost/authMe" + # cookie_auth_method = "POST" + # cookie_auth_username = "username" + # cookie_auth_password = "pa$$word" + # cookie_auth_headers = { Content-Type = "application/json", X-MY-HEADER = "hello" } + # cookie_auth_body = '{"username": "user", "password": "pa$$word", "authenticate": "me"}' + ## When unset or set to zero the authentication will only happen once + ## and will never renew the cookie. Set to a suitable duration if you + ## require cookie renewal! + # cookie_auth_renewal = "0s" + + ## Amount of time allowed to complete the HTTP request + # timeout = "5s" + + ## List of success status codes + # success_status_codes = [200] + + ## JSONata expression to transform the server response into a + ## { "secret name": "secret value", ... } + ## form. See https://jsonata.org for more information and a playground. + # transformation = '' + + ## Cipher used to decrypt the secrets. + ## In case your secrets are transmitted in an encrypted form, you need + ## to specify the cipher used and provide the corresponding configuration. + ## Please refer to https://github.com/influxdata/telegraf/blob/master/plugins/secretstores/http/README.md + ## for supported values. + # cipher = "none" + + ## AES cipher parameters + # [secretstores.http.aes] + # ## Key (hex-encoded) and initialization-vector (IV) for the decryption. + # ## In case the key (and IV) is derived from a password, the values can + # ## be omitted. + # key = "" + # init_vector = "" + # + # ## Parameters for password-based-key derivation. + # ## These parameters must match the encryption side to derive the same + # ## key on both sides! + # # kdf_algorithm = "PBKDF2-HMAC-SHA256" + # # password = "" + # # salt = "" + # # iterations = 0 +``` + +A collection of secrets is queried from the `url` endpoint. The plugin currently +expects JSON data in a flat key-value form and means to convert arbitrary JSON +to that form (see [transformation section](#transformation)). +Furthermore, the secret data can be transmitted in an encrypted +format, see [encryption section](#encryption) for details. + +## Transformation + +Secrets are currently expected to be JSON data in the following flat key-value +form + +```json +{ + "secret name A": "secret value A", + ... + "secret name X": "secret value X" +} +``` + +If your HTTP endpoint provides JSON data in a different format, you can use +the `transformation` option to apply a [JSONata expression](https://jsonata.org) +(version v1.5.4) to transform the server answer to the above format. + +## Encryption + +### Plain text + +Set `cipher` to `none` if the secrets are transmitted as plain-text. No further +options are required. + +### Advanced Encryption Standard (AES) + +Currently the following AES ciphers are supported + +- `AES128/CBC`: 128-bit key in _CBC_ block mode without padding +- `AES128/CBC/PKCS#5`: 128-bit key in _CBC_ block mode with _PKCS#5_ padding +- `AES128/CBC/PKCS#7`: 128-bit key in _CBC_ block mode with _PKCS#7_ padding +- `AES192/CBC`: 192-bit key in _CBC_ block mode without padding +- `AES192/CBC/PKCS#5`: 192-bit key in _CBC_ block mode with _PKCS#5_ padding +- `AES192/CBC/PKCS#7`: 192-bit key in _CBC_ block mode with _PKCS#7_ padding +- `AES256/CBC`: 256-bit key in _CBC_ block mode without padding +- `AES256/CBC/PKCS#5`: 256-bit key in _CBC_ block mode with _PKCS#5_ padding +- `AES256/CBC/PKCS#7`: 256-bit key in _CBC_ block mode with _PKCS#7_ padding + +Additional to the cipher, you need to provide the encryption `key` and +initialization vector `init_vector` to be able to decrypt the data. +In case you are using password-based key derivation, `key` +(and possibly `init_vector`) can be omitted. Take a look at the +[password-based key derivation section](#password-based-key-derivation). + +### Password-based key derivation + +Alternatively to providing a `key` (and `init_vector`) the key (and vector) +can be derived from a given password. Currently the following algorithms are +supported for `kdf_algorithm`: + +- `PBKDF2-HMAC-SHA256` for `key` only, no `init_vector` created + +You also need to provide the `password` to derive the key from as well as the +`salt` and `iterations` used. +__Please note:__ All parameters must match the encryption side to derive the +same key in Telegraf! diff --git a/plugins/secretstores/http/aes.go b/plugins/secretstores/http/aes.go new file mode 100644 index 000000000..b7f8fcdfb --- /dev/null +++ b/plugins/secretstores/http/aes.go @@ -0,0 +1,171 @@ +package http + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/awnumar/memguard" + "github.com/influxdata/telegraf/config" +) + +type AesEncryptor struct { + Variant []string `toml:"-"` + Key config.Secret `toml:"key"` + Vec config.Secret `toml:"init_vector"` + KDFConfig + + mode string + trim func([]byte) ([]byte, error) +} + +func (a *AesEncryptor) Init() error { + var cipherName, mode, padding string + + switch len(a.Variant) { + case 3: + padding = strings.ToLower(a.Variant[2]) + fallthrough + case 2: + mode = strings.ToLower(a.Variant[1]) + cipherName = strings.ToLower(a.Variant[0]) + if !strings.HasPrefix(cipherName, "aes") { + return fmt.Errorf("requested AES but specified %q", cipherName) + } + case 1: + return errors.New("please specify cipher mode") + case 0: + return errors.New("please specify cipher") + default: + return errors.New("too many variant elements") + } + + var keylen int + switch cipherName { + case "aes128": + keylen = 16 + case "aes192": + keylen = 24 + case "aes256": + keylen = 32 + default: + return fmt.Errorf("unsupported AES cipher %q", cipherName) + } + + if mode != "cbc" { + return fmt.Errorf("unsupported cipher mode %q", a.Variant[1]) + } + a.mode = mode + + // Setup the trimming function to revert padding + switch padding { + case "", "none": + // identity, no padding + a.trim = func(in []byte) ([]byte, error) { return in, nil } + case "pkcs#5", "pkcs#7": + a.trim = PKCS5or7Trimming + default: + return fmt.Errorf("unsupported padding %q", padding) + } + + // Generate the key using password-based-keys + if a.Key.Empty() { + if a.Passwd.Empty() { + return errors.New("either key or password has to be specified") + } + if a.Salt.Empty() || a.Iterations == 0 { + return errors.New("salt and iterations required for password-based-keys") + } + + key, iv, err := a.KDFConfig.NewKey(keylen) + if err != nil { + return fmt.Errorf("generating key failed: %w", err) + } + a.Key.Destroy() + a.Key = key + + if a.Vec.Empty() && !iv.Empty() { + a.Vec.Destroy() + a.Vec = iv + } + } else { + encodedKey, err := a.Key.Get() + if err != nil { + return fmt.Errorf("getting key failed: %w", err) + } + key := make([]byte, hex.DecodedLen(len(encodedKey))) + if _, err := hex.Decode(key, encodedKey); err != nil { + config.ReleaseSecret(encodedKey) + return fmt.Errorf("decoding key failed: %w", err) + } + config.ReleaseSecret(encodedKey) + actuallen := len(key) + memguard.WipeBytes(key) + + if actuallen != keylen { + return fmt.Errorf("key length (%d bit) does not match cipher (%d bit)", actuallen*8, keylen*8) + } + } + + if a.Vec.Empty() { + return errors.New("'init_vector' has to be specified or derived from password") + } + + encodedIV, err := a.Vec.Get() + if err != nil { + return fmt.Errorf("getting IV failed: %w", err) + } + ivlen := len(encodedIV) + config.ReleaseSecret(encodedIV) + if ivlen != 2*aes.BlockSize { + return errors.New("init vector size must match block size") + } + + return nil +} + +func (a *AesEncryptor) Decrypt(data []byte) ([]byte, error) { + if len(data)%aes.BlockSize != 0 { + return nil, fmt.Errorf("invalid data size %d", len(data)) + } + if a.mode != "cbc" { + return nil, fmt.Errorf("unsupported cipher mode %q", a.mode) + } + + // Setup the cipher and return the decoded data + encodedKey, err := a.Key.Get() + if err != nil { + return nil, fmt.Errorf("getting key failed: %w", err) + } + key := make([]byte, hex.DecodedLen(len(encodedKey))) + if _, err := hex.Decode(key, encodedKey); err != nil { + config.ReleaseSecret(encodedKey) + return nil, fmt.Errorf("decoding key failed: %w", err) + } + config.ReleaseSecret(encodedKey) + + // Setup AES + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("creating AES cipher failed: %w", err) + } + + // Setup the block/stream cipher and decode the data + encodedIV, err := a.Vec.Get() + if err != nil { + return nil, fmt.Errorf("getting initialization-vector failed: %w", err) + } + iv := make([]byte, hex.DecodedLen(len(encodedIV))) + _, err = hex.Decode(iv, encodedIV) + config.ReleaseSecret(encodedIV) + if err != nil { + memguard.WipeBytes(iv) + return nil, fmt.Errorf("decoding init vector failed: %w", err) + } + + cipher.NewCBCDecrypter(block, iv).CryptBlocks(data, data) + return a.trim(data) +} diff --git a/plugins/secretstores/http/aes_test.go b/plugins/secretstores/http/aes_test.go new file mode 100644 index 000000000..c3b4c2365 --- /dev/null +++ b/plugins/secretstores/http/aes_test.go @@ -0,0 +1,319 @@ +package http + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/influxdata/telegraf/config" +) + +func TestAES(t *testing.T) { + keySource := hex.EncodeToString([]byte("0123456789abcdefghijklmnopqrstuvwxyz")) + expected := "my $ecret-Passw0rd" + iv := hex.EncodeToString([]byte("0123456789abcdef")) + tests := []struct { + cipher string + encrypted string + key string + }{ + { + cipher: "AES128/CBC/PKCS#5", + encrypted: "9E36B490B0B1D6CE28550DF9DE65FC0013FF9F0939E24DA4A24324BDB5EABA04", + key: keySource[:32], + }, + { + cipher: "AES192/CBC/PKCS#5", + encrypted: "D3A5A0004B6783351F89B00C1D4154EDF2321EDAD3111B5551C18836B9FCFD62", + key: keySource[:48], + }, + { + cipher: "AES256/CBC/PKCS#5", + encrypted: "9751D7FB4B1497DEBC8A95C5D88097ECB1B8E63979E2D41E7ECD304D6B39B808", + key: keySource[:64], + }, + } + for _, tt := range tests { + t.Run(tt.cipher, func(t *testing.T) { + decrypter := AesEncryptor{ + Variant: strings.Split(tt.cipher, "/"), + Key: config.NewSecret([]byte(tt.key)), + Vec: config.NewSecret([]byte(iv)), + } + require.NoError(t, decrypter.Init()) + enc, err := hex.DecodeString(tt.encrypted) + require.NoError(t, err) + dec, err := decrypter.Decrypt(enc) + require.NoError(t, err) + require.Equal(t, expected, string(dec)) + }) + } +} + +func TestAESNoPadding(t *testing.T) { + keySource := hex.EncodeToString([]byte("0123456789abcdefghijklmnopqrstuvwxyz")) + expected := "my $ecret-Passw0rd" + iv := hex.EncodeToString([]byte("0123456789abcdef")) + tests := []struct { + cipher string + encrypted string + key string + }{ + { + cipher: "AES128/CBC", + encrypted: "9E36B490B0B1D6CE28550DF9DE65FC0013FF9F0939E24DA4A24324BDB5EABA04", + key: keySource[:32], + }, + { + cipher: "AES192/CBC", + encrypted: "D3A5A0004B6783351F89B00C1D4154EDF2321EDAD3111B5551C18836B9FCFD62", + key: keySource[:48], + }, + { + cipher: "AES256/CBC", + encrypted: "9751D7FB4B1497DEBC8A95C5D88097ECB1B8E63979E2D41E7ECD304D6B39B808", + key: keySource[:64], + }, + } + for _, tt := range tests { + t.Run(tt.cipher, func(t *testing.T) { + decrypter := AesEncryptor{ + Variant: strings.Split(tt.cipher, "/"), + Key: config.NewSecret([]byte(tt.key)), + Vec: config.NewSecret([]byte(iv)), + } + require.NoError(t, decrypter.Init()) + enc, err := hex.DecodeString(tt.encrypted) + require.NoError(t, err) + dec, err := decrypter.Decrypt(enc) + require.NoError(t, err) + require.Len(t, string(dec), 32) + require.Contains(t, string(dec), expected) + }) + } +} + +func TestAESKDF(t *testing.T) { + expected := "my $ecret-Passw0rd" + iv := hex.EncodeToString([]byte("asupersecretiv42")) + tests := []struct { + cipher string + password string + salt string + iterations int + encrypted string + }{ + { + cipher: "AES256/CBC/PKCS#5", + password: "a secret password", + salt: "somerandombytes", + iterations: 2000, + encrypted: "224b169206ce918f167ae0da18f4de45bede0d2c853d45e55f1422d1446037bf", + }, + } + for _, tt := range tests { + t.Run(tt.cipher, func(t *testing.T) { + decrypter := AesEncryptor{ + Variant: strings.Split(tt.cipher, "/"), + KDFConfig: KDFConfig{ + Algorithm: "PBKDF2-HMAC-SHA256", + Passwd: config.NewSecret([]byte(tt.password)), + Salt: config.NewSecret([]byte(tt.salt)), + Iterations: tt.iterations, + }, + Vec: config.NewSecret([]byte(iv)), + } + require.NoError(t, decrypter.Init()) + enc, err := hex.DecodeString(tt.encrypted) + require.NoError(t, err) + dec, err := decrypter.Decrypt(enc) + require.NoError(t, err) + require.Equal(t, expected, string(dec)) + }) + } +} + +func TestAESInitErrors(t *testing.T) { + tests := []struct { + name string + variant []string + key string + iv string + kdfcfg *KDFConfig + expected string + }{ + { + name: "no mode", + variant: []string{"AES128"}, + expected: "please specify cipher mode", + }, + { + name: "too many elements", + variant: []string{"AES128", "CBC", "PKCS#5", "superfluous"}, + expected: "too many variant elements", + }, + { + name: "no AES", + variant: []string{"rsa", "cbc"}, + expected: `requested AES but specified "rsa"`, + }, + { + name: "no cipher", + expected: "please specify cipher", + }, + { + name: "unsupported cipher", + variant: []string{"aes64", "cbc"}, + expected: "unsupported AES cipher", + }, + { + name: "unsupported mode", + variant: []string{"aes128", "foo"}, + expected: "unsupported cipher mode", + }, + { + name: "unsupported padding", + variant: []string{"aes128", "cbc", "bar"}, + expected: "unsupported padding", + }, + { + name: "missing key", + variant: []string{"aes128", "cbc", "none"}, + expected: "either key or password has to be specified", + }, + { + name: "wrong key length", + variant: []string{"aes256", "cbc"}, + key: "63238c069e3c5d6aaa20048c43ce4ed0", + expected: "key length (128 bit) does not match cipher (256 bit)", + }, + { + name: "invalid key", + variant: []string{"aes256", "cbc"}, + key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expected: "decoding key failed: encoding/hex: invalid byte: U+0078 'x'", + }, + { + name: "missing IV", + variant: []string{"aes128", "cbc"}, + key: "63238c069e3c5d6aaa20048c43ce4ed0", + expected: "'init_vector' has to be specified or derived from password", + }, + { + name: "invalid IV", + variant: []string{"aes128", "cbc"}, + key: "63238c069e3c5d6aaa20048c43ce4ed0", + iv: "abcd", + expected: "init vector size must match block size", + }, + { + name: "missing salt and iterations", + variant: []string{"aes128", "cbc", "none"}, + kdfcfg: &KDFConfig{ + Passwd: config.NewSecret([]byte("secret")), + }, + expected: "salt and iterations required for password-based-keys", + }, + { + name: "wrong keygen algorithm", + variant: []string{"aes128", "cbc", "none"}, + kdfcfg: &KDFConfig{ + Algorithm: "foo", + Passwd: config.NewSecret([]byte("secret")), + Salt: config.NewSecret([]byte("salt")), + Iterations: 2000, + }, + expected: "unknown key-derivation function", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NotEmpty(t, tt.expected) + + decrypter := AesEncryptor{ + Variant: tt.variant, + } + if tt.key != "" { + decrypter.Key = config.NewSecret([]byte(tt.key)) + } + if tt.iv != "" { + decrypter.Vec = config.NewSecret([]byte(tt.iv)) + } + if tt.kdfcfg != nil { + decrypter.KDFConfig = *tt.kdfcfg + } + require.ErrorContains(t, decrypter.Init(), tt.expected) + }) + } +} + +func TestAESDecryptError(t *testing.T) { + tests := []struct { + name string + encrypted string + messMode string + messKey string + messIV string + expected string + }{ + { + name: "wrong data length", + encrypted: "abcd", + expected: "invalid data size", + }, + { + name: "mode tampered", + encrypted: "9E36B490B0B1D6CE28550DF9DE65FC0013FF9F0939E24DA4A24324BDB5EABA04", + messMode: "tampered", + expected: `unsupported cipher mode "tampered"`, + }, + { + name: "invalid key", + encrypted: "9E36B490B0B1D6CE28550DF9DE65FC0013FF9F0939E24DA4A24324BDB5EABA04", + messKey: "tampered", + expected: "decoding key failed: encoding/hex: invalid byte: U+0074 't'", + }, + { + name: "wrong key length", + encrypted: "9E36B490B0B1D6CE28550DF9DE65FC0013FF9F0939E24DA4A24324BDB5EABA04", + messKey: "01234567", + expected: "creating AES cipher failed: crypto/aes: invalid key size", + }, + { + name: "invalid key", + encrypted: "9E36B490B0B1D6CE28550DF9DE65FC0013FF9F0939E24DA4A24324BDB5EABA04", + messIV: "tampered", + expected: "decoding init vector failed: encoding/hex: invalid byte: U+0074 't'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NotEmpty(t, tt.expected) + + decrypter := AesEncryptor{ + Variant: []string{"AES128", "CBC", "PKCS#5"}, + Key: config.NewSecret([]byte(hex.EncodeToString([]byte("0123456789abcdef")))), + Vec: config.NewSecret([]byte(hex.EncodeToString([]byte("0123456789abcdef")))), + } + require.NoError(t, decrypter.Init()) + enc, err := hex.DecodeString(tt.encrypted) + require.NoError(t, err) + + // Mess with the internal values for testing + if tt.messMode != "" { + decrypter.mode = tt.messMode + } + if tt.messKey != "" { + decrypter.Key = config.NewSecret([]byte(tt.messKey)) + } + if tt.messIV != "" { + decrypter.Vec = config.NewSecret([]byte(tt.messIV)) + } + _, err = decrypter.Decrypt(enc) + require.ErrorContains(t, err, tt.expected) + }) + } +} diff --git a/plugins/secretstores/http/decryption.go b/plugins/secretstores/http/decryption.go new file mode 100644 index 000000000..f78296b2d --- /dev/null +++ b/plugins/secretstores/http/decryption.go @@ -0,0 +1,47 @@ +package http + +import ( + "errors" + "fmt" + "strings" +) + +type Decrypter interface { + Decrypt(data []byte) ([]byte, error) +} + +type DecryptionConfig struct { + Cipher string `toml:"cipher"` + Aes AesEncryptor `toml:"aes"` +} + +func (c *DecryptionConfig) CreateDecrypter() (Decrypter, error) { + // For ciphers that allowing variants (e.g. AES256/CBC/PKCS#5Padding) + // can specify the variant using [/param 1>[/]...] + // where all parameters will be passed on to the decrypter. + parts := strings.Split(c.Cipher, "/") + switch strings.ToLower(parts[0]) { + case "", "none": + return nil, nil + case "aes", "aes128", "aes192", "aes256": + c.Aes.Variant = parts + if err := c.Aes.Init(); err != nil { + return nil, fmt.Errorf("init of AES decrypter failed: %w", err) + } + return &c.Aes, nil + } + return nil, fmt.Errorf("unknown cipher %q", c.Cipher) +} + +func PKCS5or7Trimming(in []byte) ([]byte, error) { + // 'count' number of bytes where padded to the end of the clear-text + // each containing the value of 'count' + if len(in) == 0 { + return nil, errors.New("empty value to trim") + } + count := int(in[len(in)-1]) + if len(in) < count { + return nil, fmt.Errorf("length %d shorter than trim value %d", len(in), count) + } + return in[:len(in)-count], nil +} diff --git a/plugins/secretstores/http/decryption_test.go b/plugins/secretstores/http/decryption_test.go new file mode 100644 index 000000000..94fcb6c2c --- /dev/null +++ b/plugins/secretstores/http/decryption_test.go @@ -0,0 +1,22 @@ +package http + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateAESFail(t *testing.T) { + cfg := DecryptionConfig{Cipher: "aes128/CBC/PKCS#5/garbage"} + decrypt, err := cfg.CreateDecrypter() + require.ErrorContains(t, err, "init of AES decrypter failed") + require.Nil(t, decrypt) +} + +func TestTrimPKCSFail(t *testing.T) { + _, err := PKCS5or7Trimming([]byte{}) + require.ErrorContains(t, err, "empty value to trim") + + _, err = PKCS5or7Trimming([]byte{0x00, 0x05}) + require.ErrorContains(t, err, "length 2 shorter than trim value 5") +} diff --git a/plugins/secretstores/http/http.go b/plugins/secretstores/http/http.go new file mode 100644 index 000000000..9839c125e --- /dev/null +++ b/plugins/secretstores/http/http.go @@ -0,0 +1,232 @@ +//go:generate ../../../tools/readme_config_includer/generator +package http + +import ( + "context" + _ "embed" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/blues/jsonata-go" + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/config" + chttp "github.com/influxdata/telegraf/plugins/common/http" + "github.com/influxdata/telegraf/plugins/secretstores" +) + +//go:embed sample.conf +var sampleConfig string + +type HTTP struct { + URL string `toml:"url"` + Headers map[string]string `toml:"headers"` + Username config.Secret `toml:"username"` + Password config.Secret `toml:"password"` + Token config.Secret `toml:"token"` + SuccessStatusCodes []int `toml:"success_status_codes"` + Transformation string `toml:"transformation"` + Log telegraf.Logger `toml:"-"` + chttp.HTTPClientConfig + DecryptionConfig + + client *http.Client + transformer *jsonata.Expr + cache map[string]string + decrypter Decrypter +} + +func (h *HTTP) SampleConfig() string { + return sampleConfig +} + +func (h *HTTP) Init() error { + ctx := context.Background() + client, err := h.HTTPClientConfig.CreateClient(ctx, h.Log) + if err != nil { + return err + } + h.client = client + + // Set default as [200] + if len(h.SuccessStatusCodes) == 0 { + h.SuccessStatusCodes = []int{200} + } + + // Setup the data transformer if any + if h.Transformation != "" { + e, err := jsonata.Compile(h.Transformation) + if err != nil { + return fmt.Errorf("setting up data transformation failed: %w", err) + } + h.transformer = e + } + + // Setup the decryption infrastructure + h.decrypter, err = h.DecryptionConfig.CreateDecrypter() + if err != nil { + return fmt.Errorf("creating decryptor failed: %w", err) + } + + return nil +} + +// Get searches for the given key and return the secret +func (h *HTTP) Get(key string) ([]byte, error) { + v, found := h.cache[key] + if !found { + return nil, errors.New("not found") + } + + if h.decrypter != nil { + // We got binary data delivered in a string, so try to + // decode it assuming base64-encoding. + buf, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return nil, fmt.Errorf("base64 decoding failed: %w", err) + } + return h.decrypter.Decrypt(buf) + } + + return []byte(v), nil +} + +// Set sets the given secret for the given key +func (h *HTTP) Set(_, _ string) error { + return errors.New("setting secrets not supported") +} + +// List lists all known secret keys +func (h *HTTP) List() ([]string, error) { + keys := make([]string, 0, len(h.cache)) + for k := range h.cache { + keys = append(keys, k) + } + return keys, nil +} + +// GetResolver returns a function to resolve the given key. +func (h *HTTP) GetResolver(key string) (telegraf.ResolveFunc, error) { + // Download and parse the credentials + if err := h.download(); err != nil { + return nil, err + } + + resolver := func() ([]byte, bool, error) { + s, err := h.Get(key) + return s, false, err + } + return resolver, nil +} + +func (h *HTTP) download() error { + // Get the raw data form the URL + data, err := h.query() + if err != nil { + return fmt.Errorf("reading body failed: %w", err) + } + + // Transform the data to the expected form if given + if h.transformer != nil { + out, err := h.transformer.EvalBytes(data) + if err != nil { + return fmt.Errorf("transforming data failed: %w", err) + } + data = out + } + + // Extract the data from the resulting data + if err := json.Unmarshal(data, &h.cache); err != nil { + var terr *json.UnmarshalTypeError + if errors.As(err, &terr) { + return fmt.Errorf("%w; maybe missing or wrong data transformation", err) + } + return err + } + + return nil +} + +func (h *HTTP) query() ([]byte, error) { + request, err := http.NewRequest(http.MethodGet, h.URL, nil) + if err != nil { + return nil, fmt.Errorf("creating request failed: %w", err) + } + + for k, v := range h.Headers { + if strings.ToLower(k) == "host" { + request.Host = v + } else { + request.Header.Add(k, v) + } + } + + if err := h.setRequestAuth(request); err != nil { + return nil, err + } + + resp, err := h.client.Do(request) + if err != nil { + return nil, fmt.Errorf("executing request failed: %w", err) + } + defer resp.Body.Close() + + // Try to wipe the bearer token if any + request.SetBasicAuth("---", "---") + request.Header.Set("Authorization", "---") + + responseHasSuccessCode := false + for _, statusCode := range h.SuccessStatusCodes { + if resp.StatusCode == statusCode { + responseHasSuccessCode = true + break + } + } + + if !responseHasSuccessCode { + msg := "received status code %d (%s), expected any value out of %v" + return nil, fmt.Errorf(msg, resp.StatusCode, http.StatusText(resp.StatusCode), h.SuccessStatusCodes) + } + + return io.ReadAll(resp.Body) +} + +func (h *HTTP) setRequestAuth(request *http.Request) error { + if !h.Username.Empty() && !h.Password.Empty() { + username, err := h.Username.Get() + if err != nil { + return fmt.Errorf("getting username failed: %w", err) + } + password, err := h.Password.Get() + if err != nil { + config.ReleaseSecret(username) + return fmt.Errorf("getting password failed: %w", err) + } + request.SetBasicAuth(string(username), string(password)) + config.ReleaseSecret(username) + config.ReleaseSecret(password) + } + + if !h.Token.Empty() { + token, err := h.Token.Get() + if err != nil { + return fmt.Errorf("getting token failed: %w", err) + } + bearer := "Bearer " + strings.TrimSpace(string(token)) + config.ReleaseSecret(token) + request.Header.Set("Authorization", bearer) + } + + return nil +} + +// Register the secret-store on load. +func init() { + secretstores.Add("http", func(id string) telegraf.SecretStore { + return &HTTP{} + }) +} diff --git a/plugins/secretstores/http/http_test.go b/plugins/secretstores/http/http_test.go new file mode 100644 index 000000000..9680c3bfa --- /dev/null +++ b/plugins/secretstores/http/http_test.go @@ -0,0 +1,397 @@ +package http + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/plugins/secretstores" + "github.com/influxdata/telegraf/testutil" +) + +func TestCases(t *testing.T) { + // Get all directories in testcases + folders, err := os.ReadDir("testcases") + require.NoError(t, err) + + // Make sure tests contains data + require.NotEmpty(t, folders) + + // Set up for file inputs + secretstores.Add("http", func(id string) telegraf.SecretStore { + return &HTTP{Log: testutil.Logger{}} + }) + + for _, f := range folders { + // Only handle folders + if !f.IsDir() { + continue + } + + fname := f.Name() + t.Run(fname, func(t *testing.T) { + testdataPath := filepath.Join("testcases", fname) + configFilename := filepath.Join(testdataPath, "telegraf.conf") + inputFilename := filepath.Join(testdataPath, "secrets.json") + expectedFilename := filepath.Join(testdataPath, "expected.json") + + // Read the input data + input, err := os.ReadFile(inputFilename) + require.NoError(t, err) + + // Read the expected output data + buf, err := os.ReadFile(expectedFilename) + require.NoError(t, err) + var expected map[string]string + require.NoError(t, json.Unmarshal(buf, &expected)) + + // Configure the plugin + cfg := config.NewConfig() + require.NoError(t, cfg.LoadConfig(configFilename)) + require.NotEmpty(t, cfg.SecretStores) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/secrets" { + _, _ = w.Write(input) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + us, err := url.Parse(server.URL) + require.NoError(t, err) + + var id string + var plugin telegraf.SecretStore + actual := make(map[string]string, len(expected)) + for id, plugin = range cfg.SecretStores { + // Setup dummy server and redirect the plugin's URL to that dummy + httpPlugin, ok := plugin.(*HTTP) + require.True(t, ok) + + u, err := url.Parse(httpPlugin.URL) + require.NoError(t, err) + u.Host = us.Host + httpPlugin.URL = u.String() + require.NoError(t, httpPlugin.download()) + + // Retrieve the secrets from the plugin + keys, err := plugin.List() + require.NoError(t, err) + + for _, k := range keys { + v, err := plugin.Get(k) + require.NoError(t, err) + actual[id+"."+k] = string(v) + } + } + require.EqualValues(t, expected, actual) + }) + } +} + +func TestSampleConfig(t *testing.T) { + plugin := &HTTP{} + require.NotEmpty(t, plugin.SampleConfig()) +} + +func TestInit(t *testing.T) { + plugin := &HTTP{ + DecryptionConfig: DecryptionConfig{ + Cipher: "AES128/CBC/PKCS#5", + Aes: AesEncryptor{ + Key: config.NewSecret([]byte("7465737474657374657374746573740a")), + Vec: config.NewSecret([]byte("7465737474657374657374746573740a")), + }, + }, + } + require.NoError(t, plugin.Init()) +} + +func TestInitErrors(t *testing.T) { + plugin := &HTTP{Transformation: "{some: malformed"} + require.ErrorContains(t, plugin.Init(), "setting up data transformation failed") + + plugin = &HTTP{DecryptionConfig: DecryptionConfig{Cipher: "non-existing/CBC/lala"}} + require.ErrorContains(t, plugin.Init(), "creating decryptor failed: unknown cipher") +} + +func TestSetNotSupported(t *testing.T) { + plugin := &HTTP{} + require.NoError(t, plugin.Init()) + + require.ErrorContains(t, plugin.Set("key", "value"), "setting secrets not supported") +} + +func TestGetErrors(t *testing.T) { + plugin := &HTTP{ + DecryptionConfig: DecryptionConfig{ + Cipher: "AES256/CBC/PKCS#5", + Aes: AesEncryptor{ + Key: config.NewSecret([]byte("63238c069e3c5d6aaa20048c43ce4ed0a910eef95f22f55bacdddacafa06b656")), + Vec: config.NewSecret([]byte("61737570657273656372657469763432")), + }, + }, + } + require.NoError(t, plugin.Init()) + + _, err := plugin.Get("OMG") + require.ErrorContains(t, err, "not found") + + plugin.cache = map[string]string{"test": "aedMZXaLR246OHHjVtJKXQ=X"} + _, err = plugin.Get("test") + require.ErrorContains(t, err, "base64 decoding failed") +} + +func TestResolver(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"test": "aedMZXaLR246OHHjVtJKXQ=="}`)) + })) + defer server.Close() + + plugin := &HTTP{ + URL: server.URL, + DecryptionConfig: DecryptionConfig{ + Cipher: "AES256/CBC/PKCS#5", + Aes: AesEncryptor{ + Key: config.NewSecret([]byte("63238c069e3c5d6aaa20048c43ce4ed0a910eef95f22f55bacdddacafa06b656")), + Vec: config.NewSecret([]byte("61737570657273656372657469763432")), + }, + }, + } + plugin.Timeout = config.Duration(200 * time.Millisecond) + require.NoError(t, plugin.Init()) + + resolver, err := plugin.GetResolver("test") + require.NoError(t, err) + + s, _, err := resolver() + require.NoError(t, err) + require.Equal(t, "password-B", string(s)) +} + +func TestGetResolverErrors(t *testing.T) { + dummy, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer dummy.Close() + + plugin := &HTTP{ + URL: "http://" + dummy.Addr().String(), + } + plugin.Timeout = config.Duration(200 * time.Millisecond) + require.NoError(t, plugin.Init()) + + _, err = plugin.GetResolver("test") + require.ErrorContains(t, err, "context deadline exceeded") + dummy.Close() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"test": "aedMZXaLR246OHHjVtJKXQ=="}]`)) + })) + defer server.Close() + + plugin = &HTTP{ + URL: server.URL, + DecryptionConfig: DecryptionConfig{ + Cipher: "AES256/CBC/PKCS#5", + Aes: AesEncryptor{ + Key: config.NewSecret([]byte("63238c069e3c5d6aaa20048c43ce4ed0a910eef95f22f55bacdddacafa06b656")), + Vec: config.NewSecret([]byte("61737570657273656372657469763432")), + }, + }, + } + plugin.Timeout = config.Duration(200 * time.Millisecond) + require.NoError(t, plugin.Init()) + + _, err = plugin.GetResolver("test") + require.ErrorContains(t, err, "maybe missing or wrong data transformation") + + plugin.Transformation = "{awe:skds}" + require.NoError(t, plugin.Init()) + + _, err = plugin.GetResolver("test") + require.ErrorContains(t, err, "transforming data failed") +} + +func TestInvalidServerResponse(t *testing.T) { + dummy, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer dummy.Close() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[somerandomebytes`)) + })) + defer server.Close() + + plugin := &HTTP{ + URL: server.URL, + DecryptionConfig: DecryptionConfig{ + Cipher: "AES256/CBC/PKCS#5", + Aes: AesEncryptor{ + Key: config.NewSecret([]byte("63238c069e3c5d6aaa20048c43ce4ed0a910eef95f22f55bacdddacafa06b656")), + Vec: config.NewSecret([]byte("61737570657273656372657469763432")), + }, + }, + } + plugin.Timeout = config.Duration(200 * time.Millisecond) + require.NoError(t, plugin.Init()) + + _, err = plugin.GetResolver("test") + require.Error(t, err) + var expectedErr *json.SyntaxError + require.ErrorAs(t, err, &expectedErr) +} + +func TestAdditionalHeaders(t *testing.T) { + dummy, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer dummy.Close() + + var actual http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + actual = r.Header.Clone() + if r.Host != "" { + actual.Add("host", r.Host) + } + _, _ = w.Write([]byte(`{"test": "aedMZXaLR246OHHjVtJKXQ=="}`)) + })) + defer server.Close() + + plugin := &HTTP{ + URL: server.URL, + Headers: map[string]string{ + "host": "a.host.com", + "foo": "bar", + }, + DecryptionConfig: DecryptionConfig{ + Cipher: "AES256/CBC/PKCS#5", + Aes: AesEncryptor{ + Key: config.NewSecret([]byte("63238c069e3c5d6aaa20048c43ce4ed0a910eef95f22f55bacdddacafa06b656")), + Vec: config.NewSecret([]byte("61737570657273656372657469763432")), + }, + }, + } + plugin.Timeout = config.Duration(200 * time.Millisecond) + require.NoError(t, plugin.Init()) + + require.NoError(t, plugin.download()) + + secret, err := plugin.Get("test") + require.NoError(t, err) + require.Equal(t, "password-B", string(secret)) + + for k, v := range plugin.Headers { + av := actual.Get(k) + require.NotEmptyf(t, av, "header %q not found", k) + require.Equal(t, v, av, "mismatch for header %q", k) + } +} + +func TestServerReturnCodes(t *testing.T) { + dummy, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer dummy.Close() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/", "/200": + _, _ = w.Write([]byte(`{}`)) + case "/201": + w.WriteHeader(201) + case "/300": + w.WriteHeader(300) + _, _ = w.Write([]byte(`{}`)) + case "/401": + w.WriteHeader(401) + default: + w.WriteHeader(404) + } + })) + defer server.Close() + + plugin := &HTTP{ + URL: server.URL, + SuccessStatusCodes: []int{200, 300}, + } + plugin.Timeout = config.Duration(200 * time.Millisecond) + require.NoError(t, plugin.Init()) + + // 200 and 300 should not return an error + require.NoError(t, plugin.download()) + plugin.URL = server.URL + "/200" + require.NoError(t, plugin.download()) + plugin.URL = server.URL + "/300" + require.NoError(t, plugin.download()) + + // other error codes should cause errors + plugin.URL = server.URL + "/201" + require.ErrorContains(t, plugin.download(), "received status code 201") + plugin.URL = server.URL + "/401" + require.ErrorContains(t, plugin.download(), "received status code 401") + plugin.URL = server.URL + "/somewhere" + require.ErrorContains(t, plugin.download(), "received status code 404") +} + +func TestAuthenticationBasic(t *testing.T) { + dummy, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer dummy.Close() + + var header http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header = r.Header + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + plugin := &HTTP{ + URL: server.URL, + Username: config.NewSecret([]byte("myuser")), + Password: config.NewSecret([]byte("mypass")), + SuccessStatusCodes: []int{200, 300}, + } + plugin.Timeout = config.Duration(200 * time.Millisecond) + require.NoError(t, plugin.Init()) + require.NoError(t, plugin.download()) + + auth := header.Get("Authorization") + require.NotEmpty(t, auth) + require.Equal(t, "Basic bXl1c2VyOm15cGFzcw==", auth) +} + +func TestAuthenticationToken(t *testing.T) { + dummy, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer dummy.Close() + + var header http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header = r.Header + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + token := "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJUaWdlciIsImlhdCI6M..." + plugin := &HTTP{ + URL: server.URL, + Token: config.NewSecret([]byte(token)), + SuccessStatusCodes: []int{200, 300}, + } + plugin.Timeout = config.Duration(200 * time.Millisecond) + require.NoError(t, plugin.Init()) + require.NoError(t, plugin.download()) + + auth := header.Get("Authorization") + require.NotEmpty(t, auth) + require.Equal(t, "Bearer "+token, auth) +} diff --git a/plugins/secretstores/http/key_derivation.go b/plugins/secretstores/http/key_derivation.go new file mode 100644 index 000000000..50afef2c0 --- /dev/null +++ b/plugins/secretstores/http/key_derivation.go @@ -0,0 +1,53 @@ +package http + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "hash" + "strings" + + "golang.org/x/crypto/pbkdf2" + + "github.com/influxdata/telegraf/config" +) + +type KDFConfig struct { + Algorithm string `toml:"kdf_algorithm"` + Passwd config.Secret `toml:"password"` + Salt config.Secret `toml:"salt"` + Iterations int `toml:"iterations"` +} + +type hashFunc func() hash.Hash + +func (k *KDFConfig) NewKey(keylen int) (key, iv config.Secret, err error) { + switch strings.ToUpper(k.Algorithm) { + case "", "PBKDF2-HMAC-SHA256": + return k.generatePBKDF2HMAC(sha256.New, keylen) + } + return config.Secret{}, config.Secret{}, fmt.Errorf("unknown key-derivation function %q", k.Algorithm) +} + +func (k *KDFConfig) generatePBKDF2HMAC(hf hashFunc, keylen int) (config.Secret, config.Secret, error) { + if k.Iterations == 0 { + return config.Secret{}, config.Secret{}, errors.New("'iteration value not set") + } + + passwd, err := k.Passwd.Get() + if err != nil { + return config.Secret{}, config.Secret{}, fmt.Errorf("getting password failed: %w", err) + } + defer config.ReleaseSecret(passwd) + + salt, err := k.Salt.Get() + if err != nil { + return config.Secret{}, config.Secret{}, fmt.Errorf("getting salt failed: %w", err) + } + defer config.ReleaseSecret(salt) + + rawkey := pbkdf2.Key(passwd, salt, k.Iterations, keylen, hf) + key := config.NewSecret([]byte(hex.EncodeToString(rawkey))) + return key, config.Secret{}, nil +} diff --git a/plugins/secretstores/http/key_derivation_test.go b/plugins/secretstores/http/key_derivation_test.go new file mode 100644 index 000000000..7abe6145a --- /dev/null +++ b/plugins/secretstores/http/key_derivation_test.go @@ -0,0 +1,88 @@ +package http + +import ( + "testing" + + "github.com/influxdata/telegraf/config" + "github.com/stretchr/testify/require" +) + +func TestKDF(t *testing.T) { + tests := []struct { + algorithm string + password string + salt string + iterations int + length int + key string + iv string + }{ + { + algorithm: "PBKDF2-HMAC-SHA256", + password: "a secret password", + salt: "somerandombytes", + iterations: 2000, + length: 16, + key: "f49817e5faa63d9bb631b143c7d11ff7", + }, + } + for _, tt := range tests { + t.Run(tt.algorithm, func(t *testing.T) { + cfg := KDFConfig{ + Algorithm: tt.algorithm, + Passwd: config.NewSecret([]byte(tt.password)), + Salt: config.NewSecret([]byte(tt.salt)), + Iterations: tt.iterations, + } + skey, siv, err := cfg.NewKey(16) + require.NoError(t, err) + require.NotNil(t, skey) + require.NotNil(t, siv) + + key, err := skey.Get() + require.NoError(t, err) + require.Equal(t, tt.key, string(key)) + + if tt.iv != "" { + iv, err := siv.Get() + require.NoError(t, err) + require.Equal(t, tt.iv, string(iv)) + } else { + require.True(t, siv.Empty()) + } + }) + } +} + +func TestKDFErrors(t *testing.T) { + tests := []struct { + name string + password string + salt string + iterations int + length int + expected string + }{ + { + name: "missing iterations", + password: "a secret password", + salt: "somerandombytes", + length: 16, + expected: "iteration value not set", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NotEmpty(t, tt.expected) + + cfg := KDFConfig{ + Algorithm: "PBKDF2-HMAC-SHA256", + Passwd: config.NewSecret([]byte(tt.password)), + Salt: config.NewSecret([]byte(tt.salt)), + Iterations: tt.iterations, + } + _, _, err := cfg.NewKey(16) + require.ErrorContains(t, err, tt.expected) + }) + } +} diff --git a/plugins/secretstores/http/sample.conf b/plugins/secretstores/http/sample.conf new file mode 100644 index 000000000..ac9cc6489 --- /dev/null +++ b/plugins/secretstores/http/sample.conf @@ -0,0 +1,80 @@ +# Read secrets from a HTTP endpoint +[[secretstores.http]] + ## URLs from which to read the secrets + url = "http://localhost/secrets" + + ## Optional HTTP headers + # headers = {"X-Special-Header" = "Special-Value"} + + ## Optional Token for Bearer Authentication via + ## "Authorization: Bearer " header + # token = "your-token" + + ## Optional Credentials for HTTP Basic Authentication + # username = "username" + # password = "pa$$word" + + ## OAuth2 Client Credentials. The options 'client_id', 'client_secret', and 'token_url' are required to use OAuth2. + # client_id = "clientid" + # client_secret = "secret" + # token_url = "https://indentityprovider/oauth2/v1/token" + # scopes = ["urn:opc:idm:__myscopes__"] + + ## HTTP Proxy support + # use_system_proxy = false + # http_proxy_url = "" + + ## Optional TLS Config + # tls_ca = "/etc/telegraf/ca.pem" + # tls_cert = "/etc/telegraf/cert.pem" + # tls_key = "/etc/telegraf/key.pem" + ## Minimal TLS version to accept by the client + # tls_min_version = "TLS12" + ## Use TLS but skip chain & host verification + # insecure_skip_verify = false + + ## Optional Cookie authentication + # cookie_auth_url = "https://localhost/authMe" + # cookie_auth_method = "POST" + # cookie_auth_username = "username" + # cookie_auth_password = "pa$$word" + # cookie_auth_headers = { Content-Type = "application/json", X-MY-HEADER = "hello" } + # cookie_auth_body = '{"username": "user", "password": "pa$$word", "authenticate": "me"}' + ## When unset or set to zero the authentication will only happen once + ## and will never renew the cookie. Set to a suitable duration if you + ## require cookie renewal! + # cookie_auth_renewal = "0s" + + ## Amount of time allowed to complete the HTTP request + # timeout = "5s" + + ## List of success status codes + # success_status_codes = [200] + + ## JSONata expression to transform the server response into a + ## { "secret name": "secret value", ... } + ## form. See https://jsonata.org for more information and a playground. + # transformation = '' + + ## Cipher used to decrypt the secrets. + ## In case your secrets are transmitted in an encrypted form, you need + ## to specify the cipher used and provide the corresponding configuration. + ## Please refer to https://github.com/influxdata/telegraf/blob/master/plugins/secretstores/http/README.md + ## for supported values. + # cipher = "none" + + ## AES cipher parameters + # [secretstores.http.aes] + # ## Key (hex-encoded) and initialization-vector (IV) for the decryption. + # ## In case the key (and IV) is derived from a password, the values can + # ## be omitted. + # key = "" + # init_vector = "" + # + # ## Parameters for password-based-key derivation. + # ## These parameters must match the encryption side to derive the same + # ## key on both sides! + # # kdf_algorithm = "PBKDF2-HMAC-SHA256" + # # password = "" + # # salt = "" + # # iterations = 0 diff --git a/plugins/secretstores/http/testcases/aes-cbc-kdf/expected.json b/plugins/secretstores/http/testcases/aes-cbc-kdf/expected.json new file mode 100644 index 000000000..a69fbded5 --- /dev/null +++ b/plugins/secretstores/http/testcases/aes-cbc-kdf/expected.json @@ -0,0 +1,6 @@ +{ + "test.user_1": "password A", + "test.user 2": "password-B", + "test.user@company.com": "my$3cR3T", + "test.user %with% $trAng€ characters": "" +} \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/aes-cbc-kdf/secrets.json b/plugins/secretstores/http/testcases/aes-cbc-kdf/secrets.json new file mode 100644 index 000000000..088e37bd9 --- /dev/null +++ b/plugins/secretstores/http/testcases/aes-cbc-kdf/secrets.json @@ -0,0 +1,6 @@ +{ + "user_1": "3y1Za18sLLNIIHw1fv2Olg==", + "user 2": "aedMZXaLR246OHHjVtJKXQ==", + "user@company.com": "rcFobNmuaaboSPZY5nKjzQ==", + "user %with% $trAng\u20ac characters": "1HxPInsJomaWAE19VBisyw==" +} \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/aes-cbc-kdf/telegraf.conf b/plugins/secretstores/http/testcases/aes-cbc-kdf/telegraf.conf new file mode 100644 index 000000000..1d69c3d99 --- /dev/null +++ b/plugins/secretstores/http/testcases/aes-cbc-kdf/telegraf.conf @@ -0,0 +1,12 @@ +[[secretstores.http]] + id = "test" + url = "http://127.0.0.1/secrets" + + cipher = "AES256/CBC/PKCS#5" + + [secretstores.http.aes] + init_vector = "61737570657273656372657469763432" + kdf_algorithm = "PBKDF2-HMAC-SHA256" + password = "a secret key" + salt = "somerandombytes" + iterations = 2000 diff --git a/plugins/secretstores/http/testcases/aes-cbc-key/expected.json b/plugins/secretstores/http/testcases/aes-cbc-key/expected.json new file mode 100644 index 000000000..a69fbded5 --- /dev/null +++ b/plugins/secretstores/http/testcases/aes-cbc-key/expected.json @@ -0,0 +1,6 @@ +{ + "test.user_1": "password A", + "test.user 2": "password-B", + "test.user@company.com": "my$3cR3T", + "test.user %with% $trAng€ characters": "" +} \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/aes-cbc-key/secrets.json b/plugins/secretstores/http/testcases/aes-cbc-key/secrets.json new file mode 100644 index 000000000..088e37bd9 --- /dev/null +++ b/plugins/secretstores/http/testcases/aes-cbc-key/secrets.json @@ -0,0 +1,6 @@ +{ + "user_1": "3y1Za18sLLNIIHw1fv2Olg==", + "user 2": "aedMZXaLR246OHHjVtJKXQ==", + "user@company.com": "rcFobNmuaaboSPZY5nKjzQ==", + "user %with% $trAng\u20ac characters": "1HxPInsJomaWAE19VBisyw==" +} \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/aes-cbc-key/telegraf.conf b/plugins/secretstores/http/testcases/aes-cbc-key/telegraf.conf new file mode 100644 index 000000000..59cee7fd1 --- /dev/null +++ b/plugins/secretstores/http/testcases/aes-cbc-key/telegraf.conf @@ -0,0 +1,9 @@ +[[secretstores.http]] + id = "test" + url = "http://127.0.0.1/secrets" + + cipher = "AES256/CBC/PKCS#5" + + [secretstores.http.aes] + key = "63238c069e3c5d6aaa20048c43ce4ed0a910eef95f22f55bacdddacafa06b656" + init_vector = "61737570657273656372657469763432" diff --git a/plugins/secretstores/http/testcases/mixed/expected.json b/plugins/secretstores/http/testcases/mixed/expected.json new file mode 100644 index 000000000..317a89836 --- /dev/null +++ b/plugins/secretstores/http/testcases/mixed/expected.json @@ -0,0 +1,10 @@ +{ + "user.user_a": "user_1", + "passwd.password_a": "password A", + "user.user_b": "user 2", + "passwd.password_b": "password-B", + "user.user_c": "user@company.com", + "passwd.password_c": "my$3cR3T", + "user.user_d": "user %with% $trAng€ characters", + "passwd.password_d": "" + } \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/mixed/secrets.json b/plugins/secretstores/http/testcases/mixed/secrets.json new file mode 100644 index 000000000..60af79c9a --- /dev/null +++ b/plugins/secretstores/http/testcases/mixed/secrets.json @@ -0,0 +1,30 @@ +[ + { + "userName": "user_a", + "userValue": "user_1", + "secretName": "password_a", + "secretValue": "3y1Za18sLLNIIHw1fv2Olg==", + "description": "server credentials for A" + }, + { + "userName": "user_b", + "userValue": "user 2", + "secretName": "password_b", + "secretValue": "aedMZXaLR246OHHjVtJKXQ==", + "description": "server credentials for B" + }, + { + "userName": "user_c", + "userValue": "user@company.com", + "secretName": "password_c", + "secretValue": "rcFobNmuaaboSPZY5nKjzQ==", + "description": "server credentials for C" + }, + { + "userName": "user_d", + "userValue": "user %with% $trAng€ characters", + "secretName": "password_d", + "secretValue": "1HxPInsJomaWAE19VBisyw==", + "description": "server credentials for D" + } +] diff --git a/plugins/secretstores/http/testcases/mixed/telegraf.conf b/plugins/secretstores/http/testcases/mixed/telegraf.conf new file mode 100644 index 000000000..536a28198 --- /dev/null +++ b/plugins/secretstores/http/testcases/mixed/telegraf.conf @@ -0,0 +1,15 @@ +[[secretstores.http]] + id = "user" + url = "http://127.0.0.1/secrets" + transformation = '{userName: userValue}' + +[[secretstores.http]] + id = "passwd" + url = "http://127.0.0.1/secrets" + transformation = '{secretName: secretValue}' + + cipher = "AES256/CBC/PKCS#5" + + [secretstores.http.aes] + key = "63238c069e3c5d6aaa20048c43ce4ed0a910eef95f22f55bacdddacafa06b656" + init_vector = "61737570657273656372657469763432" diff --git a/plugins/secretstores/http/testcases/plain-list-complex/expected.json b/plugins/secretstores/http/testcases/plain-list-complex/expected.json new file mode 100644 index 000000000..893bc7e94 --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-list-complex/expected.json @@ -0,0 +1,10 @@ +{ + "test.user_a": "user_1", + "test.password_a": "password A", + "test.user_b": "user 2", + "test.password_b": "password-B", + "test.user_c": "user@company.com", + "test.password_c": "my$3cR3T", + "test.user_d": "user %with% $trAng€ characters", + "test.password_d": "" + } \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/plain-list-complex/secrets.json b/plugins/secretstores/http/testcases/plain-list-complex/secrets.json new file mode 100644 index 000000000..fd813ab6d --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-list-complex/secrets.json @@ -0,0 +1,30 @@ +[ + { + "userName": "user_a", + "userValue": "user_1", + "secretName": "password_a", + "secretValue": "password A", + "description": "server credentials for A" + }, + { + "userName": "user_b", + "userValue": "user 2", + "secretName": "password_b", + "secretValue": "password-B", + "description": "server credentials for B" + }, + { + "userName": "user_c", + "userValue": "user@company.com", + "secretName": "password_c", + "secretValue": "my$3cR3T", + "description": "server credentials for C" + }, + { + "userName": "user_d", + "userValue": "user %with% $trAng€ characters", + "secretName": "password_d", + "secretValue": "", + "description": "server credentials for D" + } +] \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/plain-list-complex/telegraf.conf b/plugins/secretstores/http/testcases/plain-list-complex/telegraf.conf new file mode 100644 index 000000000..c7e38f416 --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-list-complex/telegraf.conf @@ -0,0 +1,4 @@ +[[secretstores.http]] + id = "test" + url = "http://127.0.0.1/secrets" + transformation = '{userName: userValue, secretName: secretValue}' diff --git a/plugins/secretstores/http/testcases/plain-list-simple/expected.json b/plugins/secretstores/http/testcases/plain-list-simple/expected.json new file mode 100644 index 000000000..a69fbded5 --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-list-simple/expected.json @@ -0,0 +1,6 @@ +{ + "test.user_1": "password A", + "test.user 2": "password-B", + "test.user@company.com": "my$3cR3T", + "test.user %with% $trAng€ characters": "" +} \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/plain-list-simple/secrets.json b/plugins/secretstores/http/testcases/plain-list-simple/secrets.json new file mode 100644 index 000000000..7e145d5c7 --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-list-simple/secrets.json @@ -0,0 +1,18 @@ +[ + { + "user": "user_1", + "secret": "password A" + }, + { + "user": "user 2", + "secret": "password-B" + }, + { + "user": "user@company.com", + "secret": "my$3cR3T" + }, + { + "user": "user %with% $trAng€ characters", + "secret": "" + } +] \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/plain-list-simple/telegraf.conf b/plugins/secretstores/http/testcases/plain-list-simple/telegraf.conf new file mode 100644 index 000000000..0379983fb --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-list-simple/telegraf.conf @@ -0,0 +1,4 @@ +[[secretstores.http]] + id = "test" + url = "http://127.0.0.1/secrets" + transformation = '{user: secret}' diff --git a/plugins/secretstores/http/testcases/plain-no-transform/expected.json b/plugins/secretstores/http/testcases/plain-no-transform/expected.json new file mode 100644 index 000000000..a69fbded5 --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-no-transform/expected.json @@ -0,0 +1,6 @@ +{ + "test.user_1": "password A", + "test.user 2": "password-B", + "test.user@company.com": "my$3cR3T", + "test.user %with% $trAng€ characters": "" +} \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/plain-no-transform/secrets.json b/plugins/secretstores/http/testcases/plain-no-transform/secrets.json new file mode 100644 index 000000000..11e80a0d4 --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-no-transform/secrets.json @@ -0,0 +1,6 @@ +{ + "user_1": "password A", + "user 2": "password-B", + "user@company.com": "my$3cR3T", + "user %with% $trAng€ characters": "" +} \ No newline at end of file diff --git a/plugins/secretstores/http/testcases/plain-no-transform/telegraf.conf b/plugins/secretstores/http/testcases/plain-no-transform/telegraf.conf new file mode 100644 index 000000000..53353245b --- /dev/null +++ b/plugins/secretstores/http/testcases/plain-no-transform/telegraf.conf @@ -0,0 +1,3 @@ +[[secretstores.http]] + id = "test" + url = "http://127.0.0.1/secrets" diff --git a/tools/readme_config_includer/generator.go b/tools/readme_config_includer/generator.go index 446d654ce..4b8a90120 100644 --- a/tools/readme_config_includer/generator.go +++ b/tools/readme_config_includer/generator.go @@ -105,14 +105,7 @@ func insertIncludes(buf *bytes.Buffer, b *includeBlock) error { } } // Make sure we add a trailing newline - if !bytes.HasSuffix(buf.Bytes(), []byte("\n")) { - if _, err := buf.Write([]byte("\n")); err != nil { - return errors.New("adding newline failed") - } - } - - // Insert newlines before and after - if b.Newlines { + if !bytes.HasSuffix(buf.Bytes(), []byte("\n")) || b.Newlines { if _, err := buf.Write([]byte("\n")); err != nil { return errors.New("adding newline failed") }