diff --git a/cmd/otelcontribcol/builder-config.yaml b/cmd/otelcontribcol/builder-config.yaml index c0b98560c32e3..89c0ea0c4076f 100644 --- a/cmd/otelcontribcol/builder-config.yaml +++ b/cmd/otelcontribcol/builder-config.yaml @@ -19,6 +19,7 @@ extensions: - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/asapauthextension v0.152.0 - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/awsproxy v0.152.0 - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/azureauthextension v0.152.0 + - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension v0.152.0 - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/basicauthextension v0.152.0 - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/bearertokenauthextension v0.152.0 - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/datadogextension v0.152.0 diff --git a/extension/awssecretsmanagerauthextension/README.md b/extension/awssecretsmanagerauthextension/README.md new file mode 100644 index 0000000000000..e92df08a998f6 --- /dev/null +++ b/extension/awssecretsmanagerauthextension/README.md @@ -0,0 +1,153 @@ +# AWS Secrets Manager Auth Extension + +| Status | | +| ------------- |-----------| +| Stability | [development] | +| Distributions | [contrib] | + +[development]: https://github.com/open-telemetry/opentelemetry-collector/blob/main/docs/component-stability.md#development +[contrib]: https://github.com/open-telemetry/opentelemetry-collector-releases/tree/main/distributions/otelcol-contrib + +This extension implements both `extensionauth.Server` and `extensionauth.Client` to authenticate receivers and exporters using HTTP Basic Auth, with credentials sourced from [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) and automatically rotated at a configurable interval. + +The extension uses the default AWS credential chain (environment variables, `~/.aws/credentials`, EC2/ECS/EKS IAM roles). No explicit AWS credentials are required in the collector config. + +When a new secret version is detected, credentials are swapped atomically with no restart required. If a fetch fails, the extension logs a warning and continues using the last known credentials. + +When used as a server authenticator, a successful authentication exposes the following attributes on `client.Info.Auth`: + +- `username`: The authenticated username. +- `raw`: Raw base64-encoded credentials. + +## Modes + +Exactly one of `htpasswd` or `client_auth` must be set. + +### Server authentication (`htpasswd`) + +The secret value is treated as [htpasswd](https://httpd.apache.org/docs/current/programs/htpasswd.html) file content. All hash formats supported by the [`go-htpasswd`](https://github.com/tg123/go-htpasswd) library are accepted (bcrypt, SHA, MD5, APR1, etc.). + +If `value_key` is set, the secret must be a JSON object and the value at that key is used as the htpasswd content. If `value_key` is empty, the entire secret string is used as-is. + +### Client authentication (`client_auth`) + +The secret must be a JSON object. `username_key` and `password_key` identify which fields to use (both default to `"username"` and `"password"`). + +## Configuration + +| Field | Required | Default | Description | +|---|---|---|---| +| `secret_arn` | yes | | ARN or name of the secret in AWS Secrets Manager | +| `refresh_interval` | yes | `30s` | How often to poll for a new secret version | +| `htpasswd.value_key` | no | `""` | JSON key holding htpasswd content; if empty the raw secret string is used | +| `client_auth.username_key` | no | `"username"` | JSON key for the username | +| `client_auth.password_key` | no | `"password"` | JSON key for the password | + +## Examples + +### Client authenticator — outbound requests + +Secret value in AWS Secrets Manager: +```json +{"username": "alice", "password": "s3cr3t"} +``` + +Collector config: +```yaml +extensions: + awssecretsmanagerauth/client: + secret_arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-exporter-creds" + refresh_interval: 30s + client_auth: {} # uses "username" and "password" keys by default + +exporters: + otlp: + endpoint: https://my-collector.example.com:4317 + auth: + authenticator: awssecretsmanagerauth/client + +service: + extensions: [awssecretsmanagerauth/client] + pipelines: + traces: + receivers: [otlp] + processors: [] + exporters: [otlp] +``` + +### Client authenticator — custom JSON key names + +Secret value: +```json +{"user": "alice", "pass": "s3cr3t"} +``` + +```yaml +extensions: + awssecretsmanagerauth/client: + secret_arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-creds" + refresh_interval: 60s + client_auth: + username_key: user + password_key: pass +``` + +### Server authenticator — inbound requests (raw htpasswd) + +Secret value (raw htpasswd content): +``` +alice:$2y$05$abcdefghijklmnopqrstuuVGnzYyZ1mBkYHpURsB7L9J2jl5Rp2Cy +bob:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= +``` + +```yaml +extensions: + awssecretsmanagerauth/server: + secret_arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-htpasswd" + refresh_interval: 30s + htpasswd: {} # uses the entire secret string as htpasswd content + +receivers: + otlp: + protocols: + http: + auth: + authenticator: awssecretsmanagerauth/server + +service: + extensions: [awssecretsmanagerauth/server] + pipelines: + traces: + receivers: [otlp] + processors: [] + exporters: [debug] +``` + +### Server authenticator — htpasswd stored as a JSON field + +Secret value: +```json +{"htpasswd": "alice:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g="} +``` + +```yaml +extensions: + awssecretsmanagerauth/server: + secret_arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-htpasswd-json" + refresh_interval: 30s + htpasswd: + value_key: htpasswd +``` + +## Rotation + +The extension polls AWS Secrets Manager every `refresh_interval`. AWS rotates secrets by publishing a new secret version; the extension detects the new `VersionId` and swaps credentials atomically without restarting the collector. + +To rotate manually: +```bash +aws secretsmanager put-secret-value \ + --secret-id "my-exporter-creds" \ + --secret-string '{"username":"alice","password":"newpassword"}' +``` + +The collector will pick up the new credentials within one `refresh_interval`. diff --git a/extension/awssecretsmanagerauthextension/config.go b/extension/awssecretsmanagerauthextension/config.go new file mode 100644 index 0000000000000..de93e6d48f12b --- /dev/null +++ b/extension/awssecretsmanagerauthextension/config.go @@ -0,0 +1,75 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awssecretsmanagerauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension" + +import ( + "errors" + "fmt" + "time" +) + +var ( + errNoCredentialSource = errors.New("no credential source provided") + errMultipleAuthenticators = errors.New("only one of `htpasswd` or `client_auth` can be specified") +) + +// HtpasswdSettings configures server-side authentication. The secret value is +// treated as htpasswd file content. If ValueKey is set, the secret must be a +// JSON object and the value at that key is used as the htpasswd content. +type HtpasswdSettings struct { + // ValueKey is the JSON key in the secret whose value contains the htpasswd content. + // If empty, the entire secret string is used as-is. + ValueKey string `mapstructure:"value_key"` + // prevent unkeyed literal initialization + _ struct{} +} + +// ClientAuthSettings configures client-side authentication. The secret must be +// a JSON object. UsernameKey and PasswordKey identify which fields to use. +type ClientAuthSettings struct { + // UsernameKey is the JSON key whose value is used as the username. + // Defaults to "username". + UsernameKey string `mapstructure:"username_key"` + // PasswordKey is the JSON key whose value is used as the password. + // Defaults to "password". + PasswordKey string `mapstructure:"password_key"` + // prevent unkeyed literal initialization + _ struct{} +} + +// Config defines the configuration for the awssecretsmanagerauth extension. +type Config struct { + // SecretARN is the ARN or name of the secret in AWS Secrets Manager. + SecretARN string `mapstructure:"secret_arn"` + // RefreshInterval controls how often the extension polls for a new secret version. + RefreshInterval time.Duration `mapstructure:"refresh_interval"` + // Htpasswd configures server-side (inbound) authentication. + // Exactly one of Htpasswd or ClientAuth must be set. + Htpasswd *HtpasswdSettings `mapstructure:"htpasswd,omitempty"` + // ClientAuth configures client-side (outbound) authentication. + // Exactly one of Htpasswd or ClientAuth must be set. + ClientAuth *ClientAuthSettings `mapstructure:"client_auth,omitempty"` + // prevent unkeyed literal initialization + _ struct{} +} + +func (cfg *Config) Validate() error { + if cfg.SecretARN == "" { + return errors.New("secret_arn must not be empty") + } + if cfg.RefreshInterval <= 0 { + return fmt.Errorf("refresh_interval must be positive, got %s", cfg.RefreshInterval) + } + + serverSet := cfg.Htpasswd != nil + clientSet := cfg.ClientAuth != nil + + if serverSet && clientSet { + return errMultipleAuthenticators + } + if !serverSet && !clientSet { + return errNoCredentialSource + } + return nil +} diff --git a/extension/awssecretsmanagerauthextension/doc.go b/extension/awssecretsmanagerauthextension/doc.go new file mode 100644 index 0000000000000..c97c0626bc7de --- /dev/null +++ b/extension/awssecretsmanagerauthextension/doc.go @@ -0,0 +1,7 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package awssecretsmanagerauthextension provides an authenticator extension +// that sources HTTP Basic Auth credentials from AWS Secrets Manager and +// periodically rotates them at a configurable interval. +package awssecretsmanagerauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension" diff --git a/extension/awssecretsmanagerauthextension/extension.go b/extension/awssecretsmanagerauthextension/extension.go new file mode 100644 index 0000000000000..57ca449732ebf --- /dev/null +++ b/extension/awssecretsmanagerauthextension/extension.go @@ -0,0 +1,315 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awssecretsmanagerauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension" + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync/atomic" + + "github.com/tg123/go-htpasswd" + "go.opentelemetry.io/collector/client" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension" + "go.opentelemetry.io/collector/extension/extensionauth" + "go.uber.org/zap" + creds "google.golang.org/grpc/credentials" +) + +var ( + errNoAuth = errors.New("no basic auth provided") + errInvalidCredentials = errors.New("invalid credentials") + errInvalidSchemePrefix = errors.New("invalid authorization scheme prefix") + errInvalidFormat = errors.New("invalid authorization format") +) + +// ---- server auth ---- + +var ( + _ extension.Extension = (*serverAuth)(nil) + _ extensionauth.Server = (*serverAuth)(nil) +) + +type serverAuth struct { + cfg *Config + logger *zap.Logger + matchFunc atomic.Pointer[func(username, password string) bool] + poller *poller +} + +func newServerAuth(cfg *Config, logger *zap.Logger) *serverAuth { + return &serverAuth{cfg: cfg, logger: logger} +} + +func (s *serverAuth) Start(ctx context.Context, _ component.Host) error { + p, err := newPoller(s.cfg, s.logger) + if err != nil { + return fmt.Errorf("init AWS Secrets Manager poller: %w", err) + } + return s.startWithPoller(ctx, p) +} + +func (s *serverAuth) startWithClient(ctx context.Context, c secretsManagerClient) error { + return s.startWithPoller(ctx, &poller{ + client: c, + secretARN: s.cfg.SecretARN, + refreshInterval: s.cfg.RefreshInterval, + logger: s.logger, + }) +} + +func (s *serverAuth) startWithPoller(ctx context.Context, p *poller) error { + s.poller = p + + valueKey := s.cfg.Htpasswd.ValueKey + return s.poller.Start(ctx, func(secretValue string) { + content := secretValue + if valueKey != "" { + var m map[string]string + if jsonErr := json.Unmarshal([]byte(secretValue), &m); jsonErr != nil { + s.logger.Error("failed to parse AWS secret as JSON for htpasswd", zap.Error(jsonErr)) + return + } + v, ok := m[valueKey] + if !ok { + s.logger.Error("value_key not found in secret", zap.String("key", valueKey)) + return + } + content = v + } + if reloadErr := s.reload(content); reloadErr != nil { + s.logger.Error("failed to reload htpasswd from AWS secret", zap.Error(reloadErr)) + } + }) +} + +func (s *serverAuth) Shutdown(_ context.Context) error { + if s.poller != nil { + s.poller.Shutdown() + } + return nil +} + +func (s *serverAuth) reload(content string) error { + htp, err := htpasswd.NewFromReader(strings.NewReader(content), htpasswd.DefaultSystems, nil) + if err != nil { + return fmt.Errorf("parse htpasswd content: %w", err) + } + fn := htp.Match + s.matchFunc.Store(&fn) + return nil +} + +func (s *serverAuth) Authenticate(ctx context.Context, headers map[string][]string) (context.Context, error) { + auth := getAuthHeader(headers) + if auth == "" { + return ctx, errNoAuth + } + ad, err := parseBasicAuth(auth) + if err != nil { + return ctx, err + } + fn := s.matchFunc.Load() + if fn == nil || !(*fn)(ad.username, ad.password) { + return ctx, errInvalidCredentials + } + cl := client.FromContext(ctx) + cl.Auth = ad + return client.NewContext(ctx, cl), nil +} + +// ---- client auth ---- + +var ( + _ extension.Extension = (*clientAuth)(nil) + _ extensionauth.HTTPClient = (*clientAuth)(nil) + _ extensionauth.GRPCClient = (*clientAuth)(nil) +) + +type clientAuth struct { + cfg *Config + logger *zap.Logger + poller *poller + awsUsername atomic.Pointer[string] + awsPassword atomic.Pointer[string] + grpcMetadata atomic.Pointer[map[string]string] +} + +func newClientAuth(cfg *Config, logger *zap.Logger) *clientAuth { + return &clientAuth{cfg: cfg, logger: logger} +} + +func (c *clientAuth) Start(ctx context.Context, _ component.Host) error { + p, err := newPoller(c.cfg, c.logger) + if err != nil { + return fmt.Errorf("init AWS Secrets Manager poller: %w", err) + } + return c.startWithPoller(ctx, p) +} + +func (c *clientAuth) startWithClient(ctx context.Context, cl secretsManagerClient) error { + return c.startWithPoller(ctx, &poller{ + client: cl, + secretARN: c.cfg.SecretARN, + refreshInterval: c.cfg.RefreshInterval, + logger: c.logger, + }) +} + +func (c *clientAuth) startWithPoller(ctx context.Context, p *poller) error { + c.poller = p + + usernameKey := c.cfg.ClientAuth.UsernameKey + if usernameKey == "" { + usernameKey = "username" + } + passwordKey := c.cfg.ClientAuth.PasswordKey + if passwordKey == "" { + passwordKey = "password" + } + + return c.poller.Start(ctx, func(secretValue string) { + var m map[string]string + if jsonErr := json.Unmarshal([]byte(secretValue), &m); jsonErr != nil { + c.logger.Error("failed to parse AWS secret as JSON for client auth", zap.Error(jsonErr)) + return + } + u := m[usernameKey] + p := m[passwordKey] + c.awsUsername.Store(&u) + c.awsPassword.Store(&p) + c.updateGRPCMetadata() + }) +} + +func (c *clientAuth) Shutdown(_ context.Context) error { + if c.poller != nil { + c.poller.Shutdown() + } + return nil +} + +func (c *clientAuth) updateGRPCMetadata() { + encoded := base64.StdEncoding.EncodeToString([]byte(c.username() + ":" + c.password())) + m := map[string]string{"authorization": fmt.Sprintf("Basic %s", encoded)} + c.grpcMetadata.Store(&m) +} + +func (c *clientAuth) username() string { + if u := c.awsUsername.Load(); u != nil { + return *u + } + return "" +} + +func (c *clientAuth) password() string { + if p := c.awsPassword.Load(); p != nil { + return *p + } + return "" +} + +func (c *clientAuth) RoundTripper(base http.RoundTripper) (http.RoundTripper, error) { + if strings.Contains(c.username(), ":") { + return nil, errInvalidFormat + } + return &roundTripper{base: base, client: c}, nil +} + +func (c *clientAuth) PerRPCCredentials() (creds.PerRPCCredentials, error) { + if strings.Contains(c.username(), ":") { + return nil, errInvalidFormat + } + return &perRPCAuth{client: c}, nil +} + +type roundTripper struct { + base http.RoundTripper + client *clientAuth +} + +func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + newReq := req.Clone(req.Context()) + if newReq.Header == nil { + newReq.Header = make(http.Header) + } + newReq.SetBasicAuth(r.client.username(), r.client.password()) + return r.base.RoundTrip(newReq) +} + +type perRPCAuth struct { + client *clientAuth +} + +func (p *perRPCAuth) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { + return *p.client.grpcMetadata.Load(), nil +} + +func (*perRPCAuth) RequireTransportSecurity() bool { return true } + +// ---- shared helpers ---- + +func getAuthHeader(h map[string][]string) string { + const ( + canonicalKey = "Authorization" + metadataKey = "authorization" + ) + if v, ok := h[canonicalKey]; ok { + return v[0] + } + if v, ok := h[metadataKey]; ok { + return v[0] + } + for k, v := range h { + if strings.EqualFold(k, metadataKey) && len(v) > 0 { + return v[0] + } + } + return "" +} + +func parseBasicAuth(auth string) (*authData, error) { + const prefix = "Basic " + if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { + return nil, errInvalidSchemePrefix + } + encoded := auth[len(prefix):] + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, errInvalidFormat + } + before, after, ok := strings.Cut(string(decoded), ":") + if !ok { + return nil, errInvalidFormat + } + return &authData{username: before, password: after, raw: encoded}, nil +} + +var _ client.AuthData = (*authData)(nil) + +type authData struct { + username string + password string + raw string +} + +func (a *authData) GetAttribute(name string) any { + switch name { + case "username": + return a.username + case "raw": + return a.raw + default: + return nil + } +} + +func (*authData) GetAttributeNames() []string { + return []string{"username", "raw"} +} diff --git a/extension/awssecretsmanagerauthextension/extension_test.go b/extension/awssecretsmanagerauthextension/extension_test.go new file mode 100644 index 0000000000000..3dbed13259095 --- /dev/null +++ b/extension/awssecretsmanagerauthextension/extension_test.go @@ -0,0 +1,353 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awssecretsmanagerauthextension + +import ( + "context" + "crypto/sha1" //nolint:gosec + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "go.uber.org/zap/zaptest" +) + +// mockClient implements secretsManagerClient for testing. +type mockClient struct { + mu sync.Mutex + results []mockResult + idx int +} + +type mockResult struct { + secretString string + versionID string + err error +} + +func (m *mockClient) GetSecretValue(_ context.Context, _ *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + i := m.idx + if i >= len(m.results) { + i = len(m.results) - 1 + } + m.idx++ + r := m.results[i] + if r.err != nil { + return nil, r.err + } + return &secretsmanager.GetSecretValueOutput{ + SecretString: &r.secretString, + VersionId: &r.versionID, + }, nil +} + +func htpasswdEntry(username, password string) string { + h := sha1.Sum([]byte(password)) //nolint:gosec + return fmt.Sprintf("%s:{SHA}%s", username, base64.StdEncoding.EncodeToString(h[:])) +} + +func clientSecret(username, password string) string { + b, _ := json.Marshal(map[string]string{"username": username, "password": password}) + return string(b) +} + +// ---- poller tests ---- + +func TestPollerInitialFetch(t *testing.T) { + defer goleak.VerifyNone(t) + + mock := &mockClient{results: []mockResult{{secretString: "secret-value", versionID: "v1"}}} + p := &poller{client: mock, secretARN: "arn:test", refreshInterval: time.Hour, logger: zaptest.NewLogger(t)} + + var got string + require.NoError(t, p.Start(context.Background(), func(v string) { got = v })) + p.Shutdown() + + assert.Equal(t, "secret-value", got) + assert.Equal(t, "v1", p.lastVersionID) +} + +func TestPollerSkipsSameVersion(t *testing.T) { + defer goleak.VerifyNone(t) + + mock := &mockClient{results: []mockResult{ + {secretString: "val", versionID: "v1"}, + {secretString: "val", versionID: "v1"}, + }} + + calls := 0 + p := &poller{client: mock, secretARN: "arn:test", refreshInterval: 10 * time.Millisecond, logger: zaptest.NewLogger(t)} + require.NoError(t, p.Start(context.Background(), func(_ string) { calls++ })) + time.Sleep(50 * time.Millisecond) + p.Shutdown() + + assert.Equal(t, 1, calls) +} + +func TestPollerFiresOnVersionChange(t *testing.T) { + defer goleak.VerifyNone(t) + + mock := &mockClient{results: []mockResult{ + {secretString: "first", versionID: "v1"}, + {secretString: "second", versionID: "v2"}, + }} + + var mu sync.Mutex + var received []string + p := &poller{client: mock, secretARN: "arn:test", refreshInterval: 10 * time.Millisecond, logger: zaptest.NewLogger(t)} + require.NoError(t, p.Start(context.Background(), func(v string) { + mu.Lock() + received = append(received, v) + mu.Unlock() + })) + assert.Eventually(t, func() bool { + mu.Lock() + defer mu.Unlock() + return len(received) >= 2 + }, time.Second, 5*time.Millisecond) + p.Shutdown() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, []string{"first", "second"}, received[:2]) +} + +func TestPollerKeepsCredentialsOnFetchError(t *testing.T) { + defer goleak.VerifyNone(t) + + mock := &mockClient{results: []mockResult{ + {secretString: "initial", versionID: "v1"}, + {err: errors.New("network error")}, + }} + + calls := 0 + p := &poller{client: mock, secretARN: "arn:test", refreshInterval: 10 * time.Millisecond, logger: zaptest.NewLogger(t)} + require.NoError(t, p.Start(context.Background(), func(_ string) { calls++ })) + time.Sleep(50 * time.Millisecond) + p.Shutdown() + + assert.Equal(t, 1, calls) + assert.Equal(t, "v1", p.lastVersionID) +} + +func TestPollerShutdownStopsGoroutine(t *testing.T) { + defer goleak.VerifyNone(t) + + mock := &mockClient{results: []mockResult{{secretString: "v", versionID: "v1"}}} + p := &poller{client: mock, secretARN: "arn:test", refreshInterval: time.Hour, logger: zaptest.NewLogger(t)} + require.NoError(t, p.Start(context.Background(), func(_ string) {})) + p.Shutdown() +} + +// ---- client auth tests ---- + +func TestClientAuthSwapsCredentials(t *testing.T) { + defer goleak.VerifyNone(t) + + mock := &mockClient{results: []mockResult{ + {secretString: clientSecret("alice", "s3cr3t"), versionID: "v1"}, + {secretString: clientSecret("bob", "n3wp4ss"), versionID: "v2"}, + }} + + cfg := &Config{SecretARN: "arn:test", RefreshInterval: 10 * time.Millisecond, ClientAuth: &ClientAuthSettings{}} + ext := newClientAuth(cfg, zaptest.NewLogger(t)) + require.NoError(t, ext.startWithClient(context.Background(), mock)) + + assert.Equal(t, "alice", ext.username()) + assert.Equal(t, "s3cr3t", ext.password()) + + assert.Eventually(t, func() bool { return ext.username() == "bob" }, time.Second, 5*time.Millisecond) + assert.Equal(t, "n3wp4ss", ext.password()) + + require.NoError(t, ext.Shutdown(context.Background())) +} + +// captureTransport records the request it receives so tests can inspect the injected headers. +type captureTransport struct{ last *http.Request } + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + c.last = req + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func TestClientAuthRoundTripper(t *testing.T) { + defer goleak.VerifyNone(t) + + mock := &mockClient{results: []mockResult{ + {secretString: clientSecret("alice", "pass"), versionID: "v1"}, + }} + + cfg := &Config{SecretARN: "arn:test", RefreshInterval: time.Hour, ClientAuth: &ClientAuthSettings{}} + ext := newClientAuth(cfg, zaptest.NewLogger(t)) + require.NoError(t, ext.startWithClient(context.Background(), mock)) + + capture := &captureTransport{} + rt, err := ext.RoundTripper(capture) + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + _, _ = rt.RoundTrip(req) + require.NotNil(t, capture.last) + assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("alice:pass")), capture.last.Header.Get("Authorization")) + + require.NoError(t, ext.Shutdown(context.Background())) +} + +func TestClientAuthCustomKeys(t *testing.T) { + defer goleak.VerifyNone(t) + + secret, _ := json.Marshal(map[string]string{"user": "alice", "pass": "s3cr3t"}) + mock := &mockClient{results: []mockResult{{secretString: string(secret), versionID: "v1"}}} + + cfg := &Config{ + SecretARN: "arn:test", + RefreshInterval: time.Hour, + ClientAuth: &ClientAuthSettings{UsernameKey: "user", PasswordKey: "pass"}, + } + ext := newClientAuth(cfg, zaptest.NewLogger(t)) + require.NoError(t, ext.startWithClient(context.Background(), mock)) + + assert.Equal(t, "alice", ext.username()) + assert.Equal(t, "s3cr3t", ext.password()) + + require.NoError(t, ext.Shutdown(context.Background())) +} + +// ---- server auth tests ---- + +func TestServerAuthSwapsMatchFunc(t *testing.T) { + defer goleak.VerifyNone(t) + + htV1 := htpasswdEntry("alice", "pass1") + htV2 := htpasswdEntry("bob", "pass2") + + mock := &mockClient{results: []mockResult{ + {secretString: htV1, versionID: "v1"}, + {secretString: htV2, versionID: "v2"}, + }} + + cfg := &Config{SecretARN: "arn:test", RefreshInterval: 10 * time.Millisecond, Htpasswd: &HtpasswdSettings{}} + ext := newServerAuth(cfg, zaptest.NewLogger(t)) + require.NoError(t, ext.startWithClient(context.Background(), mock)) + + assert.Eventually(t, func() bool { + fn := ext.matchFunc.Load() + return fn != nil && (*fn)("alice", "pass1") + }, time.Second, 5*time.Millisecond) + assert.False(t, (*ext.matchFunc.Load())("bob", "pass2")) + + assert.Eventually(t, func() bool { + fn := ext.matchFunc.Load() + return fn != nil && (*fn)("bob", "pass2") + }, time.Second, 5*time.Millisecond) + assert.False(t, (*ext.matchFunc.Load())("alice", "pass1")) + + require.NoError(t, ext.Shutdown(context.Background())) +} + +func TestServerAuthAuthenticate(t *testing.T) { + defer goleak.VerifyNone(t) + + mock := &mockClient{results: []mockResult{ + {secretString: htpasswdEntry("alice", "pass1"), versionID: "v1"}, + }} + + cfg := &Config{SecretARN: "arn:test", RefreshInterval: time.Hour, Htpasswd: &HtpasswdSettings{}} + ext := newServerAuth(cfg, zaptest.NewLogger(t)) + require.NoError(t, ext.startWithClient(context.Background(), mock)) + + encoded := base64.StdEncoding.EncodeToString([]byte("alice:pass1")) + ctx, err := ext.Authenticate(context.Background(), map[string][]string{ + "Authorization": {"Basic " + encoded}, + }) + require.NoError(t, err) + assert.NotNil(t, ctx) + + _, err = ext.Authenticate(context.Background(), map[string][]string{ + "Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte("alice:wrong"))}, + }) + assert.ErrorIs(t, err, errInvalidCredentials) + + require.NoError(t, ext.Shutdown(context.Background())) +} + +func TestServerAuthValueKey(t *testing.T) { + defer goleak.VerifyNone(t) + + entry := htpasswdEntry("alice", "pass1") + secret, _ := json.Marshal(map[string]string{"htpasswd": entry}) + mock := &mockClient{results: []mockResult{{secretString: string(secret), versionID: "v1"}}} + + cfg := &Config{SecretARN: "arn:test", RefreshInterval: time.Hour, Htpasswd: &HtpasswdSettings{ValueKey: "htpasswd"}} + ext := newServerAuth(cfg, zaptest.NewLogger(t)) + require.NoError(t, ext.startWithClient(context.Background(), mock)) + + encoded := base64.StdEncoding.EncodeToString([]byte("alice:pass1")) + _, err := ext.Authenticate(context.Background(), map[string][]string{ + "Authorization": {"Basic " + encoded}, + }) + require.NoError(t, err) + + require.NoError(t, ext.Shutdown(context.Background())) +} + +// ---- config validation tests ---- + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "valid client auth", + cfg: Config{SecretARN: "arn:test", RefreshInterval: 30 * time.Second, ClientAuth: &ClientAuthSettings{}}, + }, + { + name: "valid htpasswd", + cfg: Config{SecretARN: "arn:test", RefreshInterval: 30 * time.Second, Htpasswd: &HtpasswdSettings{}}, + }, + { + name: "missing secret_arn", + cfg: Config{RefreshInterval: 30 * time.Second, ClientAuth: &ClientAuthSettings{}}, + wantErr: true, + }, + { + name: "zero refresh_interval", + cfg: Config{SecretARN: "arn:test", ClientAuth: &ClientAuthSettings{}}, + wantErr: true, + }, + { + name: "both modes set", + cfg: Config{SecretARN: "arn:test", RefreshInterval: 30 * time.Second, Htpasswd: &HtpasswdSettings{}, ClientAuth: &ClientAuthSettings{}}, + wantErr: true, + }, + { + name: "no mode set", + cfg: Config{SecretARN: "arn:test", RefreshInterval: 30 * time.Second}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/extension/awssecretsmanagerauthextension/factory.go b/extension/awssecretsmanagerauthextension/factory.go new file mode 100644 index 0000000000000..3f67ba9014d45 --- /dev/null +++ b/extension/awssecretsmanagerauthextension/factory.go @@ -0,0 +1,38 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awssecretsmanagerauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension" + +import ( + "context" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension" + + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension/internal/metadata" +) + +func NewFactory() extension.Factory { + return extension.NewFactory( + metadata.Type, + createDefaultConfig, + createExtension, + metadata.ExtensionStability, + ) +} + +func createDefaultConfig() component.Config { + return &Config{ + RefreshInterval: 30 * time.Second, + ClientAuth: &ClientAuthSettings{}, + } +} + +func createExtension(_ context.Context, set extension.Settings, cfg component.Config) (extension.Extension, error) { + c := cfg.(*Config) + if c.Htpasswd != nil { + return newServerAuth(c, set.Logger), nil + } + return newClientAuth(c, set.Logger), nil +} diff --git a/extension/awssecretsmanagerauthextension/go.mod b/extension/awssecretsmanagerauthextension/go.mod new file mode 100644 index 0000000000000..e21929c7c3dbb --- /dev/null +++ b/extension/awssecretsmanagerauthextension/go.mod @@ -0,0 +1,51 @@ +module github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension + +go 1.25.0 + +require ( + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 + github.com/stretchr/testify v1.11.1 + github.com/tg123/go-htpasswd v1.2.4 + go.opentelemetry.io/collector/client v1.58.0 + go.opentelemetry.io/collector/component v1.58.0 + go.opentelemetry.io/collector/extension v1.58.0 + go.opentelemetry.io/collector/extension/extensionauth v1.58.0 + go.uber.org/goleak v1.3.0 + go.uber.org/zap v1.28.0 + google.golang.org/grpc v1.81.0 +) + +require ( + github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/collector/featuregate v1.58.0 // indirect + go.opentelemetry.io/collector/internal/componentalias v0.152.0 // indirect + go.opentelemetry.io/collector/pdata v1.58.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/extension/awssecretsmanagerauthextension/go.sum b/extension/awssecretsmanagerauthextension/go.sum new file mode 100644 index 0000000000000..dc60589d995ae --- /dev/null +++ b/extension/awssecretsmanagerauthextension/go.sum @@ -0,0 +1,127 @@ +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 h1:JUGKqUnJHbXpS8uyuICP/zpQ+vXUIXW2zTEqjMLCqrY= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7/go.mod h1:l/cqI7ujYqBuTR6Ll13d9/gG/uUdlVzJ1UDltEEBTOo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU= +github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/collector/client v1.58.0 h1:82j32jaTjPUHKpEbdEQ1nHkqTBD2Qtuzc80HBcynJag= +go.opentelemetry.io/collector/client v1.58.0/go.mod h1:vib5K6C0F6y0i5ofWmO4VlYu9PHrJ5hyAQOkk74JvrY= +go.opentelemetry.io/collector/component v1.58.0 h1:GLHxFfw8jw3lRVX/h09Cc3inoR+2+XY93VxnrwHxY8Q= +go.opentelemetry.io/collector/component v1.58.0/go.mod h1:oAA7bLZUz/j1r16bQ9Cu/F+72xbQxlci8H2VmqtsAGE= +go.opentelemetry.io/collector/consumer v1.58.0 h1:R9qWrp4xlZTrT+Ph10vgrjnaIzkW20/RAbXs2F7PhPA= +go.opentelemetry.io/collector/consumer v1.58.0/go.mod h1:ptX5120b3Py+vqBUmjvl5VJ8yYpSQOto6Vv9b12f2d4= +go.opentelemetry.io/collector/extension v1.58.0 h1:dEndHFvE9XJ+A+9hpxD6cUEJxgtP9DRWgNPZVkzf2QM= +go.opentelemetry.io/collector/extension v1.58.0/go.mod h1:eiWWL+MwUOUMD18mo01sNLic9RZlRBbQqyRs3URbh3U= +go.opentelemetry.io/collector/extension/extensionauth v1.58.0 h1:G+sYoC2yshjfAF1hdthi9xfv3kDFFAC1G1WkgYe8af0= +go.opentelemetry.io/collector/extension/extensionauth v1.58.0/go.mod h1:1jwgMpThKn842SSTWSOti0JA+IwInkbMUJrBKhx/lW8= +go.opentelemetry.io/collector/featuregate v1.58.0 h1:Kh6Dpgbxywv/Q3D6qPehaSxNCxvr/U/ki7CL4y3udCo= +go.opentelemetry.io/collector/featuregate v1.58.0/go.mod h1:4ga1QBMPEejXXmpyJS8lmaRpknJ3Lb9Bvk6e420bUFU= +go.opentelemetry.io/collector/internal/componentalias v0.152.0 h1:5uwYJ+F37s882FLzcE8ZBvCyLtcGGQsRQrNkXxYMApk= +go.opentelemetry.io/collector/internal/componentalias v0.152.0/go.mod h1:RPRS7z22S6yb+hTYzr67HoYyyB+qCKPXMaYafvdPb5s= +go.opentelemetry.io/collector/internal/testutil v0.152.0 h1:8LGwekR7mLcUDhT1ofLmdnrHRFuUa3U7PBd95ZvJEjQ= +go.opentelemetry.io/collector/internal/testutil v0.152.0/go.mod h1:Jkjs6rkqs973LqgZ0Fe3zrokQRKULYXPIf4HuqStiEE= +go.opentelemetry.io/collector/pdata v1.58.0 h1:5Lxut3NxKp87066Pzt+3q7+JUuFI5B3teCyLZIF8wIs= +go.opentelemetry.io/collector/pdata v1.58.0/go.mod h1:4vZtODINbC/JF3eGocnatdImzbRHseOywIcr+aULjCg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/slim/otlp v1.10.0 h1:iR97Vs/ZDR+y9TfuP9b1XBtdPWeC+OMslIBmhcLU7jM= +go.opentelemetry.io/proto/slim/otlp v1.10.0/go.mod h1:lV9250stpjYLPNA5viFabIgP2QlUGRT1GdTgAf8SIUk= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.3.0 h1:RUF5rO0hAlgiJt1fzQVzcVs3vZVNHIcMLgOgG4rWNcQ= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.3.0/go.mod h1:I89cynRj8y+383o7tEQVg2SVA6SRgDVIouWPUVXjx0U= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.3.0 h1:CQvJSldHRUN6Z8jsUeYv8J0lXRvygALXIzsmAeCcZE0= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.3.0/go.mod h1:xSQ+mEfJe/GjK1LXEyVOoSI1N9JV9ZI923X5kup43W4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/extension/awssecretsmanagerauthextension/internal/metadata/generated_status.go b/extension/awssecretsmanagerauthextension/internal/metadata/generated_status.go new file mode 100644 index 0000000000000..2fc2993bf01dd --- /dev/null +++ b/extension/awssecretsmanagerauthextension/internal/metadata/generated_status.go @@ -0,0 +1,18 @@ +// Code generated by mdatagen. DO NOT EDIT. + +// Package metadata contains the autogenerated telemetry and +// build information for the extension/awssecretsmanagerauth component. +package metadata + +import ( + "go.opentelemetry.io/collector/component" +) + +var ( + Type = component.MustNewType("awssecretsmanagerauth") + ScopeName = "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension" +) + +const ( + ExtensionStability = component.StabilityLevelDevelopment +) diff --git a/extension/awssecretsmanagerauthextension/metadata.yaml b/extension/awssecretsmanagerauthextension/metadata.yaml new file mode 100644 index 0000000000000..f63329095b074 --- /dev/null +++ b/extension/awssecretsmanagerauthextension/metadata.yaml @@ -0,0 +1,16 @@ +type: awssecretsmanagerauth +display_name: AWS Secrets Manager Auth Extension + +status: + class: extension + stability: + development: [extension] + distributions: [contrib] + codeowners: + active: [] + +tests: + config: + secret_arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-basic-auth" + refresh_interval: 30s + client_auth: {} diff --git a/extension/awssecretsmanagerauthextension/poller.go b/extension/awssecretsmanagerauthextension/poller.go new file mode 100644 index 0000000000000..26b378e5e4294 --- /dev/null +++ b/extension/awssecretsmanagerauthextension/poller.go @@ -0,0 +1,100 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awssecretsmanagerauthextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awssecretsmanagerauthextension" + +import ( + "context" + "time" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "go.uber.org/zap" +) + +type secretsManagerClient interface { + GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) +} + +type poller struct { + client secretsManagerClient + secretARN string + refreshInterval time.Duration + logger *zap.Logger + cancel context.CancelFunc + lastVersionID string +} + +func newPoller(cfg *Config, logger *zap.Logger) (*poller, error) { + awsCfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + return nil, err + } + return &poller{ + client: secretsmanager.NewFromConfig(awsCfg), + secretARN: cfg.SecretARN, + refreshInterval: cfg.RefreshInterval, + logger: logger, + }, nil +} + +// Start fetches the secret immediately and calls onChange, then polls on RefreshInterval. +// onChange is called only when the secret VersionId changes. Start is non-blocking. +func (p *poller) Start(ctx context.Context, onChange func(string)) error { + value, versionID, err := p.fetch(ctx) + if err != nil { + return err + } + p.lastVersionID = versionID + onChange(value) + + pollCtx, cancel := context.WithCancel(ctx) + p.cancel = cancel + + go func() { + ticker := time.NewTicker(p.refreshInterval) + defer ticker.Stop() + for { + select { + case <-pollCtx.Done(): + return + case <-ticker.C: + v, vid, fetchErr := p.fetch(pollCtx) + if fetchErr != nil { + p.logger.Warn("failed to fetch secret from AWS Secrets Manager, keeping last known credentials", + zap.String("secret_arn", p.secretARN), + zap.Error(fetchErr)) + continue + } + if vid != p.lastVersionID { + p.lastVersionID = vid + onChange(v) + } + } + } + }() + + return nil +} + +func (p *poller) Shutdown() { + if p.cancel != nil { + p.cancel() + } +} + +func (p *poller) fetch(ctx context.Context) (value, versionID string, err error) { + out, err := p.client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ + SecretId: &p.secretARN, + }) + if err != nil { + return "", "", err + } + if out.SecretString != nil { + value = *out.SecretString + } + if out.VersionId != nil { + versionID = *out.VersionId + } + return value, versionID, nil +}