Adding upstream version 1.34.4.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
e393c3af3f
commit
4978089aab
4963 changed files with 677545 additions and 0 deletions
11
plugins/secretstores/README.md
Normal file
11
plugins/secretstores/README.md
Normal 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.
|
1
plugins/secretstores/all/all.go
Normal file
1
plugins/secretstores/all/all.go
Normal file
|
@ -0,0 +1 @@
|
|||
package all
|
5
plugins/secretstores/all/docker.go
Normal file
5
plugins/secretstores/all/docker.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
//go:build !custom || secretstores || secretstores.docker
|
||||
|
||||
package all
|
||||
|
||||
import _ "github.com/influxdata/telegraf/plugins/secretstores/docker" // register plugin
|
5
plugins/secretstores/all/http.go
Normal file
5
plugins/secretstores/all/http.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
//go:build !custom || secretstores || secretstores.http
|
||||
|
||||
package all
|
||||
|
||||
import _ "github.com/influxdata/telegraf/plugins/secretstores/http" // register plugin
|
5
plugins/secretstores/all/jose.go
Normal file
5
plugins/secretstores/all/jose.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
//go:build !custom || secretstores || secretstores.jose
|
||||
|
||||
package all
|
||||
|
||||
import _ "github.com/influxdata/telegraf/plugins/secretstores/jose" // register plugin
|
5
plugins/secretstores/all/oauth2.go
Normal file
5
plugins/secretstores/all/oauth2.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
//go:build !custom || secretstores || secretstores.oauth2
|
||||
|
||||
package all
|
||||
|
||||
import _ "github.com/influxdata/telegraf/plugins/secretstores/oauth2" // register plugin
|
5
plugins/secretstores/all/os.go
Normal file
5
plugins/secretstores/all/os.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
//go:build !custom || secretstores || secretstores.os
|
||||
|
||||
package all
|
||||
|
||||
import _ "github.com/influxdata/telegraf/plugins/secretstores/os" // register plugin
|
5
plugins/secretstores/all/systemd.go
Normal file
5
plugins/secretstores/all/systemd.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
//go:build !custom || secretstores || secretstores.systemd
|
||||
|
||||
package all
|
||||
|
||||
import _ "github.com/influxdata/telegraf/plugins/secretstores/systemd" // register plugin
|
6
plugins/secretstores/deprecations.go
Normal file
6
plugins/secretstores/deprecations.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package secretstores
|
||||
|
||||
import "github.com/influxdata/telegraf"
|
||||
|
||||
// Deprecations lists the deprecated plugins
|
||||
var Deprecations = make(map[string]telegraf.DeprecationInfo)
|
93
plugins/secretstores/docker/README.md
Normal file
93
plugins/secretstores/docker/README.md
Normal 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/
|
92
plugins/secretstores/docker/docker.go
Normal file
92
plugins/secretstores/docker/docker.go
Normal 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}
|
||||
})
|
||||
}
|
144
plugins/secretstores/docker/docker_test.go
Normal file
144
plugins/secretstores/docker/docker_test.go
Normal 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")
|
||||
}
|
16
plugins/secretstores/docker/sample.conf
Normal file
16
plugins/secretstores/docker/sample.conf
Normal 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
|
1
plugins/secretstores/docker/testdata/secret-file-1
vendored
Normal file
1
plugins/secretstores/docker/testdata/secret-file-1
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
IWontTell
|
1
plugins/secretstores/docker/testdata/secretFile
vendored
Normal file
1
plugins/secretstores/docker/testdata/secretFile
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
foobar
|
1
plugins/secretstores/docker/testdata/secret_file_2
vendored
Normal file
1
plugins/secretstores/docker/testdata/secret_file_2
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
SuperDuperSecret!23
|
175
plugins/secretstores/http/README.md
Normal file
175
plugins/secretstores/http/README.md
Normal 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!
|
172
plugins/secretstores/http/aes.go
Normal file
172
plugins/secretstores/http/aes.go
Normal 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)
|
||||
}
|
319
plugins/secretstores/http/aes_test.go
Normal file
319
plugins/secretstores/http/aes_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
47
plugins/secretstores/http/decryption.go
Normal file
47
plugins/secretstores/http/decryption.go
Normal 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
|
||||
}
|
22
plugins/secretstores/http/decryption_test.go
Normal file
22
plugins/secretstores/http/decryption_test.go
Normal 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")
|
||||
}
|
241
plugins/secretstores/http/http.go
Normal file
241
plugins/secretstores/http/http.go
Normal 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{}
|
||||
})
|
||||
}
|
433
plugins/secretstores/http/http_test.go
Normal file
433
plugins/secretstores/http/http_test.go
Normal 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)
|
||||
}
|
53
plugins/secretstores/http/key_derivation.go
Normal file
53
plugins/secretstores/http/key_derivation.go
Normal 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
|
||||
}
|
91
plugins/secretstores/http/key_derivation_test.go
Normal file
91
plugins/secretstores/http/key_derivation_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
85
plugins/secretstores/http/sample.conf
Normal file
85
plugins/secretstores/http/sample.conf
Normal 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
|
|
@ -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": ""
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"user_1": "3y1Za18sLLNIIHw1fv2Olg==",
|
||||
"user 2": "aedMZXaLR246OHHjVtJKXQ==",
|
||||
"user@company.com": "rcFobNmuaaboSPZY5nKjzQ==",
|
||||
"user %with% $trAng\u20ac characters": "1HxPInsJomaWAE19VBisyw=="
|
||||
}
|
|
@ -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
|
|
@ -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": ""
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"user_1": "3y1Za18sLLNIIHw1fv2Olg==",
|
||||
"user 2": "aedMZXaLR246OHHjVtJKXQ==",
|
||||
"user@company.com": "rcFobNmuaaboSPZY5nKjzQ==",
|
||||
"user %with% $trAng\u20ac characters": "1HxPInsJomaWAE19VBisyw=="
|
||||
}
|
|
@ -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"
|
10
plugins/secretstores/http/testcases/mixed/expected.json
Normal file
10
plugins/secretstores/http/testcases/mixed/expected.json
Normal 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": ""
|
||||
}
|
30
plugins/secretstores/http/testcases/mixed/secrets.json
Normal file
30
plugins/secretstores/http/testcases/mixed/secrets.json
Normal 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"
|
||||
}
|
||||
]
|
15
plugins/secretstores/http/testcases/mixed/telegraf.conf
Normal file
15
plugins/secretstores/http/testcases/mixed/telegraf.conf
Normal 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"
|
|
@ -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": ""
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -0,0 +1,4 @@
|
|||
[[secretstores.http]]
|
||||
id = "test"
|
||||
url = "http://127.0.0.1/secrets"
|
||||
transformation = '{userName: userValue, secretName: secretValue}'
|
|
@ -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": ""
|
||||
}
|
|
@ -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": ""
|
||||
}
|
||||
]
|
|
@ -0,0 +1,4 @@
|
|||
[[secretstores.http]]
|
||||
id = "test"
|
||||
url = "http://127.0.0.1/secrets"
|
||||
transformation = '{user: secret}'
|
|
@ -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": ""
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"user_1": "password A",
|
||||
"user 2": "password-B",
|
||||
"user@company.com": "my$3cR3T",
|
||||
"user %with% $trAng€ characters": ""
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
[[secretstores.http]]
|
||||
id = "test"
|
||||
url = "http://127.0.0.1/secrets"
|
53
plugins/secretstores/jose/README.md
Normal file
53
plugins/secretstores/jose/README.md
Normal 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
|
115
plugins/secretstores/jose/jose.go
Normal file
115
plugins/secretstores/jose/jose.go
Normal 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}
|
||||
})
|
||||
}
|
203
plugins/secretstores/jose/jose_test.go
Normal file
203
plugins/secretstores/jose/jose_test.go
Normal 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")
|
||||
}
|
13
plugins/secretstores/jose/sample.conf
Normal file
13
plugins/secretstores/jose/sample.conf
Normal 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 = ""
|
136
plugins/secretstores/oauth2/README.md
Normal file
136
plugins/secretstores/oauth2/README.md
Normal 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
|
200
plugins/secretstores/oauth2/oauth2.go
Normal file
200
plugins/secretstores/oauth2/oauth2.go
Normal 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)}
|
||||
})
|
||||
}
|
364
plugins/secretstores/oauth2/oauth2_test.go
Normal file
364
plugins/secretstores/oauth2/oauth2_test.go
Normal 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))
|
||||
}
|
42
plugins/secretstores/oauth2/sample.conf
Normal file
42
plugins/secretstores/oauth2/sample.conf
Normal 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 = ""
|
125
plugins/secretstores/os/README.md
Normal file
125
plugins/secretstores/os/README.md
Normal 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
|
101
plugins/secretstores/os/os.go
Normal file
101
plugins/secretstores/os/os.go
Normal 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}
|
||||
})
|
||||
}
|
29
plugins/secretstores/os/os_darwin.go
Normal file
29
plugins/secretstores/os/os_darwin.go
Normal 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
|
||||
}
|
19
plugins/secretstores/os/os_linux.go
Normal file
19
plugins/secretstores/os/os_linux.go
Normal 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
|
||||
}
|
89
plugins/secretstores/os/os_test.go
Normal file
89
plugins/secretstores/os/os_test.go
Normal 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")
|
||||
}
|
1
plugins/secretstores/os/os_unsupported.go
Normal file
1
plugins/secretstores/os/os_unsupported.go
Normal file
|
@ -0,0 +1 @@
|
|||
package os
|
15
plugins/secretstores/os/os_windows.go
Normal file
15
plugins/secretstores/os/os_windows.go
Normal 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
|
||||
}
|
24
plugins/secretstores/os/sample.conf
Normal file
24
plugins/secretstores/os/sample.conf
Normal 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
|
16
plugins/secretstores/registry.go
Normal file
16
plugins/secretstores/registry.go
Normal 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
|
||||
}
|
246
plugins/secretstores/systemd/README.md
Normal file
246
plugins/secretstores/systemd/README.md
Normal 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
|
15
plugins/secretstores/systemd/sample.conf
Normal file
15
plugins/secretstores/systemd/sample.conf
Normal 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."
|
||||
|
139
plugins/secretstores/systemd/systemd.go
Normal file
139
plugins/secretstores/systemd/systemd.go
Normal 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."}
|
||||
})
|
||||
}
|
1
plugins/secretstores/systemd/systemd_nonlinux.go
Normal file
1
plugins/secretstores/systemd/systemd_nonlinux.go
Normal file
|
@ -0,0 +1 @@
|
|||
package systemd
|
175
plugins/secretstores/systemd/systemd_test.go
Normal file
175
plugins/secretstores/systemd/systemd_test.go
Normal 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:")
|
||||
}
|
1
plugins/secretstores/systemd/testdata/secret-file-1
vendored
Normal file
1
plugins/secretstores/systemd/testdata/secret-file-1
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
IWontTell
|
1
plugins/secretstores/systemd/testdata/secretFile
vendored
Normal file
1
plugins/secretstores/systemd/testdata/secretFile
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
foobar
|
1
plugins/secretstores/systemd/testdata/secret_file_2
vendored
Normal file
1
plugins/secretstores/systemd/testdata/secret_file_2
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
SuperDuperSecret!23
|
Loading…
Add table
Add a link
Reference in a new issue