From 3029c41a5ce21015d04585bb707b2a632ac1f187 Mon Sep 17 00:00:00 2001 From: Isaac Flores Date: Tue, 25 Nov 2025 15:18:32 -0800 Subject: [PATCH 1/2] Add `ClientAddressMetadataKeys` http and grpc configuration to provide flexible client address determination. --- config/configgrpc/configgrpc.go | 79 ++++++++--- config/configgrpc/configgrpc_test.go | 146 +++++++++++++++++++- config/confighttp/client_test.go | 42 +++++- config/confighttp/clientinfohandler.go | 33 ++++- config/confighttp/clientinfohandler_test.go | 102 ++++++++++++++ config/confighttp/server.go | 10 +- 6 files changed, 376 insertions(+), 36 deletions(-) diff --git a/config/configgrpc/configgrpc.go b/config/configgrpc/configgrpc.go index 3bd98b0b2ad..d5efb3b358e 100644 --- a/config/configgrpc/configgrpc.go +++ b/config/configgrpc/configgrpc.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "math" + "net" "strings" "time" @@ -206,6 +207,11 @@ type ServerConfig struct { // Middlewares for the gRPC server. Middlewares []configmiddleware.Config `mapstructure:"middlewares,omitempty"` + // ClientAddressMetadataKeys list of metadata keys to determine the client address. + // Keys are processed in order, the first valid value is used to set the client.Addr. + // The client.Addr will default to using Peer address. + ClientAddressMetadataKeys []string `mapstructure:"client_address_metadata_keys,omitempty"` + // prevent unkeyed literal initialization _ struct{} } @@ -532,8 +538,8 @@ func (sc *ServerConfig) getGrpcServerOptions( // Enable OpenTelemetry observability plugin. - uInterceptors = append(uInterceptors, enhanceWithClientInformation(sc.IncludeMetadata)) - sInterceptors = append(sInterceptors, enhanceStreamWithClientInformation(sc.IncludeMetadata)) //nolint:contextcheck // context already handled + uInterceptors = append(uInterceptors, enhanceWithClientInformation(sc.IncludeMetadata, sc.ClientAddressMetadataKeys)) + sInterceptors = append(sInterceptors, enhanceStreamWithClientInformation(sc.IncludeMetadata, sc.ClientAddressMetadataKeys)) //nolint:contextcheck // context already handled opts = append(opts, grpc.StatsHandler(otelgrpc.NewServerHandler(otelOpts...)), grpc.ChainUnaryInterceptor(uInterceptors...), grpc.ChainStreamInterceptor(sInterceptors...)) @@ -571,37 +577,76 @@ func getGRPCCompressionName(compressionType configcompression.Type) (string, err // enhanceWithClientInformation intercepts the incoming RPC, replacing the incoming context with one that includes // a client.Info, potentially with the peer's address. -func enhanceWithClientInformation(includeMetadata bool) grpc.UnaryServerInterceptor { +func enhanceWithClientInformation(includeMetadata bool, clientAddrMetadataKeys []string) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - return handler(contextWithClient(ctx, includeMetadata), req) + return handler(contextWithClient(ctx, includeMetadata, clientAddrMetadataKeys), req) } } -func enhanceStreamWithClientInformation(includeMetadata bool) grpc.StreamServerInterceptor { +func enhanceStreamWithClientInformation(includeMetadata bool, clientAddrMetadataKeys []string) grpc.StreamServerInterceptor { return func(srv any, ss grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - return handler(srv, wrapServerStream(contextWithClient(ss.Context(), includeMetadata), ss)) + return handler(srv, wrapServerStream(contextWithClient(ss.Context(), includeMetadata, clientAddrMetadataKeys), ss)) } } -// contextWithClient attempts to add the peer address to the client.Info from the context. When no -// client.Info exists in the context, one is created. -func contextWithClient(ctx context.Context, includeMetadata bool) context.Context { +// contextWithClient attempts to add the client address to the client.Info from the context. +// The address is found by first checking the metadata using clientAddrMetadataKeys and +// falls back to the peer address. +// When no client.Info exists in the context, one is created. +func contextWithClient(ctx context.Context, includeMetadata bool, clientAddrMetadataKeys []string) context.Context { cl := client.FromContext(ctx) - if p, ok := peer.FromContext(ctx); ok { + md, mdExists := metadata.FromIncomingContext(ctx) + + var ip *net.IPAddr + if mdExists { + ip = getIP(md, clientAddrMetadataKeys) + } + if ip != nil { + cl.Addr = ip + } else if p, ok := peer.FromContext(ctx); ok { cl.Addr = p.Addr } - if includeMetadata { - if md, ok := metadata.FromIncomingContext(ctx); ok { - copiedMD := md.Copy() - if len(md[client.MetadataHostName]) == 0 && len(md[":authority"]) > 0 { - copiedMD[client.MetadataHostName] = md[":authority"] - } - cl.Metadata = client.NewMetadata(copiedMD) + + if includeMetadata && mdExists { + copiedMD := md.Copy() + if len(md[client.MetadataHostName]) == 0 && len(md[":authority"]) > 0 { + copiedMD[client.MetadataHostName] = md[":authority"] } + cl.Metadata = client.NewMetadata(copiedMD) } return client.NewContext(ctx, cl) } +// getIP checks keys in order to get an IP address. +// Returns the first valid IP address found, otherwise +// returns nil. +func getIP(md metadata.MD, keys []string) *net.IPAddr { + for _, key := range keys { + if values := md.Get(key); len(values) > 0 && values[0] != "" { + if ip := parseIP(values[0]); ip != nil { + return ip + } + } + } + return nil +} + +// parseIP parses the given string for an IP address. The input string might contain the port, +// but must not contain a protocol or path. Suitable for getting the IP part of a client connection. +func parseIP(source string) *net.IPAddr { + ipstr, _, err := net.SplitHostPort(source) + if err == nil { + source = ipstr + } + ip := net.ParseIP(source) + if ip != nil { + return &net.IPAddr{ + IP: ip, + } + } + return nil +} + func authUnaryServerInterceptor(server extensionauth.Server) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { headers, ok := metadata.FromIncomingContext(ctx) diff --git a/config/configgrpc/configgrpc_test.go b/config/configgrpc/configgrpc_test.go index 6c7185ae6f5..df5bff09fc7 100644 --- a/config/configgrpc/configgrpc_test.go +++ b/config/configgrpc/configgrpc_test.go @@ -784,10 +784,11 @@ func TestReceiveOnUnixDomainSocket(t *testing.T) { func TestContextWithClient(t *testing.T) { testCases := []struct { - desc string - input context.Context - doMetadata bool - expected client.Info + desc string + input context.Context + doMetadata bool + clientAddrKeys []string + expected client.Info }{ { desc: "no peer information, empty client", @@ -837,6 +838,53 @@ func TestContextWithClient(t *testing.T) { }, }, }, + { + desc: "empty client, existing IP gets overridden from metadata keys", + input: metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("x-forwarded-for", "1.1.1.1"), + ), + clientAddrKeys: []string{"x-forwarded-for", "x-real-ip"}, + expected: client.Info{ + Addr: &net.IPAddr{ + IP: net.IPv4(1, 1, 1, 1), + }, + }, + }, + { + desc: "existing client, existing IP gets overridden from metadata keys", + input: metadata.NewIncomingContext( + peer.NewContext(context.Background(), &peer.Peer{ + Addr: &net.IPAddr{ + IP: net.IPv4(1, 2, 3, 4), + }, + }), + metadata.Pairs("x-forwarded-for", "1.1.1.1"), + ), + clientAddrKeys: []string{"x-forwarded-for", "x-real-ip"}, + expected: client.Info{ + Addr: &net.IPAddr{ + IP: net.IPv4(1, 1, 1, 1), + }, + }, + }, + { + desc: "existing client, no valid IP in metadata existing overridden by defaulting to peer information", + input: metadata.NewIncomingContext( + peer.NewContext(context.Background(), &peer.Peer{ + Addr: &net.IPAddr{ + IP: net.IPv4(1, 2, 3, 4), + }, + }), + metadata.Pairs("x-forwarded-for", "", "x-real-ip", "invalid address"), + ), + clientAddrKeys: []string{"x-forwarded-for", "x-real-ip"}, + expected: client.Info{ + Addr: &net.IPAddr{ + IP: net.IPv4(1, 2, 3, 4), + }, + }, + }, { desc: "existing client with metadata", input: client.NewContext(context.Background(), client.Info{ @@ -880,12 +928,98 @@ func TestContextWithClient(t *testing.T) { } for _, tt := range testCases { t.Run(tt.desc, func(t *testing.T) { - cl := client.FromContext(contextWithClient(tt.input, tt.doMetadata)) + cl := client.FromContext(contextWithClient(tt.input, tt.doMetadata, tt.clientAddrKeys)) assert.Equal(t, tt.expected, cl) }) } } +func TestGetIP(t *testing.T) { + testCases := []struct { + name string + md metadata.MD + keys []string + expected *net.IPAddr + }{ + { + name: "valid ip in first key", + md: metadata.Pairs( + "x-forwarded-for", "1.2.3.4", + ), + keys: []string{"x-forwarded-for", "x-real-ip"}, + expected: &net.IPAddr{ + IP: net.IPv4(1, 2, 3, 4), + }, + }, + { + name: "valid ip in second key", + md: metadata.Pairs( + "x-forwarded-for", "", + "x-real-ip", "5.6.7.8", + ), + keys: []string{"x-forwarded-for", "x-real-ip"}, + expected: &net.IPAddr{ + IP: net.IPv4(5, 6, 7, 8), + }, + }, + { + name: "valid ip with port", + md: metadata.Pairs( + "x-forwarded-for", "1.2.3.4:8080", + ), + keys: []string{"x-forwarded-for"}, + expected: &net.IPAddr{ + IP: net.IPv4(1, 2, 3, 4), + }, + }, + { + name: "invalid ip falls to next key", + md: metadata.Pairs( + "x-forwarded-for", "invalid", + "x-real-ip", "9.10.11.12", + ), + keys: []string{"x-forwarded-for", "x-real-ip"}, + expected: &net.IPAddr{ + IP: net.IPv4(9, 10, 11, 12), + }, + }, + { + name: "no valid ip", + md: metadata.Pairs( + "x-forwarded-for", "invalid", + "x-real-ip", "also-invalid", + ), + keys: []string{"x-forwarded-for", "x-real-ip"}, + expected: nil, + }, + { + name: "empty keys", + md: metadata.Pairs("x-forwarded-for", "1.2.3.4"), + keys: []string{}, + expected: nil, + }, + { + name: "empty metadata", + md: metadata.MD{}, + keys: []string{"x-forwarded-for", "x-real-ip"}, + expected: nil, + }, + { + name: "missing key", + md: metadata.Pairs( + "other-header", "1.2.3.4", + ), + keys: []string{"x-forwarded-for", "x-real-ip"}, + expected: nil, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, getIP(tt.md, tt.keys)) + }) + } +} + func TestStreamInterceptorEnhancesClient(t *testing.T) { // prepare inCtx := peer.NewContext(context.Background(), &peer.Peer{ @@ -905,7 +1039,7 @@ func TestStreamInterceptorEnhancesClient(t *testing.T) { } // test - err := enhanceStreamWithClientInformation(false)(nil, stream, nil, handler) + err := enhanceStreamWithClientInformation(false, nil)(nil, stream, nil, handler) // verify require.NoError(t, err) diff --git a/config/confighttp/client_test.go b/config/confighttp/client_test.go index 4ec4e6e8cd8..f86a3620802 100644 --- a/config/confighttp/client_test.go +++ b/config/confighttp/client_test.go @@ -615,10 +615,11 @@ func TestHttpTransportOptions(t *testing.T) { func TestContextWithClient(t *testing.T) { testCases := []struct { - name string - input *http.Request - doMetadata bool - expected client.Info + name string + input *http.Request + doMetadata bool + clientAddrKeys []string + expected client.Info }{ { name: "request without client IP or headers", @@ -626,7 +627,7 @@ func TestContextWithClient(t *testing.T) { expected: client.Info{}, }, { - name: "request with client IP", + name: "request with client IP from RemoteAddr", input: &http.Request{ RemoteAddr: "1.2.3.4:55443", }, @@ -636,6 +637,35 @@ func TestContextWithClient(t *testing.T) { }, }, }, + { + name: "request with client IP from metadata keys", + input: &http.Request{ + RemoteAddr: "1.2.3.4:55443", + Header: map[string][]string{http.CanonicalHeaderKey("x-forwarded-for"): {"1.1.1.1"}}, + }, + clientAddrKeys: []string{"x-forwarded-for", "x-real-ip"}, + expected: client.Info{ + Addr: &net.IPAddr{ + IP: net.IPv4(1, 1, 1, 1), + }, + }, + }, + { + name: "request with client IP, no valid IP default to RemoteAddr", + input: &http.Request{ + RemoteAddr: "1.2.3.4:55443", + Header: map[string][]string{ + http.CanonicalHeaderKey("x-forwarded-for"): {""}, + http.CanonicalHeaderKey("x-real-ip"): {"invalid address"}, + }, + }, + clientAddrKeys: []string{"x-forwarded-for", "x-real-ip"}, + expected: client.Info{ + Addr: &net.IPAddr{ + IP: net.IPv4(1, 2, 3, 4), + }, + }, + }, { name: "request with client headers, no metadata processing", input: &http.Request{ @@ -668,7 +698,7 @@ func TestContextWithClient(t *testing.T) { } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - ctx := contextWithClient(tt.input, tt.doMetadata) + ctx := contextWithClient(tt.input, tt.doMetadata, tt.clientAddrKeys) assert.Equal(t, tt.expected, client.FromContext(ctx)) }) } diff --git a/config/confighttp/clientinfohandler.go b/config/confighttp/clientinfohandler.go index 67d09c6f885..75d078a2ad3 100644 --- a/config/confighttp/clientinfohandler.go +++ b/config/confighttp/clientinfohandler.go @@ -17,21 +17,29 @@ type clientInfoHandler struct { // include client metadata or not includeMetadata bool + + // metadata keys to determine the client address + clientAddrMetadataKeys []string } // ServeHTTP intercepts incoming HTTP requests, replacing the request's context with one that contains // a client.Info containing the client's IP address. func (h *clientInfoHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - req = req.WithContext(contextWithClient(req, h.includeMetadata)) //nolint:contextcheck //context already handled through contextWithClient + req = req.WithContext(contextWithClient(req, h.includeMetadata, h.clientAddrMetadataKeys)) //nolint:contextcheck //context already handled through contextWithClient h.next.ServeHTTP(w, req) } -// contextWithClient attempts to add the client IP address to the client.Info from the context. When no -// client.Info exists in the context, one is created. -func contextWithClient(req *http.Request, includeMetadata bool) context.Context { +// contextWithClient attempts to add the client IP address to the client.Info from the context. +// The address is found by first checking the metadata using clientAddrMetadataKeys and +// falls back to the request Remote address +// When no client.Info exists in the context, one is created. +func contextWithClient(req *http.Request, includeMetadata bool, clientAddrMetadataKeys []string) context.Context { cl := client.FromContext(req.Context()) - ip := parseIP(req.RemoteAddr) + var ip *net.IPAddr + if ip = getIP(req.Header, clientAddrMetadataKeys); ip == nil { + ip = parseIP(req.RemoteAddr) + } if ip != nil { cl.Addr = ip } @@ -49,6 +57,21 @@ func contextWithClient(req *http.Request, includeMetadata bool) context.Context return ctx } +// getIP checks keys in order to get an IP address. +// Returns the first valid IP address found, otherwise +// returns nil. +func getIP(header http.Header, keys []string) *net.IPAddr { + for _, key := range keys { + addr := header.Get(key) + if addr != "" { + if ip := parseIP(addr); ip != nil { + return ip + } + } + } + return nil +} + // parseIP parses the given string for an IP address. The input string might contain the port, // but must not contain a protocol or path. Suitable for getting the IP part of a client connection. func parseIP(source string) *net.IPAddr { diff --git a/config/confighttp/clientinfohandler_test.go b/config/confighttp/clientinfohandler_test.go index 492a96c9c3f..d95af00703a 100644 --- a/config/confighttp/clientinfohandler_test.go +++ b/config/confighttp/clientinfohandler_test.go @@ -50,3 +50,105 @@ func TestParseIP(t *testing.T) { }) } } + +func TestGetIP(t *testing.T) { + testCases := []struct { + name string + header http.Header + keys []string + expected *net.IPAddr + }{ + { + name: "valid ip in first key", + header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4"}, + }, + keys: []string{"X-Forwarded-For", "X-Real-IP"}, + expected: &net.IPAddr{ + IP: net.IPv4(1, 2, 3, 4), + }, + }, + { + name: "valid ip in second key", + header: func() http.Header { + h := make(http.Header) + h.Set("X-Real-IP", "5.6.7.8") + return h + }(), + keys: []string{"X-Forwarded-For", "X-Real-IP"}, + expected: &net.IPAddr{ + IP: net.IPv4(5, 6, 7, 8), + }, + }, + { + name: "valid ip with port", + header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4:8080"}, + }, + keys: []string{"X-Forwarded-For"}, + expected: &net.IPAddr{ + IP: net.IPv4(1, 2, 3, 4), + }, + }, + { + name: "invalid ip falls to next key", + header: func() http.Header { + h := make(http.Header) + h.Set("X-Forwarded-For", "invalid") + h.Set("X-Real-IP", "9.10.11.12") + return h + }(), + keys: []string{"X-Forwarded-For", "X-Real-IP"}, + expected: &net.IPAddr{ + IP: net.IPv4(9, 10, 11, 12), + }, + }, + { + name: "no valid ip", + header: http.Header{ + "X-Forwarded-For": []string{"invalid"}, + "X-Real-IP": []string{"also-invalid"}, + }, + keys: []string{"X-Forwarded-For", "X-Real-IP"}, + expected: nil, + }, + { + name: "empty keys", + header: http.Header{"X-Forwarded-For": []string{"1.2.3.4"}}, + keys: []string{}, + expected: nil, + }, + { + name: "empty header", + header: http.Header{}, + keys: []string{"X-Forwarded-For", "X-Real-IP"}, + expected: nil, + }, + { + name: "missing key", + header: http.Header{ + "Other-Header": []string{"1.2.3.4"}, + }, + keys: []string{"X-Forwarded-For", "X-Real-IP"}, + expected: nil, + }, + { + name: "empty string value skips to next key", + header: func() http.Header { + h := make(http.Header) + h.Set("X-Forwarded-For", "") + h.Set("X-Real-IP", "10.11.12.13") + return h + }(), + keys: []string{"X-Forwarded-For", "X-Real-IP"}, + expected: &net.IPAddr{ + IP: net.IPv4(10, 11, 12, 13), + }, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, getIP(tt.header, tt.keys)) + }) + } +} diff --git a/config/confighttp/server.go b/config/confighttp/server.go index f0f95e3f994..691ea511e27 100644 --- a/config/confighttp/server.go +++ b/config/confighttp/server.go @@ -96,6 +96,11 @@ type ServerConfig struct { // KeepAlivesEnabled controls whether HTTP keep-alives are enabled. // By default, keep-alives are always enabled. Only very resource-constrained environments should disable them. KeepAlivesEnabled bool `mapstructure:"keep_alives_enabled,omitempty"` + + // ClientAddressMetadataKeys list of metadata keys to determine the client address. + // Keys are processed in order, the first valid value is used to set the client.Addr. + // The client.Addr will default to using http RemoteAddr or grpc Peer values. + ClientAddressMetadataKeys []string `mapstructure:"client_address_metadata_keys,omitempty"` } // NewDefaultServerConfig returns ServerConfig type object with default values. @@ -262,8 +267,9 @@ func (sc *ServerConfig) ToServer(ctx context.Context, host component.Host, setti // wrap the current handler in an interceptor that will add client.Info to the request's context handler = &clientInfoHandler{ - next: handler, - includeMetadata: sc.IncludeMetadata, + next: handler, + includeMetadata: sc.IncludeMetadata, + clientAddrMetadataKeys: sc.ClientAddressMetadataKeys, } errorLog, err := zap.NewStdLogAt(settings.Logger, zapcore.ErrorLevel) From 41ac09c8acbb12632c3e4e047d0f508782bb0052 Mon Sep 17 00:00:00 2001 From: Isaac Flores Date: Wed, 26 Nov 2025 10:38:38 -0800 Subject: [PATCH 2/2] Update docs --- config/configgrpc/README.md | 2 ++ config/confighttp/README.md | 4 ++++ config/confighttp/clientinfohandler.go | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/config/configgrpc/README.md b/config/configgrpc/README.md index e1e652591f6..0ebda93690e 100644 --- a/config/configgrpc/README.md +++ b/config/configgrpc/README.md @@ -114,3 +114,5 @@ README](../confignet/README.md). - [`write_buffer_size`](https://godoc.org/google.golang.org/grpc#WriteBufferSize) - [`auth`](../configauth/README.md) - [`middlewares`](../configmiddleware/README.md) +- `include_metadata` +- `client_address_metadata_keys` diff --git a/config/confighttp/README.md b/config/confighttp/README.md index 08987e61161..c3f8b174ed5 100644 --- a/config/confighttp/README.md +++ b/config/confighttp/README.md @@ -113,6 +113,8 @@ will not be enabled. - [`auth`](../configauth/README.md) - `request_params`: a list of query parameter names to add to the auth context, along with the HTTP headers - [`middlewares`](../configmiddleware/README.md) +- `include_metadata` +- `client_address_metadata_keys` You can enable [`attribute processor`][attribute-processor] to append any http header to span's attribute using custom key. You also need to enable the "include_metadata" @@ -137,6 +139,8 @@ receivers: max_age: 7200 endpoint: 0.0.0.0:55690 compression_algorithms: ["", "gzip"] + client_address_metadata_keys: + - x-forwarded-for processors: attributes: actions: diff --git a/config/confighttp/clientinfohandler.go b/config/confighttp/clientinfohandler.go index 75d078a2ad3..16fd6b10923 100644 --- a/config/confighttp/clientinfohandler.go +++ b/config/confighttp/clientinfohandler.go @@ -31,7 +31,7 @@ func (h *clientInfoHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) // contextWithClient attempts to add the client IP address to the client.Info from the context. // The address is found by first checking the metadata using clientAddrMetadataKeys and -// falls back to the request Remote address +// falls back to the request Remote address. // When no client.Info exists in the context, one is created. func contextWithClient(req *http.Request, includeMetadata bool, clientAddrMetadataKeys []string) context.Context { cl := client.FromContext(req.Context())