1
0
Fork 0

Adding upstream version 1.34.4.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-24 07:26:29 +02:00
parent e393c3af3f
commit 4978089aab
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
4963 changed files with 677545 additions and 0 deletions

View file

@ -0,0 +1,11 @@
# Secret-Stores
This folder contains the plugins for the secret-store functionality:
* docker: Docker Secrets within containers
* http: Query secrets from an HTTP endpoint
* jose: Javascript Object Signing and Encryption
* os: Native tooling provided on Linux, MacOS, or Windows.
* systemd: Secret-store to access systemd secrets
See each plugin's README for additional details.

View file

@ -0,0 +1 @@
package all

View file

@ -0,0 +1,5 @@
//go:build !custom || secretstores || secretstores.docker
package all
import _ "github.com/influxdata/telegraf/plugins/secretstores/docker" // register plugin

View file

@ -0,0 +1,5 @@
//go:build !custom || secretstores || secretstores.http
package all
import _ "github.com/influxdata/telegraf/plugins/secretstores/http" // register plugin

View file

@ -0,0 +1,5 @@
//go:build !custom || secretstores || secretstores.jose
package all
import _ "github.com/influxdata/telegraf/plugins/secretstores/jose" // register plugin

View file

@ -0,0 +1,5 @@
//go:build !custom || secretstores || secretstores.oauth2
package all
import _ "github.com/influxdata/telegraf/plugins/secretstores/oauth2" // register plugin

View file

@ -0,0 +1,5 @@
//go:build !custom || secretstores || secretstores.os
package all
import _ "github.com/influxdata/telegraf/plugins/secretstores/os" // register plugin

View file

@ -0,0 +1,5 @@
//go:build !custom || secretstores || secretstores.systemd
package all
import _ "github.com/influxdata/telegraf/plugins/secretstores/systemd" // register plugin

View file

@ -0,0 +1,6 @@
package secretstores
import "github.com/influxdata/telegraf"
// Deprecations lists the deprecated plugins
var Deprecations = make(map[string]telegraf.DeprecationInfo)

View file

@ -0,0 +1,93 @@
# Docker Secrets Secret-Store Plugin
The `docker` plugin allows to utilize credentials and secrets mounted by
Docker during container runtime. The secrets are mounted as files
under the `/run/secrets` directory within the container.
> NOTE: This plugin can ONLY read the mounted secrets from Docker and NOT set them.
## Usage <!-- @/docs/includes/secret_usage.md -->
Secrets defined by a store are referenced with `@{<store-id>:<secret_key>}`
the Telegraf configuration. Only certain Telegraf plugins and options of
support secret stores. To see which plugins and options support
secrets, see their respective documentation (e.g.
`plugins/outputs/influxdb/README.md`). If the plugin's README has the
`Secret-store support` section, it will detail which options support secret
store usage.
## Configuration
```toml @sample.conf
# Secret-store to access Docker Secrets
[[secretstores.docker]]
## Unique identifier for the secretstore.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "docker_secretstore"
## Default Path to directory where docker stores the secrets file
## Current implementation in docker compose v2 only allows the following
## value for the path where the secrets are mounted at runtime
# path = "/run/secrets"
## Allow dynamic secrets that are updated during runtime of telegraf
## Dynamic Secrets work only with `file` or `external` configuration
## in `secrets` section of the `docker-compose.yml` file
# dynamic = false
```
Each Secret mentioned within a Compose service's `secrets` parameter will be
available as file under the `/run/secrets/<secret-name>` within the container.
It is possible to let Telegraf pick changed secret values into plugins by setting
`dynamic = true`. This feature will work only for Docker Secrets provided via
`file` and `external` type within the `docker-compose.yml` file
and not when using `environment` type
(Refer here [Docker Secrets in Compose Specification][1]).
## Example Compose File
```yaml
services:
telegraf:
image: docker.io/telegraf:latest
container_name: dockersecret_telegraf
user: "${USERID}" # Required to access the /run/secrets directory in container
secrets:
- secret_for_plugin
volumes:
- /path/to/telegrafconf/host:/etc/telegraf/telegraf.conf:ro
secrets:
secret_for_plugin:
environment: TELEGRAF_PLUGIN_CREDENTIAL
```
here the `TELEGRAF_PLUGIN_CREDENTIAL` exists in a `.env` file in the same directory
as the `docker-compose.yml`. An example of the `.env` file can be as follows:
```env
TELEGRAF_PLUGIN_CREDENTIAL=superSecretStuff
# determine this value by executing `id -u` in terminal
USERID=1000
```
### Referencing Secret within a Plugin
Referencing the secret within a plugin occurs by:
```toml
[[inputs.<some_plugin>]]
password = "@{docker_secretstore:secret_for_plugin}"
```
## Additional Information
[Docker Secrets in Swarm][2]
[Creating Secrets in Docker][3]
[1]: https://github.com/compose-spec/compose-spec/blob/master/09-secrets.md
[2]: https://docs.docker.com/engine/swarm/secrets/
[3]: https://www.rockyourcode.com/using-docker-secrets-with-docker-compose/

View file

@ -0,0 +1,92 @@
//go:generate ../../../tools/readme_config_includer/generator
package docker
import (
_ "embed"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/plugins/secretstores"
)
//go:embed sample.conf
var sampleConfig string
type Docker struct {
ID string `toml:"id"`
Path string `toml:"path"`
Dynamic bool `toml:"dynamic"`
}
func (*Docker) SampleConfig() string {
return sampleConfig
}
// Init initializes all internals of the secret-store
func (d *Docker) Init() error {
if d.ID == "" {
return errors.New("id missing")
}
if d.Path == "" {
// setting the default directory for Docker Secrets
// if no explicit path mentioned in configuration
d.Path = "/run/secrets"
}
if _, err := os.Stat(d.Path); err != nil {
// if there is no /run/secrets directory for default Path value
// this implies that there are no secrets.
// Or for any explicit path definitions for that matter.
return fmt.Errorf("accessing directory %q failed: %w", d.Path, err)
}
return nil
}
func (d *Docker) Get(key string) ([]byte, error) {
secretFile, err := filepath.Abs(filepath.Join(d.Path, key))
if err != nil {
return nil, err
}
if filepath.Dir(secretFile) != d.Path {
return nil, fmt.Errorf("directory traversal detected for key %q", key)
}
value, err := os.ReadFile(secretFile)
if err != nil {
return nil, fmt.Errorf("cannot read the secret's value under the directory: %w", err)
}
return value, nil
}
func (d *Docker) List() ([]string, error) {
secretFiles, err := os.ReadDir(d.Path)
if err != nil {
return nil, fmt.Errorf("cannot read files under the directory: %w", err)
}
secrets := make([]string, 0, len(secretFiles))
for _, entry := range secretFiles {
secrets = append(secrets, entry.Name())
}
return secrets, nil
}
func (*Docker) Set(_, _ string) error {
return errors.New("secret-store does not support creating secrets")
}
// GetResolver returns a function to resolve the given key.
func (d *Docker) GetResolver(key string) (telegraf.ResolveFunc, error) {
resolver := func() ([]byte, bool, error) {
s, err := d.Get(key)
return s, d.Dynamic, err
}
return resolver, nil
}
// Register the secret-store on load.
func init() {
secretstores.Add("docker", func(id string) telegraf.SecretStore {
return &Docker{ID: id}
})
}

View file

@ -0,0 +1,144 @@
package docker
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestSampleConfig(t *testing.T) {
plugin := &Docker{}
require.NotEmpty(t, plugin.SampleConfig())
}
func TestInitFail(t *testing.T) {
plugin := &Docker{}
require.ErrorContains(t, plugin.Init(), "id missing")
}
func TestPathNonExistent(t *testing.T) {
plugin := &Docker{
ID: "non_existent_path_test",
Path: "non/existent/path",
}
require.ErrorContainsf(t, plugin.Init(), "accessing directory", "accessing directory %q failed: %v", plugin.Path, plugin.Init())
}
func TestSetNotAvailable(t *testing.T) {
testdir, err := filepath.Abs("testdata")
require.NoError(t, err, "testdata cannot be found")
plugin := &Docker{
ID: "set_path_test",
Path: testdir,
}
require.NoError(t, plugin.Init())
// Try to Store the secrets, which this plugin should not let
secret := map[string]string{
"secret-file-1": "TryToSetThis",
}
for k, v := range secret {
require.ErrorContains(t, plugin.Set(k, v), "secret-store does not support creating secrets")
}
}
func TestListGet(t *testing.T) {
// secret files name and their content to compare under the `testdata` directory
secrets := map[string]string{
"secret-file-1": "IWontTell",
"secret_file_2": "SuperDuperSecret!23",
"secretFile": "foobar",
}
testdir, err := filepath.Abs("testdata")
require.NoError(t, err, "testdata cannot be found")
// Initialize the plugin
plugin := &Docker{
ID: "test_list_get",
Path: testdir,
}
require.NoError(t, plugin.Init())
// List the Secrets
keys, err := plugin.List()
require.NoError(t, err)
require.Len(t, keys, len(secrets))
// check if the returned array from List() is the same
// as the name of secret files
for secretFileName := range secrets {
require.Contains(t, keys, secretFileName)
}
// Get the secrets
for _, k := range keys {
value, err := plugin.Get(k)
require.NoError(t, err)
v, found := secrets[k]
require.Truef(t, found, "unexpected secret requested that was not found: %q", k)
require.Equal(t, v, string(value))
}
}
func TestResolver(t *testing.T) {
// Secret Value Name to Resolve
secretFileName := "secret-file-1"
// Secret Value to Resolve To
secretVal := "IWontTell"
testdir, err := filepath.Abs("testdata")
require.NoError(t, err, "testdata cannot be found")
// Initialize the plugin
plugin := &Docker{
ID: "test_resolver",
Path: testdir,
}
require.NoError(t, plugin.Init())
// Get the resolver
resolver, err := plugin.GetResolver(secretFileName)
require.NoError(t, err)
require.NotNil(t, resolver)
s, dynamic, err := resolver()
require.NoError(t, err)
require.False(t, dynamic)
require.Equal(t, secretVal, string(s))
}
func TestResolverInvalid(t *testing.T) {
testdir, err := filepath.Abs("testdata")
require.NoError(t, err, "testdata cannot be found")
// Initialize the plugin
plugin := &Docker{
ID: "test_invalid_resolver",
Path: testdir,
}
require.NoError(t, plugin.Init())
// Get the resolver
resolver, err := plugin.GetResolver("foo")
require.NoError(t, err)
require.NotNil(t, resolver)
_, _, err = resolver()
require.ErrorContains(t, err, "cannot read the secret's value under the directory:")
}
func TestGetNonExistent(t *testing.T) {
testdir, err := filepath.Abs("testdata")
require.NoError(t, err, "testdata cannot be found")
// Initialize the plugin
plugin := &Docker{
ID: "test_nonexistent_get",
Path: testdir,
}
require.NoError(t, plugin.Init())
// Get the resolver
_, err = plugin.Get("foo")
require.ErrorContains(t, err, "cannot read the secret's value under the directory")
}

View file

@ -0,0 +1,16 @@
# Secret-store to access Docker Secrets
[[secretstores.docker]]
## Unique identifier for the secretstore.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "docker_secretstore"
## Default Path to directory where docker stores the secrets file
## Current implementation in docker compose v2 only allows the following
## value for the path where the secrets are mounted at runtime
# path = "/run/secrets"
## Allow dynamic secrets that are updated during runtime of telegraf
## Dynamic Secrets work only with `file` or `external` configuration
## in `secrets` section of the `docker-compose.yml` file
# dynamic = false

View file

@ -0,0 +1 @@
IWontTell

View file

@ -0,0 +1 @@
foobar

View file

@ -0,0 +1 @@
SuperDuperSecret!23

View file

@ -0,0 +1,175 @@
# 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.
## Usage <!-- @/docs/includes/secret_usage.md -->
Secrets defined by a store are referenced with `@{<store-id>:<secret_key>}`
the Telegraf configuration. Only certain Telegraf plugins and options of
support secret stores. To see which plugins and options support
secrets, see their respective documentation (e.g.
`plugins/outputs/influxdb/README.md`). If the plugin's README has the
`Secret-store support` section, it will detail which options support secret
store usage.
## Configuration
```toml @sample.conf
# Read secrets from a HTTP endpoint
[[secretstores.http]]
## Unique identifier for the secret-store.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "secretstore"
## 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 <token>" 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!

View file

@ -0,0 +1,172 @@
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.Bytes())))
_, err = hex.Decode(key, encodedKey.Bytes())
encodedKey.Destroy()
if err != nil {
return fmt.Errorf("decoding key failed: %w", err)
}
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.Bytes())
encodedIV.Destroy()
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.Bytes())))
_, err = hex.Decode(key, encodedKey.Bytes())
encodedKey.Destroy()
if err != nil {
return nil, fmt.Errorf("decoding key failed: %w", err)
}
// 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.Bytes())))
_, err = hex.Decode(iv, encodedIV.Bytes())
encodedIV.Destroy()
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)
}

View file

@ -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)
})
}
}

View file

@ -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 <algorithm>[/param 1>[/<param 2>]...]
// 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
}

View file

@ -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(nil)
require.ErrorContains(t, err, "empty value to trim")
_, err = PKCS5or7Trimming([]byte{0x00, 0x05})
require.ErrorContains(t, err, "length 2 shorter than trim value 5")
}

View file

@ -0,0 +1,241 @@
//go:generate ../../../tools/readme_config_includer/generator
package http
import (
"context"
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/blues/jsonata-go"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
common_http "github.com/influxdata/telegraf/plugins/common/http"
"github.com/influxdata/telegraf/plugins/secretstores"
)
//go:embed sample.conf
var sampleConfig string
const defaultIdleConnTimeoutMinutes = 5
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:"-"`
common_http.HTTPClientConfig
DecryptionConfig
client *http.Client
transformer *jsonata.Expr
cache map[string]string
decrypter Decrypter
}
func (*HTTP) SampleConfig() string {
return sampleConfig
}
func (h *HTTP) Init() error {
ctx := context.Background()
// Prevent idle connections from hanging around forever on telegraf reload
if h.HTTPClientConfig.IdleConnTimeout == 0 {
h.HTTPClientConfig.IdleConnTimeout = config.Duration(defaultIdleConnTimeoutMinutes * time.Minute)
}
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 (*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.EqualFold(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)
}
defer username.Destroy()
password, err := h.Password.Get()
if err != nil {
return fmt.Errorf("getting password failed: %w", err)
}
defer password.Destroy()
request.SetBasicAuth(username.String(), password.String())
}
if !h.Token.Empty() {
token, err := h.Token.Get()
if err != nil {
return fmt.Errorf("getting token failed: %w", err)
}
defer token.Destroy()
bearer := "Bearer " + strings.TrimSpace(token.String())
request.Header.Set("Authorization", bearer)
}
return nil
}
// Register the secret-store on load.
func init() {
secretstores.Add("http", func(string) telegraf.SecretStore {
return &HTTP{}
})
}

View file

@ -0,0 +1,433 @@
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(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" {
if _, err = w.Write(input); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
} 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, _ *http.Request) {
if _, err := w.Write([]byte(`{"test": "aedMZXaLR246OHHjVtJKXQ=="}`)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
}))
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, _ *http.Request) {
if _, err = w.Write([]byte(`[{"test": "aedMZXaLR246OHHjVtJKXQ=="}]`)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
}))
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, _ *http.Request) {
if _, err = w.Write([]byte(`[somerandomebytes`)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
}))
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)
}
if _, err = w.Write([]byte(`{"test": "aedMZXaLR246OHHjVtJKXQ=="}`)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
}))
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":
if _, err = w.Write([]byte(`{}`)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
case "/201":
w.WriteHeader(201)
case "/300":
w.WriteHeader(300)
if _, err = w.Write([]byte(`{}`)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
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
if _, err = w.Write([]byte(`{}`)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
}))
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
if _, err = w.Write([]byte(`{}`)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
}))
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)
}

View file

@ -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) (key, iv config.Secret, err 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 passwd.Destroy()
salt, err := k.Salt.Get()
if err != nil {
return config.Secret{}, config.Secret{}, fmt.Errorf("getting salt failed: %w", err)
}
defer salt.Destroy()
rawkey := pbkdf2.Key(passwd.Bytes(), salt.Bytes(), k.Iterations, keylen, hf)
key = config.NewSecret([]byte(hex.EncodeToString(rawkey)))
return key, config.Secret{}, nil
}

View file

@ -0,0 +1,91 @@
package http
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/influxdata/telegraf/config"
)
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)
defer key.Destroy()
require.Equal(t, tt.key, key.TemporaryString())
if tt.iv != "" {
iv, err := siv.Get()
require.NoError(t, err)
defer iv.Destroy()
require.Equal(t, tt.iv, iv.TemporaryString())
} 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)
})
}
}

View file

@ -0,0 +1,85 @@
# Read secrets from a HTTP endpoint
[[secretstores.http]]
## Unique identifier for the secret-store.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "secretstore"
## 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 <token>" 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

View file

@ -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": ""
}

View file

@ -0,0 +1,6 @@
{
"user_1": "3y1Za18sLLNIIHw1fv2Olg==",
"user 2": "aedMZXaLR246OHHjVtJKXQ==",
"user@company.com": "rcFobNmuaaboSPZY5nKjzQ==",
"user %with% $trAng\u20ac characters": "1HxPInsJomaWAE19VBisyw=="
}

View file

@ -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

View file

@ -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": ""
}

View file

@ -0,0 +1,6 @@
{
"user_1": "3y1Za18sLLNIIHw1fv2Olg==",
"user 2": "aedMZXaLR246OHHjVtJKXQ==",
"user@company.com": "rcFobNmuaaboSPZY5nKjzQ==",
"user %with% $trAng\u20ac characters": "1HxPInsJomaWAE19VBisyw=="
}

View file

@ -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"

View file

@ -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": ""
}

View file

@ -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"
}
]

View file

@ -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"

View file

@ -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": ""
}

View file

@ -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"
}
]

View file

@ -0,0 +1,4 @@
[[secretstores.http]]
id = "test"
url = "http://127.0.0.1/secrets"
transformation = '{userName: userValue, secretName: secretValue}'

View file

@ -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": ""
}

View file

@ -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": ""
}
]

View file

@ -0,0 +1,4 @@
[[secretstores.http]]
id = "test"
url = "http://127.0.0.1/secrets"
transformation = '{user: secret}'

View file

@ -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": ""
}

View file

@ -0,0 +1,6 @@
{
"user_1": "password A",
"user 2": "password-B",
"user@company.com": "my$3cR3T",
"user %with% $trAng€ characters": ""
}

View file

@ -0,0 +1,3 @@
[[secretstores.http]]
id = "test"
url = "http://127.0.0.1/secrets"

View file

@ -0,0 +1,53 @@
# Javascript Object Signing and Encryption Secret-store Plugin
The `jose` plugin allows to manage and store secrets locally
protected by the [Javascript Object Signing and Encryption][jose] algorithm.
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.
## Usage <!-- @/docs/includes/secret_usage.md -->
Secrets defined by a store are referenced with `@{<store-id>:<secret_key>}`
the Telegraf configuration. Only certain Telegraf plugins and options of
support secret stores. To see which plugins and options support
secrets, see their respective documentation (e.g.
`plugins/outputs/influxdb/README.md`). If the plugin's README has the
`Secret-store support` section, it will detail which options support secret
store usage.
## Configuration
```toml @sample.conf
# File based Javascript Object Signing and Encryption based secret-store
[[secretstores.jose]]
## Unique identifier for the secret-store.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "secretstore"
## Directory for storing the secrets
path = "/etc/telegraf/secrets"
## Password to access the secrets.
## If no password is specified here, Telegraf will prompt for it at startup time.
# password = ""
```
Each secret is stored in an individual file in the subdirectory specified
using the `path` parameter. To access the secrets, a password is required.
This password can be specified using the `password` parameter containing a
string, an environment variable or as a reference to a secret in another
secret store. If `password` is not specified in the config, you will be
prompted for the password at startup.
__Please note:__ All secrets in this secret store are encrypted using
the same password. If you need individual passwords for each `jose`
secret, please use multiple instances of this plugin.
[jose]: https://github.com/dvsekhvalnov/jose2go

View file

@ -0,0 +1,115 @@
//go:generate ../../../tools/readme_config_includer/generator
package jose
import (
_ "embed"
"errors"
"fmt"
"github.com/99designs/keyring"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/plugins/secretstores"
)
//go:embed sample.conf
var sampleConfig string
type Jose struct {
ID string `toml:"id"`
Path string `toml:"path"`
Password config.Secret `toml:"password"`
ring keyring.Keyring
}
func (*Jose) SampleConfig() string {
return sampleConfig
}
// Init initializes all internals of the secret-store
func (j *Jose) Init() error {
defer j.Password.Destroy()
if j.ID == "" {
return errors.New("id missing")
}
if j.Path == "" {
return errors.New("path missing")
}
// Create the prompt-function in case we need it
promptFunc := keyring.TerminalPrompt
if !j.Password.Empty() {
passwd, err := j.Password.Get()
if err != nil {
return fmt.Errorf("getting password failed: %w", err)
}
defer passwd.Destroy()
promptFunc = keyring.FixedStringPrompt(passwd.String())
} else if !config.Password.Empty() {
passwd, err := config.Password.Get()
if err != nil {
return fmt.Errorf("getting global password failed: %w", err)
}
defer passwd.Destroy()
promptFunc = keyring.FixedStringPrompt(passwd.String())
}
// Setup the actual keyring
cfg := keyring.Config{
AllowedBackends: []keyring.BackendType{keyring.FileBackend},
FileDir: j.Path,
FilePasswordFunc: promptFunc,
}
kr, err := keyring.Open(cfg)
if err != nil {
return fmt.Errorf("opening keyring failed: %w", err)
}
j.ring = kr
return nil
}
// Get searches for the given key and return the secret
func (j *Jose) Get(key string) ([]byte, error) {
item, err := j.ring.Get(key)
if err != nil {
return nil, err
}
return item.Data, nil
}
// Set sets the given secret for the given key
func (j *Jose) Set(key, value string) error {
item := keyring.Item{
Key: key,
Data: []byte(value),
}
return j.ring.Set(item)
}
// List lists all known secret keys
func (j *Jose) List() ([]string, error) {
return j.ring.Keys()
}
// GetResolver returns a function to resolve the given key.
func (j *Jose) GetResolver(key string) (telegraf.ResolveFunc, error) {
resolver := func() ([]byte, bool, error) {
s, err := j.Get(key)
return s, false, err
}
return resolver, nil
}
// Register the secret-store on load.
func init() {
secretstores.Add("jose", func(id string) telegraf.SecretStore {
return &Jose{ID: id}
})
}

View file

@ -0,0 +1,203 @@
package jose
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/influxdata/telegraf/config"
)
func TestSampleConfig(t *testing.T) {
plugin := &Jose{}
require.NotEmpty(t, plugin.SampleConfig())
}
func TestInitFail(t *testing.T) {
tests := []struct {
name string
plugin *Jose
expected string
}{
{
name: "invalid id",
plugin: &Jose{},
expected: "id missing",
},
{
name: "missing path",
plugin: &Jose{
ID: "test",
},
expected: "path missing",
},
{
name: "invalid password",
plugin: &Jose{
ID: "test",
Path: t.TempDir(),
Password: config.NewSecret([]byte("@{unresolvable:secret}")),
},
expected: "getting password failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.plugin.Init()
require.ErrorContains(t, err, tt.expected)
})
}
}
func TestSetListGet(t *testing.T) {
secrets := map[string]string{
"a secret": "I won't tell",
"another one": "secret",
"foo": "bar",
}
// Create a temporary directory we can use to store the secrets
testdir := t.TempDir()
// Initialize the plugin
plugin := &Jose{
ID: "test",
Password: config.NewSecret([]byte("test")),
Path: testdir,
}
require.NoError(t, plugin.Init())
// Store the secrets
for k, v := range secrets {
require.NoError(t, plugin.Set(k, v))
}
// Check if the secrets were actually stored
entries, err := os.ReadDir(testdir)
require.NoError(t, err)
require.Len(t, entries, len(secrets))
for _, e := range entries {
_, found := secrets[e.Name()]
require.True(t, found)
require.False(t, e.IsDir())
}
// List the secrets
keys, err := plugin.List()
require.NoError(t, err)
require.Len(t, keys, len(secrets))
for _, k := range keys {
_, found := secrets[k]
require.True(t, found)
}
// Get the secrets
require.Len(t, keys, len(secrets))
for _, k := range keys {
value, err := plugin.Get(k)
require.NoError(t, err)
v, found := secrets[k]
require.True(t, found)
require.Equal(t, v, string(value))
}
}
func TestResolver(t *testing.T) {
secretKey := "a secret"
secretVal := "I won't tell"
// Create a temporary directory we can use to store the secrets
testdir := t.TempDir()
// Initialize the plugin
plugin := &Jose{
ID: "test",
Password: config.NewSecret([]byte("test")),
Path: testdir,
}
require.NoError(t, plugin.Init())
require.NoError(t, plugin.Set(secretKey, secretVal))
// Get the resolver
resolver, err := plugin.GetResolver(secretKey)
require.NoError(t, err)
require.NotNil(t, resolver)
s, dynamic, err := resolver()
require.NoError(t, err)
require.False(t, dynamic)
require.Equal(t, secretVal, string(s))
}
func TestResolverInvalid(t *testing.T) {
secretKey := "a secret"
secretVal := "I won't tell"
// Create a temporary directory we can use to store the secrets
testdir := t.TempDir()
// Initialize the plugin
plugin := &Jose{
ID: "test",
Password: config.NewSecret([]byte("test")),
Path: testdir,
}
require.NoError(t, plugin.Init())
require.NoError(t, plugin.Set(secretKey, secretVal))
// Get the resolver
resolver, err := plugin.GetResolver("foo")
require.NoError(t, err)
require.NotNil(t, resolver)
_, _, err = resolver()
require.Error(t, err)
}
func TestGetNonExistent(t *testing.T) {
secretKey := "a secret"
secretVal := "I won't tell"
// Create a temporary directory we can use to store the secrets
testdir := t.TempDir()
// Initialize the plugin
plugin := &Jose{
ID: "test",
Password: config.NewSecret([]byte("test")),
Path: testdir,
}
require.NoError(t, plugin.Init())
require.NoError(t, plugin.Set(secretKey, secretVal))
// Get the resolver
_, err := plugin.Get("foo")
require.EqualError(t, err, "The specified item could not be found in the keyring")
}
func TestGetInvalidPassword(t *testing.T) {
secretKey := "a secret"
secretVal := "I won't tell"
// Create a temporary directory we can use to store the secrets
testdir := t.TempDir()
// Initialize the stored secrets
creator := &Jose{
ID: "test",
Password: config.NewSecret([]byte("test")),
Path: testdir,
}
require.NoError(t, creator.Init())
require.NoError(t, creator.Set(secretKey, secretVal))
// Initialize the plugin with a wrong password
// and try to access an existing secret
plugin := &Jose{
ID: "test",
Password: config.NewSecret([]byte("lala")),
Path: testdir,
}
require.NoError(t, plugin.Init())
_, err := plugin.Get(secretKey)
require.ErrorContains(t, err, "integrity check failed")
}

View file

@ -0,0 +1,13 @@
# File based Javascript Object Signing and Encryption based secret-store
[[secretstores.jose]]
## Unique identifier for the secret-store.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "secretstore"
## Directory for storing the secrets
path = "/etc/telegraf/secrets"
## Password to access the secrets.
## If no password is specified here, Telegraf will prompt for it at startup time.
# password = ""

View file

@ -0,0 +1,136 @@
# OAuth2 Secret-store Plugin
The `oauth2` plugin allows to retrieve and maintain secrets from various OAuth2
services such as [Auth0][auth0], [AzureAD][azuread] or others (see
[Configuration section](#configuration)).
Tokens that are expired or are about to expire will be automatically renewed
by this secret-store, so other plugins referencing those tokens can then use
them to perform their API calls without hassle.
**Please note:** This plugin only supports the *2-legged client credentials*
flow.
You can use Telegraf to test token retrieval. Run
```shell
telegraf secrets help
```
to get more information on how to do access secrets with Telegraf.
## Usage <!-- @/docs/includes/secret_usage.md -->
Secrets defined by a store are referenced with `@{<store-id>:<secret_key>}`
the Telegraf configuration. Only certain Telegraf plugins and options of
support secret stores. To see which plugins and options support
secrets, see their respective documentation (e.g.
`plugins/outputs/influxdb/README.md`). If the plugin's README has the
`Secret-store support` section, it will detail which options support secret
store usage.
## Configuration
```toml @sample.conf
# Secret-store to retrieve and maintain tokens from various OAuth2 services
[[secretstores.oauth2]]
## Unique identifier for the secret-store.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "secretstore"
## Service to retrieve the token(s) from
## Currently supported services are "custom", "auth0" and "AzureAD"
# service = "custom"
## Setting to overwrite the queried token-endpoint
## This setting is optional for some services but mandatory for others such
## as "custom" or "auth0". Please check the documentation at
## https://github.com/influxdata/telegraf/blob/master/plugins/secretstores/oauth2/README.md
# token_endpoint = ""
## Tenant ID for the AzureAD service
# tenant_id = ""
## Minimal remaining time until the token expires
## If a token expires less than the set duration in the future, the token is
## renewed. This is useful to avoid race-condition issues where a token is
## still valid, but isn't when the request reaches the API endpoint of
## your service using the token.
# token_expiry_margin = "1s"
## Section for defining a token secret
[[secretstores.oauth2.token]]
## Unique secret-key used for referencing the token via @{<id>:<secret_key>}
key = ""
## Client-ID and secret for the 2-legged OAuth flow
client_id = ""
client_secret = ""
## Scopes to send in the request
# scopes = []
## Additional (optional) parameters to include in the token request
## This might for example include the "audience" parameter required for
## auth0.
# [secretstores.oauth2.token.parameters]
# audience = ""
```
All services allow multiple `[[secretstores.oauth2.token]]` sections to be
specified to define different tokens for the secret store. Please make sure to
specify `key`s that are **unique** within the secret-store instance as those
are used to reference the tokens/secrets later.
The `oauth2` secret-store supports various services that might differ in the
required or allowed settings as listed below. All of the services accept
optional `scopes` and optional `parameter` settings if not stated otherwise.
Please **replace the placeholders** in the minumal example configurations below
and add `scopes` and/or `parameters` if required.
### Auth0
To use the [Auth0 service][auth0] for retrieving the token you need to set the
`token_endpoint` to your application's endpoint. Furthermore, specifying the
`audience` parameter is required. An example configuration look like
```toml
[[secretstores.oauth2]]
id = "secretstore"
service = "auth0"
token_endpoint = "https://YOUR_DOMAIN/oauth/token"
[[secretstores.oauth2.token]]
key = "mytoken"
client_id = "YOUR_CLIENT_ID"
client_secret = "YOUR_CLIENT_SECRET"
[secretstores.oauth2.token.parameters]
audience = "YOUR_API_IDENTIFIER"
```
### AzureAD
To use the [AzureAD service][azuread] for retrieving the token you need to set
the `tenant_id` and provide a valid `scope`. An example configuration look like
```toml
[[secretstores.oauth2]]
id = "secretstore"
service = "AzureAD"
tenant_id = "YOUR_TENANT_ID"
[[secretstores.oauth2.token]]
key = "mytoken"
client_id = "YOUR_CLIENT_ID"
client_secret = "YOUR_CLIENT_SECRET"
scopes = ["YOUR_CLIENT_ID/.default"]
```
### Custom service
If your service is not listed above, you can still use it setting
`service = "custom"` as well as the `token_endpoint`. Please make sure your
service is configured for the *2-legged client credentials* OAuth2 flow!
[auth0]: https://auth0.com
[azuread]: https://azure.microsoft.com/en/products/active-directory

View file

@ -0,0 +1,200 @@
//go:generate ../../../tools/readme_config_includer/generator
package oauth2
import (
"context"
_ "embed"
"errors"
"fmt"
"net/url"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
"golang.org/x/oauth2/endpoints"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/plugins/secretstores"
)
//go:embed sample.conf
var sampleConfig string
type TokenConfig struct {
Key string `toml:"key"`
ClientID config.Secret `toml:"client_id"`
ClientSecret config.Secret `toml:"client_secret"`
Scopes []string `toml:"scopes"`
Params map[string]string `toml:"parameters"`
}
type OAuth2 struct {
Service string `toml:"service"`
Endpoint string `toml:"token_endpoint"`
Tenant string `toml:"tenant_id"`
ExpiryMargin config.Duration `toml:"token_expiry_margin"`
TokenConfigs []TokenConfig `toml:"token"`
Log telegraf.Logger `toml:"-"`
sources map[string]oauth2.TokenSource
cancel context.CancelFunc
}
func (*OAuth2) SampleConfig() string {
return sampleConfig
}
// Init initializes all internals of the secret-store
func (o *OAuth2) Init() error {
ctx, cancel := context.WithCancel(context.Background())
o.cancel = cancel
// Check the service setting and determine the endpoint
var endpoint oauth2.Endpoint
var requireTenant, acceptCustomEndpoint bool
switch strings.ToLower(o.Service) {
case "", "custom":
if o.Endpoint == "" {
return errors.New("'token_endpoint' required for custom service")
}
endpoint.TokenURL = o.Endpoint
endpoint.AuthStyle = oauth2.AuthStyleAutoDetect
acceptCustomEndpoint = true
case "auth0":
if o.Endpoint == "" {
return errors.New("'token_endpoint' required for Auth0")
}
endpoint = oauth2.Endpoint{
TokenURL: o.Endpoint,
AuthStyle: oauth2.AuthStyleInParams,
}
acceptCustomEndpoint = true
case "azuread":
if o.Tenant == "" {
return errors.New("'tenant_id' required for AzureAD")
}
requireTenant = true
endpoint = endpoints.AzureAD(o.Tenant)
default:
return fmt.Errorf("service %q not supported", o.Service)
}
if !requireTenant && o.Tenant != "" {
o.Log.Warnf("'tenant_id' set but ignored by service %q", o.Service)
}
if !acceptCustomEndpoint && o.Endpoint != "" {
return fmt.Errorf("'token_endpoint' cannot be set for service %q", o.Service)
}
// Setup the token sources
o.sources = make(map[string]oauth2.TokenSource, len(o.TokenConfigs))
for _, c := range o.TokenConfigs {
if c.Key == "" {
return errors.New("'key' not specified")
}
if c.ClientID.Empty() {
return fmt.Errorf("'client_id' not specified for key %q", c.Key)
}
if c.ClientSecret.Empty() {
return fmt.Errorf("'client_secret' not specified for key %q", c.Key)
}
// Check service specific parameters
if strings.EqualFold(o.Service, "auth0") {
if audience := c.Params["audience"]; audience == "" {
return fmt.Errorf("'audience' parameter in key %q missing for service Auth0", c.Key)
}
}
if _, found := o.sources[c.Key]; found {
return fmt.Errorf("token with key %q already defined", c.Key)
}
// Get the secrets
cid, err := c.ClientID.Get()
if err != nil {
return fmt.Errorf("getting client ID for %q failed: %w", c.Key, err)
}
csecret, err := c.ClientSecret.Get()
if err != nil {
cid.Destroy()
return fmt.Errorf("getting client secret for %q failed: %w", c.Key, err)
}
// Setup the configuration
cfg := &clientcredentials.Config{
ClientID: cid.String(),
ClientSecret: csecret.String(),
TokenURL: endpoint.TokenURL,
Scopes: c.Scopes,
AuthStyle: endpoint.AuthStyle,
EndpointParams: url.Values{},
}
cid.Destroy()
csecret.Destroy()
// Add the parameters if any
for k, v := range c.Params {
cfg.EndpointParams.Add(k, v)
}
src := cfg.TokenSource(ctx)
o.sources[c.Key] = oauth2.ReuseTokenSourceWithExpiry(nil, src, time.Duration(o.ExpiryMargin))
}
return nil
}
// Get searches for the given key and return the secret
func (o *OAuth2) Get(key string) ([]byte, error) {
src, found := o.sources[key]
if !found {
return nil, fmt.Errorf("token %q not found", key)
}
// Return the token from the token-source. The token will be automatically
// renewed if the token expires.
token, err := src.Token()
if err != nil {
return nil, err
}
if !token.Valid() {
return nil, errors.New("token invalid")
}
return []byte(token.AccessToken), nil
}
// Set sets the given secret for the given key
func (*OAuth2) Set(_, _ string) error {
return errors.New("not supported")
}
// List lists all known secret keys
func (o *OAuth2) List() ([]string, error) {
keys := make([]string, 0, len(o.sources))
for k := range o.sources {
keys = append(keys, k)
}
return keys, nil
}
// GetResolver returns a function to resolve the given key.
func (o *OAuth2) GetResolver(key string) (telegraf.ResolveFunc, error) {
resolver := func() ([]byte, bool, error) {
s, err := o.Get(key)
return s, true, err
}
return resolver, nil
}
// Register the secret-store on load.
func init() {
secretstores.Add("oauth2", func(_ string) telegraf.SecretStore {
return &OAuth2{ExpiryMargin: config.Duration(time.Second)}
})
}

View file

@ -0,0 +1,364 @@
package oauth2
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/testutil"
)
func TestSampleConfig(t *testing.T) {
plugin := &OAuth2{}
require.NotEmpty(t, plugin.SampleConfig())
}
func TestEndpointParams(t *testing.T) {
plugin := &OAuth2{
Endpoint: "http://localhost:8080/token",
Tenant: "tenantID",
TokenConfigs: []TokenConfig{
{
ClientID: config.NewSecret([]byte("clientID")),
ClientSecret: config.NewSecret([]byte("clientSecret")),
Key: "test",
Params: map[string]string{
"foo": "bar",
},
},
},
Log: testutil.Logger{},
}
require.NoError(t, plugin.Init())
}
func TestInitFail(t *testing.T) {
tests := []struct {
name string
plugin *OAuth2
expected string
}{
{
name: "no service",
plugin: &OAuth2{},
expected: "'token_endpoint' required for custom service",
},
{
name: "custom service no URL",
plugin: &OAuth2{},
expected: "'token_endpoint' required for custom service",
},
{
name: "invalid service",
plugin: &OAuth2{Service: "foo"},
expected: `service "foo" not supported`,
},
{
name: "AzureAD without tenant",
plugin: &OAuth2{Service: "AzureAD"},
expected: "'tenant_id' required for AzureAD",
},
{
name: "token without key",
plugin: &OAuth2{
Service: "custom",
Endpoint: "http://localhost:8080",
TokenConfigs: []TokenConfig{{}}},
expected: "'key' not specified",
},
{
name: "token without client ID",
plugin: &OAuth2{
Service: "custom",
Endpoint: "http://localhost:8080",
TokenConfigs: []TokenConfig{
{
Key: "test",
},
},
},
expected: "'client_id' not specified",
},
{
name: "token without client secret",
plugin: &OAuth2{
Service: "custom",
Endpoint: "http://localhost:8080",
TokenConfigs: []TokenConfig{
{
Key: "test",
ClientID: config.NewSecret([]byte("someone")),
},
},
},
expected: "'client_secret' not specified",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.plugin.Init()
require.ErrorContains(t, err, tt.expected)
})
}
}
func TestSetUnsupported(t *testing.T) {
plugin := &OAuth2{
Service: "custom",
Endpoint: "http://localhost:8080",
TokenConfigs: []TokenConfig{
{
Key: "test",
ClientID: config.NewSecret([]byte("someone")),
ClientSecret: config.NewSecret([]byte("s3cr3t")),
},
},
}
require.NoError(t, plugin.Init())
require.ErrorContains(t, plugin.Set("foo", "bar"), "not supported")
}
func TestGetNonExisting(t *testing.T) {
plugin := &OAuth2{
Service: "custom",
Endpoint: "http://localhost:8080",
TokenConfigs: []TokenConfig{
{
Key: "test",
ClientID: config.NewSecret([]byte("someone")),
ClientSecret: config.NewSecret([]byte("s3cr3t")),
},
},
}
require.NoError(t, plugin.Init())
// Make sure the key does not exist and try to read that key
_, err := plugin.Get("foo")
require.EqualError(t, err, `token "foo" not found`)
}
func TestResolver404(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
plugin := &OAuth2{
Service: "custom",
Endpoint: server.URL + "/token",
TokenConfigs: []TokenConfig{
{
Key: "test",
ClientID: config.NewSecret([]byte("someone")),
ClientSecret: config.NewSecret([]byte("s3cr3t")),
},
},
}
require.NoError(t, plugin.Init())
// Get the resolver
resolver, err := plugin.GetResolver("test")
require.NoError(t, err)
require.NotNil(t, resolver)
_, _, err = resolver()
require.ErrorContains(t, err, "404 Not Found")
}
func TestGet(t *testing.T) {
expected := "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3"
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
if _, err := w.Write([]byte(err.Error())); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
creds := "client_id=someone&client_secret=s3cr3t&grant_type=client_credentials"
if !strings.Contains(string(body), creds) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":"%s","scope":"read write","token_type":"bearer","expires_in":299}`, expected)
}))
defer server.Close()
plugin := &OAuth2{
Service: "custom",
Endpoint: server.URL + "/token",
TokenConfigs: []TokenConfig{
{
Key: "test",
ClientID: config.NewSecret([]byte("someone")),
ClientSecret: config.NewSecret([]byte("s3cr3t")),
},
},
}
require.NoError(t, plugin.Init())
// Get the resolver
token, err := plugin.Get("test")
require.NoError(t, err)
require.Equal(t, expected, string(token))
}
func TestGetMultipleTimes(t *testing.T) {
expected := []string{"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", "03807CB390319329BDF6C777D4DFAE9C0D3B3C35"}
index := 0
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
if _, err := w.Write([]byte(err.Error())); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
creds := "client_id=someone&client_secret=s3cr3t&grant_type=client_credentials"
if !strings.Contains(string(body), creds) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":"%s","scope":"read write","token_type":"bearer","expires_in":60}`, expected[index])
index++
}))
defer server.Close()
plugin := &OAuth2{
Service: "custom",
Endpoint: server.URL + "/token",
TokenConfigs: []TokenConfig{
{
Key: "test",
ClientID: config.NewSecret([]byte("someone")),
ClientSecret: config.NewSecret([]byte("s3cr3t")),
},
},
}
require.NoError(t, plugin.Init())
// Get the secret
token, err := plugin.Get("test")
require.NoError(t, err)
require.Equal(t, expected[0], string(token))
// Get the token another time and it should still be the same as it didn't
// expire yet.
token, err = plugin.Get("test")
require.NoError(t, err)
require.Equal(t, expected[0], string(token))
}
func TestGetExpired(t *testing.T) {
expected := "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3"
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
if _, err := w.Write([]byte(err.Error())); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
creds := "client_id=someone&client_secret=s3cr3t&grant_type=client_credentials"
if !strings.Contains(string(body), creds) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":"%s","scope":"read write","token_type":"bearer","expires_in":3}`, expected)
}))
defer server.Close()
plugin := &OAuth2{
Service: "custom",
Endpoint: server.URL + "/token",
ExpiryMargin: config.Duration(5 * time.Second),
TokenConfigs: []TokenConfig{
{
Key: "test",
ClientID: config.NewSecret([]byte("someone")),
ClientSecret: config.NewSecret([]byte("s3cr3t")),
},
},
}
require.NoError(t, plugin.Init())
// Get the secret
token, err := plugin.Get("test")
require.ErrorContains(t, err, "token invalid")
require.Nil(t, token)
}
func TestGetRefresh(t *testing.T) {
expected := []string{"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", "03807CB390319329BDF6C777D4DFAE9C0D3B3C35"}
index := 0
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
if _, err := w.Write([]byte(err.Error())); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
creds := "client_id=someone&client_secret=s3cr3t&grant_type=client_credentials"
if !strings.Contains(string(body), creds) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":"%s","scope":"read write","token_type":"bearer","expires_in":6}`, expected[index])
index++
}))
defer server.Close()
plugin := &OAuth2{
Service: "custom",
Endpoint: server.URL + "/token",
ExpiryMargin: config.Duration(5 * time.Second),
TokenConfigs: []TokenConfig{
{
Key: "test",
ClientID: config.NewSecret([]byte("someone")),
ClientSecret: config.NewSecret([]byte("s3cr3t")),
},
},
}
require.NoError(t, plugin.Init())
// Get the secret
token, err := plugin.Get("test")
require.NoError(t, err)
require.Equal(t, expected[0], string(token))
// Wait until the secret expired and get the secret again
time.Sleep(2 * time.Second)
token, err = plugin.Get("test")
require.NoError(t, err)
require.Equal(t, expected[1], string(token))
}

View file

@ -0,0 +1,42 @@
# Secret-store to retrieve and maintain tokens from various OAuth2 services
[[secretstores.oauth2]]
## Unique identifier for the secret-store.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "secretstore"
## Service to retrieve the token(s) from
## Currently supported services are "custom", "auth0" and "AzureAD"
# service = "custom"
## Setting to overwrite the queried token-endpoint
## This setting is optional for some services but mandatory for others such
## as "custom" or "auth0". Please check the documentation at
## https://github.com/influxdata/telegraf/blob/master/plugins/secretstores/oauth2/README.md
# token_endpoint = ""
## Tenant ID for the AzureAD service
# tenant_id = ""
## Minimal remaining time until the token expires
## If a token expires less than the set duration in the future, the token is
## renewed. This is useful to avoid race-condition issues where a token is
## still valid, but isn't when the request reaches the API endpoint of
## your service using the token.
# token_expiry_margin = "1s"
## Section for defining a token secret
[[secretstores.oauth2.token]]
## Unique secret-key used for referencing the token via @{<id>:<secret_key>}
key = ""
## Client-ID and secret for the 2-legged OAuth flow
client_id = ""
client_secret = ""
## Scopes to send in the request
# scopes = []
## Additional (optional) parameters to include in the token request
## This might for example include the "audience" parameter required for
## auth0.
# [secretstores.oauth2.token.parameters]
# audience = ""

View file

@ -0,0 +1,125 @@
# OS Secret-store Plugin
The `os` plugin allows to manage and store secrets using the native Operating
System keyring. For Windows this plugin uses the credential manager, on Linux
the kernel keyring is used and on MacOS we use the Keychain implementation.
To manage your secrets you can either use Telegraf or the tools that natively
comes with your operating system. Run
```shell
telegraf secrets help
```
to get more information on how to do this with Telegraf.
## Usage <!-- @/docs/includes/secret_usage.md -->
Secrets defined by a store are referenced with `@{<store-id>:<secret_key>}`
the Telegraf configuration. Only certain Telegraf plugins and options of
support secret stores. To see which plugins and options support
secrets, see their respective documentation (e.g.
`plugins/outputs/influxdb/README.md`). If the plugin's README has the
`Secret-store support` section, it will detail which options support secret
store usage.
## Configuration
The configuration differs slightly depending on the Operating System. We first
describe the common options here and the refer to the individual interpretation
or options in the following sections.
All secret-store implementations require an `id` to be able to reference the
store when specifying the secret. The `id` needs to be unique in the
configuration.
For all operating systems, the keyring name can be chosen using the `keyring`
parameter. However, the interpretation is slightly different on the individual
implementations.
The `dynamic` flag allows to indicate secrets that change during the runtime of
Telegraf. I.e. when set to `true`, the secret will be read from the secret-store
on every access by a plugin. If set to `false`, all secrets in the secret store
are assumed to be static and are only read once at startup of Telegraf.
```toml @sample.conf
# Operating System native secret-store
[[secretstores.os]]
## Unique identifier for the secret-store.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "secretstore"
## Keyring Name & Collection
## * Linux: keyring name used for the secrets, collection is unused
## * macOS: keyring specifies the macOS' Keychain name and collection is an
## optional Keychain service name
## * Windows: keys follow a fixed pattern in the form
## `<collection>:<keyring>:<key_name>`. Please keep this in mind when
## creating secrets with the Windows credential tool.
# keyring = "telegraf"
# collection = ""
## macOS Keychain password
## If no password is specified here, Telegraf will prompt for it at startup
## time.
# password = ""
## Allow dynamic secrets that are updated during runtime of telegraf
# dynamic = false
```
### Linux
On Linux the kernel keyring in the `user` scope is used to store the
secrets. The `collection` setting is ignored on Linux.
### MacOS
On MacOS the Keychain implementation is used. Here the `keyring` parameter
corresponds to the Keychain name and the `collection` to the optional Keychain
service name. Additionally a password is required to access the Keychain.
The `password` itself is also a secret and can be a string, an environment
variable or a reference to a secret stored in another secret-store.
If `password` is omitted, you will be prompted for the password on startup.
### Windows
On Windows you can use the Credential Manager in the Control Panel or
[Telegraf](../../../cmd/telegraf/README.md) to manage your secrets.
If using the Credential Manager, click "Windows Credentials" and then
"Add a generic credential" with the following:
* _Internet or network address_: Enter the secret name in the format of:
`<collection>:<keyring>:<key_name>`
* _User name_: Use `telegraf`. This field is not used, but needs something
entered.
* _Password_: The actual secret value
If using Telegraf, see the help output of `telegraf secrets set` to add
secrets. Again use the `<collection>:<keyring>:<key_name>` format of the secret
key name.
### Docker
Access to the kernel keyring is __disabled by default__ in docker containers
(see [documentation](https://docs.docker.com/engine/security/seccomp/)).
In this case you will get an
`opening keyring failed: Specified keyring backend not available` error!
You can enable access to the kernel keyring, but as the keyring is __not__
namespaced, you should be aware of the security implication! One implication
is for example that keys added in one container are accessible by __all__
other containers running on the same host, not only within the same container.
### systemd-nspawn
The memguard dependency that Telegraf uses to secure memory for secret storage
requires the `CAP_IPC_LOCK` capability to correctly lock memory. Without this
capability Telegraf will panic. Users will need to start a container with the
`--capability=CAP_IPC_LOCK` flag for telegraf to correctly work.
See [github.com/awnumar/memguard#144][memguard-issue] for more information.
[memguard-issue]: https://github.com/awnumar/memguard/issues/144

View file

@ -0,0 +1,101 @@
//go:build darwin || linux || windows
//go:generate ../../../tools/readme_config_includer/generator
package os
import (
_ "embed"
"errors"
"fmt"
"github.com/99designs/keyring"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/plugins/secretstores"
)
//go:embed sample.conf
var sampleConfig string
type OS struct {
ID string `toml:"id"`
Keyring string `toml:"keyring"`
Collection string `toml:"collection"`
Dynamic bool `toml:"dynamic"`
Password config.Secret `toml:"password"`
ring keyring.Keyring
}
func (*OS) SampleConfig() string {
return sampleConfig
}
// Init initializes all internals of the secret-store
func (o *OS) Init() error {
defer o.Password.Destroy()
if o.ID == "" {
return errors.New("id missing")
}
// Set defaults
if o.Keyring == "" {
o.Keyring = "telegraf"
}
// Setup the actual keyring
cfg, err := o.createKeyringConfig()
if err != nil {
return fmt.Errorf("getting keyring config failed: %w", err)
}
kr, err := keyring.Open(cfg)
if err != nil {
return fmt.Errorf("opening keyring failed: %w", err)
}
o.ring = kr
return nil
}
// Get searches for the given key and return the secret
func (o *OS) Get(key string) ([]byte, error) {
item, err := o.ring.Get(key)
if err != nil {
return nil, err
}
return item.Data, nil
}
// Set sets the given secret for the given key
func (o *OS) Set(key, value string) error {
item := keyring.Item{
Key: key,
Data: []byte(value),
}
return o.ring.Set(item)
}
// List lists all known secret keys
func (o *OS) List() ([]string, error) {
return o.ring.Keys()
}
// GetResolver returns a function to resolve the given key.
func (o *OS) GetResolver(key string) (telegraf.ResolveFunc, error) {
resolver := func() ([]byte, bool, error) {
s, err := o.Get(key)
return s, o.Dynamic, err
}
return resolver, nil
}
// Register the secret-store on load.
func init() {
secretstores.Add("os", func(id string) telegraf.SecretStore {
return &OS{ID: id}
})
}

View file

@ -0,0 +1,29 @@
//go:build darwin
package os
import (
"fmt"
"github.com/99designs/keyring"
)
func (o *OS) createKeyringConfig() (keyring.Config, error) {
// Create the prompt-function in case we need it
promptFunc := keyring.TerminalPrompt
if !o.Password.Empty() {
passwd, err := o.Password.Get()
if err != nil {
return keyring.Config{}, fmt.Errorf("getting password failed: %w", err)
}
promptFunc = keyring.FixedStringPrompt(passwd.String())
passwd.Destroy()
}
return keyring.Config{
ServiceName: o.Collection,
AllowedBackends: []keyring.BackendType{keyring.KeychainBackend},
KeychainName: o.Keyring,
KeychainPasswordFunc: promptFunc,
}, nil
}

View file

@ -0,0 +1,19 @@
//go:build linux
package os
import (
"github.com/99designs/keyring"
)
func (o *OS) createKeyringConfig() (keyring.Config, error) {
if o.Keyring == "" {
o.Keyring = "telegraf"
}
return keyring.Config{
ServiceName: o.Keyring,
AllowedBackends: []keyring.BackendType{keyring.KeyCtlBackend},
KeyCtlScope: "user",
KeyCtlPerm: 0x3f3f0000, // "alswrvalswrv------------"
}, nil
}

View file

@ -0,0 +1,89 @@
//go:build darwin || linux || windows
package os
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/influxdata/telegraf/internal/choice"
)
// In docker, access to the keyring is disabled by default see
// https://docs.docker.com/engine/security/seccomp/.
// You will see the following error then.
const dockerErr = "opening keyring failed: Specified keyring backend not available"
func TestSampleConfig(t *testing.T) {
plugin := &OS{}
require.NotEmpty(t, plugin.SampleConfig())
}
func TestInitFail(t *testing.T) {
tests := []struct {
name string
plugin *OS
expected string
}{
{
name: "invalid id",
plugin: &OS{},
expected: "id missing",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.plugin.Init()
require.ErrorContains(t, err, tt.expected)
})
}
}
func TestResolverInvalid(t *testing.T) {
plugin := &OS{ID: "test"}
// In docker, access to the keyring is disabled by default
// see https://docs.docker.com/engine/security/seccomp/.
err := plugin.Init()
if err != nil && err.Error() == dockerErr {
t.Skip("Kernel keyring not available!")
}
require.NoError(t, err)
// Make sure the key does not exist and try to read that key
testKey := "foobar secret key"
keys, err := plugin.List()
require.NoError(t, err)
for choice.Contains(testKey, keys) {
testKey += "x"
}
// Get the resolver
resolver, err := plugin.GetResolver(testKey)
require.NoError(t, err)
require.NotNil(t, resolver)
_, _, err = resolver()
require.Error(t, err)
}
func TestGetNonExisting(t *testing.T) {
plugin := &OS{ID: "test"}
// In docker, access to the keyring is disabled by default
// see https://docs.docker.com/engine/security/seccomp/.
err := plugin.Init()
if err != nil && err.Error() == dockerErr {
t.Skip("Kernel keyring not available!")
}
require.NoError(t, err)
// Make sure the key does not exist and try to read that key
testKey := "foobar secret key"
keys, err := plugin.List()
require.NoError(t, err)
for choice.Contains(testKey, keys) {
testKey += "x"
}
_, err = plugin.Get(testKey)
require.EqualError(t, err, "The specified item could not be found in the keyring")
}

View file

@ -0,0 +1 @@
package os

View file

@ -0,0 +1,15 @@
//go:build windows
package os
import (
"github.com/99designs/keyring"
)
func (o *OS) createKeyringConfig() (keyring.Config, error) {
return keyring.Config{
ServiceName: o.Keyring,
AllowedBackends: []keyring.BackendType{keyring.WinCredBackend},
WinCredPrefix: o.Collection,
}, nil
}

View file

@ -0,0 +1,24 @@
# Operating System native secret-store
[[secretstores.os]]
## Unique identifier for the secret-store.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "secretstore"
## Keyring Name & Collection
## * Linux: keyring name used for the secrets, collection is unused
## * macOS: keyring specifies the macOS' Keychain name and collection is an
## optional Keychain service name
## * Windows: keys follow a fixed pattern in the form
## `<collection>:<keyring>:<key_name>`. Please keep this in mind when
## creating secrets with the Windows credential tool.
# keyring = "telegraf"
# collection = ""
## macOS Keychain password
## If no password is specified here, Telegraf will prompt for it at startup
## time.
# password = ""
## Allow dynamic secrets that are updated during runtime of telegraf
# dynamic = false

View file

@ -0,0 +1,16 @@
package secretstores
import (
"github.com/influxdata/telegraf"
)
// Creator is the function to create a new parser
type Creator func(id string) telegraf.SecretStore
// SecretStores contains the registry of all known secret-stores
var SecretStores = make(map[string]Creator)
// Add adds a secret-store to the registry. Usually this function is called in the plugin's init function
func Add(name string, creator Creator) {
SecretStores[name] = creator
}

View file

@ -0,0 +1,246 @@
# Systemd Secret-Store Plugin
The `systemd` plugin allows utilizing credentials and secrets provided by
[systemd][] to the Telegraf service. Systemd ensures that only the intended
service can access the credentials for the lifetime of this service. The
credentials appear as plaintext files to the consuming service but are stored
encrypted on the host system. This encryption can also use TPM2 protection if
available (see [this article][systemd-descr] for details).
This plugin does not support setting the credentials. See the
[credentials management section](#credential-management) below for how to
setup systemd credentials and how to add credentials
**Note**: Secrets of this plugin are static and are not updated after startup.
## Requirements and caveats
This plugin requires **systemd version 250+** with correctly set-up credentials
via [systemd-creds][] (see [setup section](#credential-management)).
However, to use `ImportCredential`, as done in the default service file, you
need **systemd version 254+** otherwise you need to specify the credentials
using `LoadCredentialEncrypted` in a service-override.
In the default setup, Telegraf expects credential files to be prefixed with
`telegraf.` and without a custom name setting (i.e. no `--name`).
It is important to note that when TPM2 sealing is available on the host,
credentials can only be created and used on the **same machine** as decrypting
the secrets requires the encryption key *and* a key stored in TPM2. Therefore,
creating credentials and then copying it to another machine will fail!
Please be aware that, due to its nature, this plugin is **ONLY** available
when started as a service. It does **NOT** find any credentials when started
manually via the command line! Therefore, `secrets` commands should **not**
be used with this plugin.
## Usage <!-- @/docs/includes/secret_usage.md -->
Secrets defined by a store are referenced with `@{<store-id>:<secret_key>}`
the Telegraf configuration. Only certain Telegraf plugins and options of
support secret stores. To see which plugins and options support
secrets, see their respective documentation (e.g.
`plugins/outputs/influxdb/README.md`). If the plugin's README has the
`Secret-store support` section, it will detail which options support secret
store usage.
## Configuration
```toml @sample.conf
# Secret-store to access systemd secrets
[[secretstores.systemd]]
## Unique identifier for the secretstore.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "systemd"
## Path to systemd credentials directory
## This should not be required as systemd indicates this directory
## via the CREDENTIALS_DIRECTORY environment variable.
# path = "${CREDENTIALS_DIRECTORY}"
## Prefix to remove from systemd credential-filenames to derive secret names
# prefix = "telegraf."
```
Each Secret provided by systemd will be available as file under
`${CREDENTIALS_DIRECTORY}/<secret-name>` for the service. You will **not** be
able to see them as a regular, non-telegraf user. Credential visibility from
other systemd services is mediated by the `User=` and `PrivateMounts=`
service-unit directives for those services. See the
[systemd.exec man-page][systemd-exec] for details.
## Credential management
Most steps here are condensed from the [systemd-creds man-page][systemd-creds]
and are using this command. Please also check that man-page as the options
or verbs used here might be outdated for the systemd version you are using.
**Please note**: We are using `/etc/credstore.encrypted` as our storage
location for encrypted credentials throughout the examples below and assuming
a Telegraf install via package manager. If you are using some other means to
install Telegraf you might need to create that directory.
Furthermore, we assume the secret-store ID to be set to `systemd` in the
examples.
Setting up systemd-credentials might vary on your distribution or version so
please also check the documentation there. You might also need to install
supporting packages such as `tpm2-tools`.
### Setup
If you have not done it already, systemd requires a first-time setup of the
credential system. If you are planning to use the TPM2 chip of your system
for protecting the credentials, you should first make sure that it is
available using
```shell
sudo systemd-creds has-tpm2
```
The output should look similar to
```text
partial
-firmware
+driver
+system
+subsystem
```
If TPM2 is available on your system, credentials can also be tied to the device
by utilizing TPM2 sealing. See the [systemd-creds man-page][systemd-creds] for
details.
Now setup the credentials by creating the root key.
```shell
sudo systemd-creds setup
```
A warning may appears if you are storing the generated key on an unencrypted
disk which is not recommended. With this, we are all set to create credentials.
### Creating credentials
After setting up the encryption key we can create a new credential using
```shell
echo -n "john-doe-jr" | sudo systemd-creds encrypt - /etc/credstore.encrypted/telegraf.http_user
```
You should now have a file named `telegraf.http_user` containing the encrypted
username. The secret-store later provides the secret using this filename as the
secret's key.
**Please note:**: By default Telegraf strips the `telegraf.` prefix. If you use
a different prefix or no prefix at all you need to adapt the `prefix` setting!
We can now add more secrets. To avoid potentially leaking the plain-text
credentials through command-history or showing it on the screen we use
```shell
systemd-ask-password -n | sudo systemd-creds encrypt - /etc/credstore.encrypted/telegraf.http_password
```
to interactively enter the password.
### Using credentials as secrets
To use the credentials as secrets you need to first instantiate a `systemd`
secret-store by adding
```toml
[[secretstores.systemd]]
id = "systemd"
```
to your Telegraf configuration. Assuming the two example credentials
`http_user` and `http_password` you can now use those as secrets via
```toml
[[inputs.http]]
urls = ["http://localhost/metrics"]
username = "@{systemd:http_user}"
password = "@{systemd:http_password}"
```
in your plugins.
### Chaining for unattended start
When using many secrets or when secrets need to be shared among hosts, listing
all of them in the service file might be cumbersome. Additionally, it is hard
to manually test Telegraf configurations with the `systemd` secret-store as
those secrets are only available when started as a service.
Here, secret-store chaining comes into play, denoting a setup where one
secret-store, in our case `secretstores.systemd`, is used to unlock another
secret-store (`secretstores.jose` in this example).
```toml
[[secretstores.systemd]]
id = "systemd"
[[secretstores.jose]]
id = "mysecrets"
path = "/etc/telegraf/secrets"
password = "@{systemd:initial}"
```
Here we assume that an `initial` credential was injected through the service
file. This `initial` secret is then used to unlock the `jose` secret-store
which might provide many different secrets backed by encrypted files.
Input and output plugins can the use the `jose` secrets (via `@{mysecrets:...}`)
to fill sensitive data such as usernames, passwords or tokens.
### Troubleshooting
Please always make sure your systemd version matches Telegraf's requirements,
i.e. you do have version 254 or later.
When not being able to start the service please check the logs. A common issue
is using the `--name` option which does not work with systemd's
`ImportCredential` setting.
a mismatch between the name stored in the credential (given during
`systemd-creds encrypt`) and the one used in the
`LoadCredentialEncrypted` statement.
In case you are having trouble referencing credentials in Telegraf, you should
check what is available via
```shell
CREDENTIALS_DIRECTORY=/etc/credstore.encrypted sudo systemd-creds list
```
for the example above you should see
```text
NAME SECURE SIZE PATH
-------------------------------------------------------------------
telegraf.http_password insecure 146B /etc/credstore.encrypted/telegraf.http_password
telegraf.http_user insecure 142B /etc/credstore.encrypted/telegraf.http_user
```
**Please note**: Telegraf's secret management functionality is not helpful here
as credentials are *only* available to the systemd service, not via commandline.
Remember to remove the `prefix` configured in your secret-store from the `NAME`
column to get the secrets' `key`.
To get the actual value of a credential use
```shell
sudo systemd-creds decrypt /etc/credstore.encrypted/telegraf.http_password -
```
Please use the above command(s) with care as they do reveal the secret value
of the credential!
[systemd]: https://www.freedesktop.org/wiki/Software/systemd/
[systemd-descr]: https://systemd.io/CREDENTIALS
[systemd-creds]: https://www.freedesktop.org/software/systemd/man/systemd-creds.html
[systemd-exec]: https://www.freedesktop.org/software/systemd/man/systemd.exec.html

View file

@ -0,0 +1,15 @@
# Secret-store to access systemd secrets
[[secretstores.systemd]]
## Unique identifier for the secretstore.
## This id can later be used in plugins to reference the secrets
## in this secret-store via @{<id>:<secret_key>} (mandatory)
id = "systemd"
## Path to systemd credentials directory
## This should not be required as systemd indicates this directory
## via the CREDENTIALS_DIRECTORY environment variable.
# path = "${CREDENTIALS_DIRECTORY}"
## Prefix to remove from systemd credential-filenames to derive secret names
# prefix = "telegraf."

View file

@ -0,0 +1,139 @@
//go:build linux
//go:generate ../../../tools/readme_config_includer/generator
package systemd
import (
"context"
_ "embed"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/coreos/go-systemd/v22/dbus"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/plugins/secretstores"
)
const systemdMinimumVersion = 250
// Required to be a variable to mock the version in tests
var getSystemdVersion = getSystemdMajorVersion
//go:embed sample.conf
var sampleConfig string
type Systemd struct {
Path string `toml:"path"`
Prefix string `toml:"prefix"`
Log telegraf.Logger `toml:"-"`
}
func (*Systemd) SampleConfig() string {
return sampleConfig
}
// Init initializes all internals of the secret-store
func (s *Systemd) Init() error {
version, err := getSystemdVersion()
if err != nil {
return fmt.Errorf("unable to detect systemd version: %w", err)
}
s.Log.Debugf("Found systemd version %d...", version)
if version < systemdMinimumVersion {
return fmt.Errorf("systemd version %d below minimum version %d", version, systemdMinimumVersion)
}
// By default the credentials directory is passed in by systemd
// via the "CREDENTIALS_DIRECTORY" environment variable.
defaultPath := os.Getenv("CREDENTIALS_DIRECTORY")
if defaultPath == "" {
s.Log.Warn("CREDENTIALS_DIRECTORY environment variable undefined. Make sure credentials are setup correctly!")
if s.Path == "" {
return errors.New("'path' required without CREDENTIALS_DIRECTORY")
}
}
// Use default path if no explicit was specified. This should be the common case.
if s.Path == "" {
s.Path = defaultPath
}
s.Path, err = filepath.Abs(s.Path)
if err != nil {
return fmt.Errorf("cannot determine absolute path of %q: %w", s.Path, err)
}
// Check if we can access the target directory
if _, err := os.Stat(s.Path); err != nil {
return fmt.Errorf("accessing credentials directory %q failed: %w", s.Path, err)
}
return nil
}
func (s *Systemd) Get(key string) ([]byte, error) {
secretFile, err := filepath.Abs(filepath.Join(s.Path, s.Prefix+key))
if err != nil {
return nil, err
}
if filepath.Dir(secretFile) != s.Path {
return nil, fmt.Errorf("invalid directory detected for key %q", key)
}
value, err := os.ReadFile(secretFile)
if err != nil {
return nil, fmt.Errorf("cannot read the secret's value: %w", err)
}
return value, nil
}
func (s *Systemd) List() ([]string, error) {
secretFiles, err := os.ReadDir(s.Path)
if err != nil {
return nil, fmt.Errorf("cannot read files: %w", err)
}
secrets := make([]string, 0, len(secretFiles))
for _, entry := range secretFiles {
key := strings.TrimPrefix(entry.Name(), s.Prefix)
secrets = append(secrets, key)
}
return secrets, nil
}
func (*Systemd) Set(_, _ string) error {
return errors.New("secret-store does not support creating secrets")
}
// GetResolver returns a function to resolve the given key.
func (s *Systemd) GetResolver(key string) (telegraf.ResolveFunc, error) {
resolver := func() ([]byte, bool, error) {
s, err := s.Get(key)
return s, false, err
}
return resolver, nil
}
func getSystemdMajorVersion() (int, error) {
ctx := context.Background()
conn, err := dbus.NewWithContext(ctx)
if err != nil {
return 0, err
}
defer conn.Close()
fullVersion, err := conn.GetManagerProperty("Version")
if err != nil {
return 0, err
}
fullVersion = strings.Trim(fullVersion, "\"")
return strconv.Atoi(strings.SplitN(fullVersion, ".", 2)[0])
}
// Register the secret-store on load.
func init() {
secretstores.Add("systemd", func(_ string) telegraf.SecretStore {
return &Systemd{Prefix: "telegraf."}
})
}

View file

@ -0,0 +1 @@
package systemd

View file

@ -0,0 +1,175 @@
//go:build linux
package systemd
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/influxdata/telegraf/testutil"
)
func getSystemdVersionMin() (int, error) {
return systemdMinimumVersion, nil
}
func TestSampleConfig(t *testing.T) {
plugin := &Systemd{}
require.NotEmpty(t, plugin.SampleConfig())
}
func TestMinimumVersion(t *testing.T) {
getSystemdVersion = func() (int, error) { return 123, nil }
plugin := &Systemd{Log: testutil.Logger{}}
require.ErrorContains(t, plugin.Init(), "below minimum version")
}
func TestEmptyPath(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
plugin := &Systemd{Log: testutil.Logger{}}
require.ErrorContains(t, plugin.Init(), "'path' required without CREDENTIALS_DIRECTORY")
}
func TestEmptyCredentialsDirectoryWarning(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
logger := &testutil.CaptureLogger{}
plugin := &Systemd{
Path: "testdata",
Log: logger}
require.NoError(t, plugin.Init())
actual := logger.Warnings()
require.Len(t, actual, 1)
require.Contains(t, actual[0], "CREDENTIALS_DIRECTORY environment variable undefined")
}
func TestPathNonExistentExplicit(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
t.Setenv("CREDENTIALS_DIRECTORY", "testdata")
plugin := &Systemd{
Path: "non/existent/path",
Log: testutil.Logger{},
}
require.ErrorContains(t, plugin.Init(), "accessing credentials directory")
}
func TestPathNonExistentImplicit(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
t.Setenv("CREDENTIALS_DIRECTORY", "non/existent/path")
plugin := &Systemd{
Log: testutil.Logger{},
}
require.ErrorContains(t, plugin.Init(), "accessing credentials directory")
}
func TestInit(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
t.Setenv("CREDENTIALS_DIRECTORY", "testdata")
plugin := &Systemd{Log: testutil.Logger{}}
require.NoError(t, plugin.Init())
}
func TestSetNotAvailable(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
t.Setenv("CREDENTIALS_DIRECTORY", "testdata")
plugin := &Systemd{Log: testutil.Logger{}}
require.NoError(t, plugin.Init())
// Try to Store the secrets, which this plugin should not let
require.ErrorContains(t, plugin.Set("foo", "bar"), "secret-store does not support creating secrets")
}
func TestListGet(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
t.Setenv("CREDENTIALS_DIRECTORY", "testdata")
// secret files name and their content to compare under the `testdata` directory
secrets := map[string]string{
"secret-file-1": "IWontTell",
"secret_file_2": "SuperDuperSecret!23",
"secretFile": "foobar",
}
// Initialize the plugin
plugin := &Systemd{Log: testutil.Logger{}}
require.NoError(t, plugin.Init())
// List the Secrets
keys, err := plugin.List()
require.NoError(t, err)
require.Len(t, keys, len(secrets))
// check if the returned array from List() is the same
// as the name of secret files
for secretFileName := range secrets {
require.Contains(t, keys, secretFileName)
}
// Get the secrets
for _, k := range keys {
value, err := plugin.Get(k)
require.NoError(t, err)
v, found := secrets[k]
require.Truef(t, found, "unexpected secret requested that was not found: %q", k)
require.Equal(t, v, string(value))
}
}
func TestResolver(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
t.Setenv("CREDENTIALS_DIRECTORY", "testdata")
// Secret Value Name to Resolve
secretFileName := "secret-file-1"
// Secret Value to Resolve To
secretVal := "IWontTell"
// Initialize the plugin
plugin := &Systemd{Log: testutil.Logger{}}
require.NoError(t, plugin.Init())
// Get the resolver
resolver, err := plugin.GetResolver(secretFileName)
require.NoError(t, err)
require.NotNil(t, resolver)
s, dynamic, err := resolver()
require.NoError(t, err)
require.False(t, dynamic)
require.Equal(t, secretVal, string(s))
}
func TestResolverInvalid(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
t.Setenv("CREDENTIALS_DIRECTORY", "testdata")
// Initialize the plugin
plugin := &Systemd{Log: testutil.Logger{}}
require.NoError(t, plugin.Init())
// Get the resolver
resolver, err := plugin.GetResolver("foo")
require.NoError(t, err)
require.NotNil(t, resolver)
_, _, err = resolver()
require.ErrorContains(t, err, "cannot read the secret's value:")
}
func TestGetNonExistent(t *testing.T) {
getSystemdVersion = getSystemdVersionMin
t.Setenv("CREDENTIALS_DIRECTORY", "testdata")
// Initialize the plugin
plugin := &Systemd{Log: testutil.Logger{}}
require.NoError(t, plugin.Init())
// Get the resolver
_, err := plugin.Get("foo")
require.ErrorContains(t, err, "cannot read the secret's value:")
}

View file

@ -0,0 +1 @@
IWontTell

View file

@ -0,0 +1 @@
foobar

View file

@ -0,0 +1 @@
SuperDuperSecret!23