Skip to content

Commit abfe72d

Browse files
maleck13david-martin
authored andcommitted
per-server SNI for hairpin TLS connections
- Replace single *http.Client hairpin with HairpinClientPool that caches per-SNI clients - Initialize uses the server HTTPRoute hostname as TLS ServerName so Envoy selects the correct filter chain - Move routing header setup (router-key, mcp-init-host) from clients.Initialize to the caller, breaking an import cycle - Add e2e tests for HTTPS hairpin and DestinationRule TLS origination - Document supported TLS configurations in the install guide Signed-off-by: craig <cbrookes@redhat.com>
1 parent ef66867 commit abfe72d

10 files changed

Lines changed: 343 additions & 107 deletions

File tree

cmd/mcp-broker-router/main.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ type app struct {
8080
jwtMgr *session.JWTManager
8181
elicitMap idmap.Map
8282
tokenElicitMap elicitation.Map
83-
hairpinClient *http.Client
83+
hairpinPool *clients.HairpinClientPool
8484
mcpBroker broker.MCPBroker
8585
tokenHandler http.Handler
8686
elicitHandler http.Handler
@@ -250,15 +250,15 @@ func (a *app) setupSessionInfra(ctx context.Context) {
250250
}
251251

252252
func (a *app) buildHairpinClient() {
253-
hc, err := clients.BuildHairpinHTTPClient(
253+
pool, err := clients.BuildHairpinHTTPClientPool(
254254
a.brokerCfg.privateHost,
255255
a.brokerCfg.publicHost,
256256
a.brokerCfg.gatewayCACert,
257257
)
258258
if err != nil {
259-
panic("failed to build hairpin HTTP client: " + err.Error())
259+
panic("failed to build hairpin HTTP client pool: " + err.Error())
260260
}
261-
a.hairpinClient = hc
261+
a.hairpinPool = pool
262262
}
263263

264264
func (a *app) registerObservers() {

cmd/mcp-broker-router/router.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func (a *app) createRouter() {
1515
Logger: a.logger.With("component", "router"),
1616
JWTManager: a.jwtMgr,
1717
InitForClient: clients.Initialize,
18-
HairpinHTTPClient: a.hairpinClient,
18+
HairpinClientPool: a.hairpinPool,
1919
SessionCache: a.sessionCache,
2020
ElicitationMap: a.elicitMap,
2121
TokenElicitationMap: a.tokenElicitMap,

docs/guides/how-to-install-and-configure.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ kubectl patch deployment mcp-gateway -n mcp-system --type=json -p '[
109109

110110
The operator preserves user-added volumes, volume mounts, and command flags across reconciliations.
111111

112+
#### Supported TLS Configurations
113+
114+
The gateway uses TLS termination (`tls.mode: Terminate`) on HTTPS listeners. Envoy decrypts incoming TLS, processes the request (ext_proc, AuthPolicy), then forwards **plain HTTP** to backends. This affects which backend setups work:
115+
116+
**Fully supported — tool discovery and tool calls work:**
117+
118+
- **HTTP listener → HTTP backend** — no TLS involved.
119+
- **HTTPS listener → HTTP backend** — Envoy terminates client TLS, forwards HTTP to backend. Requires `--gateway-ca-cert` if the listener uses a private CA.
120+
- **HTTPS listener → external HTTPS backend (with DestinationRule)** — Envoy terminates client TLS, then a DestinationRule configures TLS origination to the external service. See [External MCP Servers](./external-mcp-server.md).
121+
122+
**Partial support — tool discovery works, tool calls require additional configuration:**
123+
124+
- **Any listener → internal HTTPS backend (`caCertSecretRef`)** — the broker connects directly to the backend using the CA cert, so tool discovery works. However, `tools/call` is routed through Envoy, which sends plain HTTP to the TLS backend after termination. The backend rejects the non-TLS connection. To enable `tools/call`, create an Istio DestinationRule with TLS origination for that service — the same pattern used for [external MCP servers](./external-mcp-server.md). See the [Istio documentation on destination rule TLS settings](https://istio.io/latest/docs/reference/config/networking/destination-rule/#ClientTLSSettings) for configuring `credentialName` with a custom CA certificate.
125+
112126
The `--mcp-gateway-public-host` flag tells the router which `Host` header to expect on incoming requests, so it avoids rewriting it during routing.
113127

114128
The **EnvoyFilter** is configured to intercept traffic on the listener's port and route it through the ext_proc (external processor) running on port 50051.

internal/clients/clients.go

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,63 @@ import (
1212
"net/http"
1313
"os"
1414
"strings"
15+
"sync"
1516

1617
"github.com/Kuadrant/mcp-gateway/internal/config"
17-
mcprouter "github.com/Kuadrant/mcp-gateway/internal/mcp-router"
1818
"github.com/mark3labs/mcp-go/client"
1919
"github.com/mark3labs/mcp-go/client/transport"
2020
"github.com/mark3labs/mcp-go/mcp"
2121
)
2222

23+
// HairpinClientPool manages *http.Client instances for hairpin requests,
24+
// keyed by TLS ServerName (SNI). For HTTP or when all servers share the
25+
// same listener, a single default client is used. When a server attaches
26+
// to a different HTTPS listener, a client with that server's SNI is
27+
// created on demand and cached.
28+
type HairpinClientPool struct {
29+
defaultClient *http.Client
30+
baseTLSConfig *tls.Config // nil for HTTP
31+
mu sync.RWMutex
32+
clients map[string]*http.Client
33+
}
34+
35+
// Get returns an *http.Client with the appropriate TLS ServerName.
36+
// If sniOverride is empty, the default client (gateway hostname SNI) is returned.
37+
func (p *HairpinClientPool) Get(sniOverride string) *http.Client {
38+
if sniOverride == "" || p.baseTLSConfig == nil {
39+
return p.defaultClient
40+
}
41+
42+
sni := sniOverride
43+
if h, _, err := net.SplitHostPort(sniOverride); err == nil {
44+
sni = h
45+
}
46+
47+
// double-checked locking: read lock for the fast path (concurrent readers),
48+
// then write lock with a re-check to avoid duplicate creation when multiple
49+
// goroutines race past the read lock simultaneously
50+
p.mu.RLock()
51+
c, ok := p.clients[sni]
52+
p.mu.RUnlock()
53+
if ok {
54+
return c
55+
}
56+
57+
p.mu.Lock()
58+
defer p.mu.Unlock()
59+
if c, ok = p.clients[sni]; ok {
60+
return c
61+
}
62+
63+
cfg := p.baseTLSConfig.Clone()
64+
cfg.ServerName = sni
65+
t := http.DefaultTransport.(*http.Transport).Clone()
66+
t.TLSClientConfig = cfg
67+
c = &http.Client{Transport: t}
68+
p.clients[sni] = c
69+
return c
70+
}
71+
2372
// buildHairpinURL composes the hairpin URL the broker uses to send the internal
2473
// initialize request back through the gateway. gatewayHost may be either a
2574
// bare host[:port] (in which case http:// is assumed for backwards
@@ -34,21 +83,17 @@ func buildHairpinURL(gatewayHost, mcpPath string) string {
3483
return "http://" + gatewayHost + mcpPath
3584
}
3685

37-
// Initialize will create a new initialize and initialized request and return the associated http client for connection management
38-
// This method makes a request back to the gateway setting the target mcp server to initialize. We hairpin through the gateway to ensure any Auth applied to that host is triggered for the call.
39-
// The initToken is a short-lived JWT bound to conf.Hostname that the router will validate when the hairpin request re-enters the gateway.
40-
func Initialize(ctx context.Context, gatewayHost, initToken string, conf *config.MCPServer, passThroughHeaders map[string]string, clientElicitation bool, hairpinHTTPClient *http.Client) (*client.Client, error) {
41-
// force the initialize to hairpin back through envoy with a token that
42-
// proves the request originated from the gateway's own router.
43-
passThroughHeaders[mcprouter.RoutingKey] = initToken
44-
passThroughHeaders["mcp-init-host"] = conf.Hostname
45-
86+
// Initialize will create a new initialize and initialized request and return the associated http client for connection management.
87+
// This method makes a request back through the gateway to ensure any AuthPolicy is triggered.
88+
// The caller must set the routing key and mcp-init-host headers in passThroughHeaders before calling.
89+
func Initialize(ctx context.Context, gatewayHost string, conf *config.MCPServer, passThroughHeaders map[string]string, clientElicitation bool, hairpinClientPool *HairpinClientPool) (*client.Client, error) {
4690
mcpPath, err := conf.Path()
4791
if err != nil {
4892
return nil, err
4993
}
5094

5195
url := buildHairpinURL(gatewayHost, mcpPath)
96+
hairpinHTTPClient := hairpinClientPool.Get(conf.Hostname)
5297

5398
httpClient, err := client.NewStreamableHttpClient(url,
5499
transport.WithHTTPHeaders(passThroughHeaders),
@@ -80,41 +125,49 @@ func Initialize(ctx context.Context, gatewayHost, initToken string, conf *config
80125
return httpClient, nil
81126
}
82127

83-
// BuildHairpinHTTPClient returns an *http.Client for hairpin requests. For HTTPS
84-
// private hosts it configures TLS with ServerName set to publicHost so the
85-
// handshake verifies the cert SANs against the public hostname while the TCP
86-
// connection goes to the internal address. For plain HTTP it returns http.DefaultClient.
87-
func BuildHairpinHTTPClient(privateHost, publicHost, caCertPath string) (*http.Client, error) {
128+
// BuildHairpinHTTPClientPool returns a HairpinClientPool for hairpin requests.
129+
// For HTTPS private hosts it configures TLS with the publicHost as the default
130+
// ServerName (SNI). Servers on a different HTTPS listener can obtain a client
131+
// with a different SNI via pool.Get(serverHostname).
132+
// For plain HTTP it returns a pool whose default client has no TLS.
133+
func BuildHairpinHTTPClientPool(privateHost, publicHost, caCertPath string) (*HairpinClientPool, error) {
88134
if !strings.HasPrefix(strings.ToLower(privateHost), "https://") {
89-
return &http.Client{}, nil
135+
return &HairpinClientPool{
136+
defaultClient: &http.Client{},
137+
clients: make(map[string]*http.Client),
138+
}, nil
90139
}
91140

92-
pool, err := x509.SystemCertPool()
141+
certPool, err := x509.SystemCertPool()
93142
if err != nil {
94-
pool = x509.NewCertPool()
143+
certPool = x509.NewCertPool()
95144
}
96145

97146
if caCertPath != "" {
98147
pem, err := os.ReadFile(caCertPath) //nolint:gosec // path comes from a CLI flag, not user input
99148
if err != nil {
100149
return nil, fmt.Errorf("failed to read gateway CA cert: %w", err)
101150
}
102-
if !pool.AppendCertsFromPEM(pem) {
151+
if !certPool.AppendCertsFromPEM(pem) {
103152
return nil, fmt.Errorf("failed to parse gateway CA cert PEM")
104153
}
105154
}
106155

107-
// TLS ServerName must be a bare hostname, never host:port
108-
serverName := publicHost
156+
defaultSNI := publicHost
109157
if h, _, err := net.SplitHostPort(publicHost); err == nil {
110-
serverName = h
158+
defaultSNI = h
111159
}
112160

113-
t := http.DefaultTransport.(*http.Transport).Clone()
114-
t.TLSClientConfig = &tls.Config{
161+
baseCfg := &tls.Config{
115162
MinVersion: tls.VersionTLS12,
116-
RootCAs: pool,
117-
ServerName: serverName,
163+
RootCAs: certPool,
164+
ServerName: defaultSNI,
118165
}
119-
return &http.Client{Transport: t}, nil
166+
t := http.DefaultTransport.(*http.Transport).Clone()
167+
t.TLSClientConfig = baseCfg
168+
return &HairpinClientPool{
169+
defaultClient: &http.Client{Transport: t},
170+
baseTLSConfig: baseCfg,
171+
clients: make(map[string]*http.Client),
172+
}, nil
120173
}

internal/clients/clients_test.go

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package clients
55

66
import (
77
"context"
8+
"crypto/tls"
89
"net/http"
910
"os"
1011
"path/filepath"
@@ -65,15 +66,13 @@ func TestInitialize(t *testing.T) {
6566
testCases := []struct {
6667
name string
6768
gatewayHost string
68-
routerKey string
6969
conf *config.MCPServer
7070
passThroughHeaders map[string]string
7171
expectedError bool
7272
}{
7373
{
7474
name: "standard initialization",
7575
gatewayHost: "%invalid",
76-
routerKey: "router-key-123",
7776
conf: &config.MCPServer{
7877
Name: "test-server",
7978
Prefix: "test_",
@@ -87,7 +86,11 @@ func TestInitialize(t *testing.T) {
8786

8887
for _, tc := range testCases {
8988
t.Run(tc.name, func(t *testing.T) {
90-
client, err := Initialize(context.Background(), tc.gatewayHost, tc.routerKey, tc.conf, tc.passThroughHeaders, false, nil)
89+
pool := &HairpinClientPool{
90+
defaultClient: &http.Client{},
91+
clients: make(map[string]*http.Client),
92+
}
93+
client, err := Initialize(context.Background(), tc.gatewayHost, tc.conf, tc.passThroughHeaders, false, pool)
9194
if tc.expectedError {
9295
require.Error(t, err)
9396
return
@@ -99,42 +102,87 @@ func TestInitialize(t *testing.T) {
99102
}
100103
}
101104

102-
func TestBuildHairpinHTTPClient(t *testing.T) {
103-
t.Run("returns plain client for HTTP private host", func(t *testing.T) {
104-
c, err := BuildHairpinHTTPClient("http://gw.svc:8080", "mcp.example.com", "")
105+
func TestBuildHairpinHTTPClientPool(t *testing.T) {
106+
t.Run("returns plain pool for HTTP private host", func(t *testing.T) {
107+
pool, err := BuildHairpinHTTPClientPool("http://gw.svc:8080", "mcp.example.com", "")
105108
require.NoError(t, err)
106-
require.NotNil(t, c)
109+
require.NotNil(t, pool)
110+
require.Nil(t, pool.baseTLSConfig)
111+
require.NotNil(t, pool.Get(""))
107112
})
108113

109-
t.Run("returns plain client for bare host without scheme", func(t *testing.T) {
110-
c, err := BuildHairpinHTTPClient("gw.svc:8080", "mcp.example.com", "")
114+
t.Run("returns plain pool for bare host without scheme", func(t *testing.T) {
115+
pool, err := BuildHairpinHTTPClientPool("gw.svc:8080", "mcp.example.com", "")
111116
require.NoError(t, err)
112-
require.NotNil(t, c)
117+
require.NotNil(t, pool)
118+
require.Nil(t, pool.baseTLSConfig)
113119
})
114120

115-
t.Run("HTTPS sets ServerName and TLS minimum version", func(t *testing.T) {
116-
c, err := BuildHairpinHTTPClient("https://gw.svc:443", "mcp.example.com", "")
121+
t.Run("HTTPS sets default ServerName and TLS minimum version", func(t *testing.T) {
122+
pool, err := BuildHairpinHTTPClientPool("https://gw.svc:443", "mcp.example.com", "")
117123
require.NoError(t, err)
118-
require.NotNil(t, c)
124+
require.NotNil(t, pool)
119125

126+
c := pool.Get("")
120127
tr, ok := c.Transport.(*http.Transport)
121128
require.True(t, ok)
122129
require.Equal(t, "mcp.example.com", tr.TLSClientConfig.ServerName)
123-
require.Equal(t, uint16(0x0303), tr.TLSClientConfig.MinVersion) // tls.VersionTLS12
130+
require.Equal(t, uint16(tls.VersionTLS12), tr.TLSClientConfig.MinVersion)
124131
})
125132

126133
t.Run("HTTPS strips port from publicHost for ServerName", func(t *testing.T) {
127-
c, err := BuildHairpinHTTPClient("https://gw.svc:443", "mcp.example.com:8443", "")
134+
pool, err := BuildHairpinHTTPClientPool("https://gw.svc:443", "mcp.example.com:8443", "")
128135
require.NoError(t, err)
129-
require.NotNil(t, c)
130136

137+
c := pool.Get("")
131138
tr, ok := c.Transport.(*http.Transport)
132139
require.True(t, ok)
133140
require.Equal(t, "mcp.example.com", tr.TLSClientConfig.ServerName)
134141
})
135142

143+
t.Run("Get with override returns client with different SNI", func(t *testing.T) {
144+
pool, err := BuildHairpinHTTPClientPool("https://gw.svc:443", "mcp.example.com", "")
145+
require.NoError(t, err)
146+
147+
defaultClient := pool.Get("")
148+
overrideClient := pool.Get("server.mcp-alt.local")
149+
require.NotEqual(t, defaultClient, overrideClient)
150+
151+
tr, ok := overrideClient.Transport.(*http.Transport)
152+
require.True(t, ok)
153+
require.Equal(t, "server.mcp-alt.local", tr.TLSClientConfig.ServerName)
154+
})
155+
156+
t.Run("Get with override strips port", func(t *testing.T) {
157+
pool, err := BuildHairpinHTTPClientPool("https://gw.svc:443", "mcp.example.com", "")
158+
require.NoError(t, err)
159+
160+
c := pool.Get("server.mcp-alt.local:8443")
161+
tr, ok := c.Transport.(*http.Transport)
162+
require.True(t, ok)
163+
require.Equal(t, "server.mcp-alt.local", tr.TLSClientConfig.ServerName)
164+
})
165+
166+
t.Run("Get with override is cached", func(t *testing.T) {
167+
pool, err := BuildHairpinHTTPClientPool("https://gw.svc:443", "mcp.example.com", "")
168+
require.NoError(t, err)
169+
170+
c1 := pool.Get("server.mcp-alt.local")
171+
c2 := pool.Get("server.mcp-alt.local")
172+
require.Same(t, c1, c2)
173+
})
174+
175+
t.Run("Get with override on HTTP pool returns default", func(t *testing.T) {
176+
pool, err := BuildHairpinHTTPClientPool("http://gw.svc:8080", "mcp.example.com", "")
177+
require.NoError(t, err)
178+
179+
defaultClient := pool.Get("")
180+
overrideClient := pool.Get("server.mcp-alt.local")
181+
require.Same(t, defaultClient, overrideClient)
182+
})
183+
136184
t.Run("errors on non-existent CA cert path", func(t *testing.T) {
137-
_, err := BuildHairpinHTTPClient("https://gw.svc:443", "mcp.example.com", "/nonexistent/ca.crt")
185+
_, err := BuildHairpinHTTPClientPool("https://gw.svc:443", "mcp.example.com", "/nonexistent/ca.crt")
138186
require.Error(t, err)
139187
require.Contains(t, err.Error(), "failed to read gateway CA cert")
140188
})
@@ -144,7 +192,7 @@ func TestBuildHairpinHTTPClient(t *testing.T) {
144192
badCert := filepath.Join(tmpDir, "bad.crt")
145193
require.NoError(t, os.WriteFile(badCert, []byte("not a certificate"), 0600))
146194

147-
_, err := BuildHairpinHTTPClient("https://gw.svc:443", "mcp.example.com", badCert)
195+
_, err := BuildHairpinHTTPClientPool("https://gw.svc:443", "mcp.example.com", badCert)
148196
require.Error(t, err)
149197
require.Contains(t, err.Error(), "failed to parse gateway CA cert PEM")
150198
})

internal/mcp-router/request_handlers.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -696,11 +696,10 @@ func (s *ExtProcServer) initializeMCPSeverSession(ctx context.Context, mcpReq *M
696696
if mcpReq.Headers != nil {
697697
// We don't want to pass through any pseudo routing headers (:authority,
698698
// :path, etc.), the gateway-bound mcp-session-id, or the router-internal
699-
// headers (mcp-init-host, router-key) which we set ourselves below via
700-
// clients.Initialize. Dropping the router-internal headers here is
701-
// defense-in-depth so a client-supplied value can never reach the
702-
// hairpin request even if the override in clients.Initialize is later
703-
// refactored. Everything else is passed through for custom headers.
699+
// headers (mcp-init-host, router-key) which we set ourselves below.
700+
// Dropping the router-internal headers here is defense-in-depth so a
701+
// client-supplied value can never reach the hairpin request.
702+
// Everything else is passed through for custom headers.
704703
for _, h := range mcpReq.Headers.Headers {
705704
key := strings.ToLower(h.Key)
706705
if strings.HasPrefix(key, ":") ||
@@ -753,7 +752,9 @@ func (s *ExtProcServer) initializeMCPSeverSession(ctx context.Context, mcpReq *M
753752
mcpotel.SpanError(initSpan, err, "failed to generate backend-init token")
754753
return "", NewRouterErrorf(500, "failed to generate backend-init token: %w", err)
755754
}
756-
clientHandle, err := s.InitForClient(ctx, s.RoutingConfig.MCPGatewayInternalHostname, initToken, mcpServerConfig, passThroughHeaders, mcpReq.clientElicitation, s.HairpinHTTPClient)
755+
passThroughHeaders[RoutingKey] = initToken
756+
passThroughHeaders["mcp-init-host"] = mcpServerConfig.Hostname
757+
clientHandle, err := s.InitForClient(ctx, s.RoutingConfig.MCPGatewayInternalHostname, mcpServerConfig, passThroughHeaders, mcpReq.clientElicitation, s.HairpinClientPool)
757758
if err != nil {
758759
s.Logger.ErrorContext(ctx, "failed to get remote session ", "error", err)
759760
mcpotel.SpanError(initSpan, err, "failed to initialize backend session")

0 commit comments

Comments
 (0)