Skip to content

Commit 8737f53

Browse files
kevinschoonovertoddbaertbeeme1mr
authored
feat: add mTLS support to otel exporter (#1389)
## This PR The OpenTelemetry collectors in my production environment are configured to use TLS for uploading metrics / traces so this PR aims to - add the ability to use mTLS + self-signed certificates when exporting to the opentelemetry collector This is the 'quick and dirty' approach so wanted to make an initial PR to make sure the high level implementation is the approach you're looking for. ### Follow-up Tasks - [ ] update the documentation when this approach is approved ### How to test I am struggling to figure out how to test this with self signed certificates to give a specific set of commands you can run because the TLS connection is never successful (assuming this is because of my commands) ```bash openssl req -x509 -newkey rsa:4096 -keyout ca.key.pem -out ca.cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=localhost" openssl req -x509 -newkey rsa:4096 -keyout client.key.pem -out client.cert.pem -CA ca.cert.pem -CAkey ca.key.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=localhost" -addext "subjectAltName = IP:127.0.0.1" openssl req -x509 -newkey rsa:4096 -keyout server.key.pem -out server.cert.pem -CA ca.cert.pem -CAkey ca.key.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=localhost" -addext "subjectAltName = IP:127.0.0.1" ``` ; however, when I pull certificates from my production environment to test this works --------- Signed-off-by: Kevin Schoonover <[email protected]> Signed-off-by: Todd Baert <[email protected]> Co-authored-by: Todd Baert <[email protected]> Co-authored-by: Michael Beemer <[email protected]>
1 parent e7eb691 commit 8737f53

File tree

8 files changed

+544
-63
lines changed

8 files changed

+544
-63
lines changed

Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
FROM squidfunk/mkdocs-material:9.5
2-
RUN pip install mkdocs-include-markdown-plugin
2+
RUN pip install mkdocs-include-markdown-plugin

core/pkg/telemetry/builder.go

+73-9
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ package telemetry
22

33
import (
44
"context"
5+
"crypto/tls"
6+
"crypto/x509"
57
"fmt"
8+
"os"
69
"time"
710

811
"connectrpc.com/connect"
912
"connectrpc.com/otelconnect"
1013
"github.com/open-feature/flagd/core/pkg/logger"
14+
"github.com/open-feature/flagd/flagd/pkg/certreloader"
1115
"go.opentelemetry.io/otel"
1216
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
1317
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
@@ -20,6 +24,7 @@ import (
2024
semconv "go.opentelemetry.io/otel/semconv/v1.18.0"
2125
"go.uber.org/zap"
2226
"google.golang.org/grpc"
27+
"google.golang.org/grpc/credentials"
2328
"google.golang.org/grpc/credentials/insecure"
2429
)
2530

@@ -28,10 +33,18 @@ const (
2833
exportInterval = 2 * time.Second
2934
)
3035

36+
type CollectorConfig struct {
37+
Target string
38+
CertPath string
39+
KeyPath string
40+
ReloadInterval time.Duration
41+
CAPath string
42+
}
43+
3144
// Config of the telemetry runtime. These are expected to be mapped to start-up arguments
3245
type Config struct {
3346
MetricsExporter string
34-
CollectorTarget string
47+
CollectorConfig CollectorConfig
3548
}
3649

3750
func RegisterErrorHandling(log *logger.Logger) {
@@ -64,13 +77,13 @@ func BuildMetricsRecorder(
6477
// provide the grpc collector target. Providing empty target results in skipping provider & propagator registration.
6578
// This results in tracers having NoopTracerProvider and propagator having No-Op TextMapPropagator performing no action
6679
func BuildTraceProvider(ctx context.Context, logger *logger.Logger, svc string, svcVersion string, cfg Config) error {
67-
if cfg.CollectorTarget == "" {
80+
if cfg.CollectorConfig.Target == "" {
6881
logger.Debug("skipping trace provider setup as collector target is not set." +
6982
" Traces will use NoopTracerProvider provider and propagator will use no-Op TextMapPropagator")
7083
return nil
7184
}
7285

73-
exporter, err := buildOtlpExporter(ctx, cfg.CollectorTarget)
86+
exporter, err := buildOtlpExporter(ctx, cfg.CollectorConfig)
7487
if err != nil {
7588
return err
7689
}
@@ -95,7 +108,7 @@ func BuildConnectOptions(cfg Config) ([]connect.HandlerOption, error) {
95108
options := []connect.HandlerOption{}
96109

97110
// add interceptor if configuration is available for collector
98-
if cfg.CollectorTarget != "" {
111+
if cfg.CollectorConfig.Target != "" {
99112
interceptor, err := otelconnect.NewInterceptor(otelconnect.WithTrustRemote())
100113
if err != nil {
101114
return nil, fmt.Errorf("error creating interceptor, %w", err)
@@ -107,6 +120,47 @@ func BuildConnectOptions(cfg Config) ([]connect.HandlerOption, error) {
107120
return options, nil
108121
}
109122

123+
func buildTransportCredentials(_ context.Context, cfg CollectorConfig) (credentials.TransportCredentials, error) {
124+
creds := insecure.NewCredentials()
125+
if cfg.KeyPath != "" || cfg.CertPath != "" || cfg.CAPath != "" {
126+
capool := x509.NewCertPool()
127+
if cfg.CAPath != "" {
128+
ca, err := os.ReadFile(cfg.CAPath)
129+
if err != nil {
130+
return nil, fmt.Errorf("can't read ca file from %s", cfg.CAPath)
131+
}
132+
if !capool.AppendCertsFromPEM(ca) {
133+
return nil, fmt.Errorf("can't add CA '%s' to pool", cfg.CAPath)
134+
}
135+
}
136+
137+
reloader, err := certreloader.NewCertReloader(certreloader.Config{
138+
KeyPath: cfg.KeyPath,
139+
CertPath: cfg.CertPath,
140+
ReloadInterval: cfg.ReloadInterval,
141+
})
142+
if err != nil {
143+
return nil, fmt.Errorf("failed to create certreloader: %w", err)
144+
}
145+
146+
tlsConfig := &tls.Config{
147+
RootCAs: capool,
148+
MinVersion: tls.VersionTLS13,
149+
GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
150+
certs, err := reloader.GetCertificate()
151+
if err != nil {
152+
return nil, fmt.Errorf("failed to reload certs: %w", err)
153+
}
154+
return certs, nil
155+
},
156+
}
157+
158+
creds = credentials.NewTLS(tlsConfig)
159+
}
160+
161+
return creds, nil
162+
}
163+
110164
// buildMetricReader builds a metric reader based on provided configurations
111165
func buildMetricReader(ctx context.Context, cfg Config) (metric.Reader, error) {
112166
if cfg.MetricsExporter == "" {
@@ -120,13 +174,18 @@ func buildMetricReader(ctx context.Context, cfg Config) (metric.Reader, error) {
120174
}
121175

122176
// Otel override require target configuration
123-
if cfg.CollectorTarget == "" {
177+
if cfg.CollectorConfig.Target == "" {
124178
return nil, fmt.Errorf("metric exporter is set(%s) without providing otel collector target."+
125179
" collector target is required for this option", cfg.MetricsExporter)
126180
}
127181

182+
transportCredentials, err := buildTransportCredentials(ctx, cfg.CollectorConfig)
183+
if err != nil {
184+
return nil, fmt.Errorf("metric export would not build transport credentials: %w", err)
185+
}
186+
128187
// Non-blocking, insecure grpc connection
129-
conn, err := grpc.NewClient(cfg.CollectorTarget, grpc.WithTransportCredentials(insecure.NewCredentials()))
188+
conn, err := grpc.NewClient(cfg.CollectorConfig.Target, grpc.WithTransportCredentials(transportCredentials))
130189
if err != nil {
131190
return nil, fmt.Errorf("error creating client connection: %w", err)
132191
}
@@ -141,9 +200,14 @@ func buildMetricReader(ctx context.Context, cfg Config) (metric.Reader, error) {
141200
}
142201

143202
// buildOtlpExporter is a helper to build grpc backed otlp trace exporter
144-
func buildOtlpExporter(ctx context.Context, collectorTarget string) (*otlptrace.Exporter, error) {
145-
// Non-blocking, insecure grpc connection
146-
conn, err := grpc.NewClient(collectorTarget, grpc.WithTransportCredentials(insecure.NewCredentials()))
203+
func buildOtlpExporter(ctx context.Context, cfg CollectorConfig) (*otlptrace.Exporter, error) {
204+
transportCredentials, err := buildTransportCredentials(ctx, cfg)
205+
if err != nil {
206+
return nil, fmt.Errorf("metric export would not build transport credentials: %w", err)
207+
}
208+
209+
// Non-blocking, grpc connection
210+
conn, err := grpc.NewClient(cfg.Target, grpc.WithTransportCredentials(transportCredentials))
147211
if err != nil {
148212
return nil, fmt.Errorf("error creating client connection: %w", err)
149213
}

core/pkg/telemetry/builder_test.go

+15-5
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ func TestBuildMetricsRecorder(t *testing.T) {
2121
// Simple happy-path test
2222
recorder, err := BuildMetricsRecorder(context.Background(), "service", "0.0.1", Config{
2323
MetricsExporter: "otel",
24-
CollectorTarget: "localhost:8080",
24+
CollectorConfig: CollectorConfig{
25+
Target: "localhost:8080",
26+
},
2527
})
2628

2729
require.Nil(t, err, "expected no error, but got: %v", err)
@@ -52,15 +54,19 @@ func TestBuildMetricReader(t *testing.T) {
5254
name: "Metric exporter overriding require valid configuration combination",
5355
cfg: Config{
5456
MetricsExporter: metricsExporterOtel,
55-
CollectorTarget: "", // collector target is unset
57+
CollectorConfig: CollectorConfig{
58+
Target: "", // collector target is unset
59+
},
5660
},
5761
error: true,
5862
},
5963
{
6064
name: "Metric exporter overriding with valid configurations",
6165
cfg: Config{
6266
MetricsExporter: metricsExporterOtel,
63-
CollectorTarget: "localhost:8080",
67+
CollectorConfig: CollectorConfig{
68+
Target: "localhost:8080",
69+
},
6470
},
6571
error: false,
6672
},
@@ -90,7 +96,9 @@ func TestBuildSpanProcessor(t *testing.T) {
9096
{
9197
name: "Valid configurations yield a valid processor",
9298
cfg: Config{
93-
CollectorTarget: "localhost:8080",
99+
CollectorConfig: CollectorConfig{
100+
Target: "localhost:8080",
101+
},
94102
},
95103
error: false,
96104
},
@@ -127,7 +135,9 @@ func TestBuildConnectOptions(t *testing.T) {
127135
{
128136
name: "Connect option is set when telemetry target is set",
129137
cfg: Config{
130-
CollectorTarget: "localhost:8080",
138+
CollectorConfig: CollectorConfig{
139+
Target: "localhost:8080",
140+
},
131141
},
132142
optionCount: 1,
133143
},

docs/reference/flagd-cli/flagd_start.md

+18-14
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,24 @@ flagd start [flags]
1111
### Options
1212

1313
```
14-
-C, --cors-origin strings CORS allowed origins, * will allow all origins
15-
-h, --help help for start
16-
-z, --log-format string Set the logging format, e.g. console or json (default "console")
17-
-m, --management-port int32 Port for management operations (default 8014)
18-
-t, --metrics-exporter string Set the metrics exporter. Default(if unset) is Prometheus. Can be override to otel - OpenTelemetry metric exporter. Overriding to otel require otelCollectorURI to be present
19-
-r, --ofrep-port int32 ofrep service port (default 8016)
20-
-o, --otel-collector-uri string Set the grpc URI of the OpenTelemetry collector for flagd runtime. If unset, the collector setup will be ignored and traces will not be exported.
21-
-p, --port int32 Port to listen on (default 8013)
22-
-c, --server-cert-path string Server side tls certificate path
23-
-k, --server-key-path string Server side tls key path
24-
-d, --socket-path string Flagd socket path. With grpc the service will become available on this address. With http(s) the grpc-gateway proxy will use this address internally.
25-
-s, --sources string JSON representation of an array of SourceConfig objects. This object contains 2 required fields, uri (string) and provider (string). Documentation for this object: https://flagd.dev/reference/sync-configuration/#source-configuration
26-
-g, --sync-port int32 gRPC Sync port (default 8015)
27-
-f, --uri .yaml/.yml/.json Set a sync provider uri to read data from, this can be a filepath, URL (HTTP and gRPC) or FeatureFlag custom resource. When flag keys are duplicated across multiple providers the merge priority follows the index of the flag arguments, as such flags from the uri at index 0 take the lowest precedence, with duplicated keys being overwritten by those from the uri at index 1. Please note that if you are using filepath, flagd only supports files with .yaml/.yml/.json extension.
14+
-C, --cors-origin strings CORS allowed origins, * will allow all origins
15+
-h, --help help for start
16+
-z, --log-format string Set the logging format, e.g. console or json (default "console")
17+
-m, --management-port int32 Port for management operations (default 8014)
18+
-t, --metrics-exporter string Set the metrics exporter. Default(if unset) is Prometheus. Can be override to otel - OpenTelemetry metric exporter. Overriding to otel require otelCollectorURI to be present
19+
-r, --ofrep-port int32 ofrep service port (default 8016)
20+
-A, --otel-ca-path string tls certificate authority path to use with OpenTelemetry collector
21+
-D, --otel-cert-path string tls certificate path to use with OpenTelemetry collector
22+
-o, --otel-collector-uri string Set the grpc URI of the OpenTelemetry collector for flagd runtime. If unset, the collector setup will be ignored and traces will not be exported.
23+
-K, --otel-key-path string tls key path to use with OpenTelemetry collector
24+
-I, --otel-reload-interval duration how long between reloading the otel tls certificate from disk (default 1h0m0s)
25+
-p, --port int32 Port to listen on (default 8013)
26+
-c, --server-cert-path string Server side tls certificate path
27+
-k, --server-key-path string Server side tls key path
28+
-d, --socket-path string Flagd socket path. With grpc the service will become available on this address. With http(s) the grpc-gateway proxy will use this address internally.
29+
-s, --sources string JSON representation of an array of SourceConfig objects. This object contains 2 required fields, uri (string) and provider (string). Documentation for this object: https://flagd.dev/reference/sync-configuration/#source-configuration
30+
-g, --sync-port int32 gRPC Sync port (default 8015)
31+
-f, --uri .yaml/.yml/.json Set a sync provider uri to read data from, this can be a filepath, URL (HTTP and gRPC) or FeatureFlag custom resource. When flag keys are duplicated across multiple providers the merge priority follows the index of the flag arguments, as such flags from the uri at index 0 take the lowest precedence, with duplicated keys being overwritten by those from the uri at index 1. Please note that if you are using filepath, flagd only supports files with .yaml/.yml/.json extension.
2832
```
2933

3034
### Options inherited from parent commands

flagd/cmd/start.go

+41-24
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"log"
66
"strings"
7+
"time"
78

89
"github.com/open-feature/flagd/core/pkg/logger"
910
"github.com/open-feature/flagd/core/pkg/sync"
@@ -16,19 +17,23 @@ import (
1617
)
1718

1819
const (
19-
corsFlagName = "cors-origin"
20-
logFormatFlagName = "log-format"
21-
managementPortFlagName = "management-port"
22-
metricsExporter = "metrics-exporter"
23-
ofrepPortFlagName = "ofrep-port"
24-
otelCollectorURI = "otel-collector-uri"
25-
portFlagName = "port"
26-
serverCertPathFlagName = "server-cert-path"
27-
serverKeyPathFlagName = "server-key-path"
28-
socketPathFlagName = "socket-path"
29-
sourcesFlagName = "sources"
30-
syncPortFlagName = "sync-port"
31-
uriFlagName = "uri"
20+
corsFlagName = "cors-origin"
21+
logFormatFlagName = "log-format"
22+
managementPortFlagName = "management-port"
23+
metricsExporter = "metrics-exporter"
24+
ofrepPortFlagName = "ofrep-port"
25+
otelCollectorURI = "otel-collector-uri"
26+
otelCertPathFlagName = "otel-cert-path"
27+
otelKeyPathFlagName = "otel-key-path"
28+
otelCAPathFlagName = "otel-ca-path"
29+
otelReloadIntervalFlagName = "otel-reload-interval"
30+
portFlagName = "port"
31+
serverCertPathFlagName = "server-cert-path"
32+
serverKeyPathFlagName = "server-key-path"
33+
socketPathFlagName = "socket-path"
34+
sourcesFlagName = "sources"
35+
syncPortFlagName = "sync-port"
36+
uriFlagName = "uri"
3237
)
3338

3439
func init() {
@@ -67,12 +72,20 @@ func init() {
6772
" be present")
6873
flags.StringP(otelCollectorURI, "o", "", "Set the grpc URI of the OpenTelemetry collector "+
6974
"for flagd runtime. If unset, the collector setup will be ignored and traces will not be exported.")
75+
flags.StringP(otelCertPathFlagName, "D", "", "tls certificate path to use with OpenTelemetry collector")
76+
flags.StringP(otelKeyPathFlagName, "K", "", "tls key path to use with OpenTelemetry collector")
77+
flags.StringP(otelCAPathFlagName, "A", "", "tls certificate authority path to use with OpenTelemetry collector")
78+
flags.DurationP(otelReloadIntervalFlagName, "I", time.Hour, "how long between reloading the otel tls certificate "+
79+
"from disk")
7080

7181
_ = viper.BindPFlag(corsFlagName, flags.Lookup(corsFlagName))
7282
_ = viper.BindPFlag(logFormatFlagName, flags.Lookup(logFormatFlagName))
7383
_ = viper.BindPFlag(metricsExporter, flags.Lookup(metricsExporter))
7484
_ = viper.BindPFlag(managementPortFlagName, flags.Lookup(managementPortFlagName))
7585
_ = viper.BindPFlag(otelCollectorURI, flags.Lookup(otelCollectorURI))
86+
_ = viper.BindPFlag(otelCertPathFlagName, flags.Lookup(otelCertPathFlagName))
87+
_ = viper.BindPFlag(otelKeyPathFlagName, flags.Lookup(otelKeyPathFlagName))
88+
_ = viper.BindPFlag(otelCAPathFlagName, flags.Lookup(otelCAPathFlagName))
7689
_ = viper.BindPFlag(portFlagName, flags.Lookup(portFlagName))
7790
_ = viper.BindPFlag(serverCertPathFlagName, flags.Lookup(serverCertPathFlagName))
7891
_ = viper.BindPFlag(serverKeyPathFlagName, flags.Lookup(serverKeyPathFlagName))
@@ -127,17 +140,21 @@ var startCmd = &cobra.Command{
127140

128141
// Build Runtime -----------------------------------------------------------
129142
rt, err := runtime.FromConfig(logger, Version, runtime.Config{
130-
CORS: viper.GetStringSlice(corsFlagName),
131-
MetricExporter: viper.GetString(metricsExporter),
132-
ManagementPort: viper.GetUint16(managementPortFlagName),
133-
OfrepServicePort: viper.GetUint16(ofrepPortFlagName),
134-
OtelCollectorURI: viper.GetString(otelCollectorURI),
135-
ServiceCertPath: viper.GetString(serverCertPathFlagName),
136-
ServiceKeyPath: viper.GetString(serverKeyPathFlagName),
137-
ServicePort: viper.GetUint16(portFlagName),
138-
ServiceSocketPath: viper.GetString(socketPathFlagName),
139-
SyncServicePort: viper.GetUint16(syncPortFlagName),
140-
SyncProviders: syncProviders,
143+
CORS: viper.GetStringSlice(corsFlagName),
144+
MetricExporter: viper.GetString(metricsExporter),
145+
ManagementPort: viper.GetUint16(managementPortFlagName),
146+
OfrepServicePort: viper.GetUint16(ofrepPortFlagName),
147+
OtelCollectorURI: viper.GetString(otelCollectorURI),
148+
OtelCertPath: viper.GetString(otelCertPathFlagName),
149+
OtelKeyPath: viper.GetString(otelKeyPathFlagName),
150+
OtelReloadInterval: viper.GetDuration(otelReloadIntervalFlagName),
151+
OtelCAPath: viper.GetString(otelCAPathFlagName),
152+
ServiceCertPath: viper.GetString(serverCertPathFlagName),
153+
ServiceKeyPath: viper.GetString(serverKeyPathFlagName),
154+
ServicePort: viper.GetUint16(portFlagName),
155+
ServiceSocketPath: viper.GetString(socketPathFlagName),
156+
SyncServicePort: viper.GetUint16(syncPortFlagName),
157+
SyncProviders: syncProviders,
141158
})
142159
if err != nil {
143160
rtLogger.Fatal(err.Error())

0 commit comments

Comments
 (0)