feat(control-plane): enforce mTLS client identity in sdk tunnel paths
cognitive committed
Mar 4, 2026 at 06:27 UTC
01543bd9c684a8fb5d2b588900aa60fd149f1d2a
7 files changed
+145
-7
cmd/relay-server/main_test.go
+4
-1
@@ -2,6 +2,7 @@ package main
2
3
import (
4
"context"
5
+ "crypto/tls"
6
"encoding/json"
7
"net"
8
"net/http"
@@ -102,6 +103,7 @@ func TestSDKRegisterRejectsBannedIP(t *testing.T) {
103
104
reqBody := strings.NewReader(`{"lease_id":"lease-ban","name":"test-lease","tls":true,"reverse_token":"token-1"}`)
105
req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, reqBody)
106
+ req.TLS = &tls.ConnectionState{}
107
req.RemoteAddr = "203.0.113.17:45678"
108
rr := httptest.NewRecorder()
109
@@ -159,8 +161,9 @@ func TestSDKUnregisterCleansRouteAndReversePoolImmediately(t *testing.T) {
161
t.Fatal("failed to seed reverse pool")
162
}
163
162
- reqBody := strings.NewReader(`{"lease_id":"lease-cleanup"}`)
164
+ reqBody := strings.NewReader(`{"lease_id":"lease-cleanup","reverse_token":"token-cleanup"}`)
165
req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, reqBody)
166
+ attachPeerLeaseCertificate(req, lease.ID)
167
rr := httptest.NewRecorder()
168
registry.handleUnregister(rr, req, serv)
169
cmd/relay-server/serve.go
+1
@@ -129,6 +129,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
129
acmeManager := serv.GetACMEManager()
130
rootHost := types.PortalRootHost(flagPortalURL)
131
srv.TLSConfig = &tls.Config{
132
+ ClientAuth: tls.RequestClientCert,
133
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
134
serverName := strings.TrimSpace(strings.ToLower(hello.ServerName))
135
if serverName != "" && !strings.EqualFold(serverName, rootHost) {
sdk/client.go
+5
-3
@@ -91,6 +91,10 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
91
if err != nil {
92
return nil, err
93
}
94
+ controlPlaneIdentity, err := issueControlPlaneIdentity(lease.ID)
95
+ if err != nil {
96
+ return nil, err
97
+ }
98
99
listeners := make([]net.Listener, 0, len(relayAddrs))
100
closeActiveListeners := func() {
@@ -115,7 +119,7 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
119
}
120
121
leaseCopy := *lease
118
- listener, listenerErr := NewListener(relayAddr, &leaseCopy, tlsConfig, 0, c.config.ReverseDialTimeout, listenerCloseFns...)
122
+ listener, listenerErr := NewListener(relayAddr, &leaseCopy, tlsConfig, controlPlaneIdentity, 0, c.config.ReverseDialTimeout, listenerCloseFns...)
123
if listenerErr != nil {
124
runCloseFns(listenerCloseFns)
125
closeActiveListeners()
@@ -204,5 +208,3 @@ func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []fun
208
func (c *Client) Close() error {
209
return nil
210
}
207
-
208
-// Close closes the client.
sdk/controlplane_identity.go
new
+77
@@ -0,0 +1,77 @@
1
+package sdk
2
+
3
+import (
4
+ "crypto/ecdsa"
5
+ "crypto/elliptic"
6
+ "crypto/rand"
7
+ "crypto/tls"
8
+ "crypto/x509"
9
+ "crypto/x509/pkix"
10
+ "encoding/pem"
11
+ "errors"
12
+ "fmt"
13
+ "math/big"
14
+ "net/url"
15
+ "strings"
16
+ "time"
17
+)
18
+
19
+const (
20
+ controlPlaneCertCNPrefix = "lease:"
21
+ controlPlaneLeaseURIPfx = "spiffe://portal/lease/"
22
+)
23
+
24
+func issueControlPlaneIdentity(leaseID string) (tls.Certificate, error) {
25
+ leaseID = strings.TrimSpace(leaseID)
26
+ if leaseID == "" {
27
+ return tls.Certificate{}, errors.New("lease id is required")
28
+ }
29
+
30
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
31
+ if err != nil {
32
+ return tls.Certificate{}, fmt.Errorf("generate identity key: %w", err)
33
+ }
34
+
35
+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
36
+ if err != nil {
37
+ return tls.Certificate{}, fmt.Errorf("generate serial: %w", err)
38
+ }
39
+ notBefore := time.Now().Add(-1 * time.Minute)
40
+ notAfter := notBefore.Add(24 * time.Hour)
41
+
42
+ leaseURI, err := url.Parse(controlPlaneLeaseURIPfx + leaseID)
43
+ if err != nil {
44
+ return tls.Certificate{}, fmt.Errorf("build lease URI: %w", err)
45
+ }
46
+
47
+ template := &x509.Certificate{
48
+ SerialNumber: serial,
49
+ Subject: pkix.Name{
50
+ CommonName: controlPlaneCertCNPrefix + leaseID,
51
+ },
52
+ NotBefore: notBefore,
53
+ NotAfter: notAfter,
54
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
55
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
56
+ BasicConstraintsValid: true,
57
+ URIs: []*url.URL{leaseURI},
58
+ }
59
+
60
+ der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
61
+ if err != nil {
62
+ return tls.Certificate{}, fmt.Errorf("create lease identity certificate: %w", err)
63
+ }
64
+
65
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
66
+ keyDER, err := x509.MarshalECPrivateKey(priv)
67
+ if err != nil {
68
+ return tls.Certificate{}, fmt.Errorf("marshal identity key: %w", err)
69
+ }
70
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
71
+
72
+ cert, err := tls.X509KeyPair(certPEM, keyPEM)
73
+ if err != nil {
74
+ return tls.Certificate{}, fmt.Errorf("load identity key pair: %w", err)
75
+ }
76
+ return cert, nil
77
+}
sdk/controlplane_identity_test.go
new
+42
@@ -0,0 +1,42 @@
1
+package sdk
2
+
3
+import (
4
+ "crypto/x509"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+)
9
+
10
+func TestIssueControlPlaneIdentity(t *testing.T) {
11
+ t.Parallel()
12
+
13
+ identity, err := issueControlPlaneIdentity("lease-identity")
14
+ if err != nil {
15
+ t.Fatalf("issueControlPlaneIdentity returned error: %v", err)
16
+ }
17
+ if len(identity.Certificate) == 0 {
18
+ t.Fatal("identity certificate chain is empty")
19
+ }
20
+
21
+ leaf, err := x509.ParseCertificate(identity.Certificate[0])
22
+ if err != nil {
23
+ t.Fatalf("parse issued certificate: %v", err)
24
+ }
25
+ if got := strings.TrimSpace(leaf.Subject.CommonName); got != controlPlaneCertCNPrefix+"lease-identity" {
26
+ t.Fatalf("certificate common name = %q, want %q", got, controlPlaneCertCNPrefix+"lease-identity")
27
+ }
28
+ if len(leaf.URIs) == 0 || leaf.URIs[0].String() != controlPlaneLeaseURIPfx+"lease-identity" {
29
+ t.Fatalf("certificate lease URI = %v, want %q", leaf.URIs, controlPlaneLeaseURIPfx+"lease-identity")
30
+ }
31
+ if time.Now().Before(leaf.NotBefore) || time.Now().After(leaf.NotAfter) {
32
+ t.Fatalf("issued certificate validity window does not include current time")
33
+ }
34
+}
35
+
36
+func TestIssueControlPlaneIdentityRejectsEmptyLeaseID(t *testing.T) {
37
+ t.Parallel()
38
+
39
+ if _, err := issueControlPlaneIdentity(" "); err == nil {
40
+ t.Fatal("expected error for empty lease ID")
41
+ }
42
+}
sdk/listener.go
+14
-2
@@ -39,6 +39,10 @@ var fatalReverseConnectRejectionCodes = map[string]struct{}{
39
"tls_required": {},
40
"unauthorized": {},
41
"unsupported_transport": {},
42
+ "client_cert_required": {},
43
+ "client_cert_invalid": {},
44
+ "cert_lease_missing": {},
45
+ "cert_lease_mismatch": {},
46
}
47
48
type reverseConnectRejectionError struct {
@@ -81,6 +85,7 @@ func (e *reverseConnectRejectionError) IsFatal() bool {
85
// The relay connects to this listener after SNI routing resolves the lease.
86
type Listener struct {
87
tlsConfig *tls.Config
88
+ controlPlaneCert tls.Certificate
89
lease *portal.Lease
90
httpClient *http.Client
91
stopCh chan struct{}
@@ -99,7 +104,7 @@ var _ net.Listener = (*Listener)(nil)
104
105
// NewListener creates a relay-backed listener.
106
// If tlsConfig is provided, reverse workers complete TLS handshakes before enqueueing connections.
102
-func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, reverseWorkers int, reverseDialTimeout time.Duration, closeFns ...func()) (*Listener, error) {
107
+func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, controlPlaneCert tls.Certificate, reverseWorkers int, reverseDialTimeout time.Duration, closeFns ...func()) (*Listener, error) {
108
if lease == nil {
109
return nil, errors.New("lease is required")
110
}
@@ -115,6 +120,9 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
120
if tlsConfig == nil {
121
return nil, errors.New("tls config is required")
122
}
123
+ if len(controlPlaneCert.Certificate) == 0 {
124
+ return nil, errors.New("control plane client certificate is required")
125
+ }
126
127
apiURL, err := types.NormalizeRelayAPIURL(relayAddr)
128
if err != nil {
@@ -126,6 +134,7 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
134
MinVersion: tls.VersionTLS12,
135
ServerName: host,
136
InsecureSkipVerify: types.IsLocalhost(host),
137
+ Certificates: []tls.Certificate{controlPlaneCert},
138
}
139
140
if reverseWorkers <= 0 {
@@ -144,6 +153,7 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
153
Transport: clientTransport,
154
},
155
tlsConfig: tlsConfig,
156
+ controlPlaneCert: controlPlaneCert,
157
closeFns: closeFns,
158
stopCh: make(chan struct{}),
159
acceptCh: make(chan net.Conn, 128),
@@ -399,6 +409,7 @@ func (l *Listener) openReverseConnection() (net.Conn, error) {
409
MinVersion: tls.VersionTLS12,
410
ServerName: serverName,
411
InsecureSkipVerify: types.IsLocalhost(serverName),
412
+ Certificates: []tls.Certificate{l.controlPlaneCert},
413
})
414
err = tlsConn.HandshakeContext(ctx)
415
if err != nil {
@@ -656,7 +667,8 @@ func (l *Listener) registerWithRelay() error {
667
668
func (l *Listener) unregisterFromRelay() error {
669
reqBody := types.UnregisterRequest{
659
- LeaseID: l.lease.ID,
670
+ LeaseID: l.lease.ID,
671
+ ReverseToken: l.lease.ReverseToken,
672
}
673
return l.postJSON(types.PathSDKUnregister, reqBody)
674
}
types/api.go
+2
-1
@@ -80,7 +80,8 @@ type RegisterResponse struct {
80
81
// UnregisterRequest is the lease unregistration request.
82
type UnregisterRequest struct {
83
- LeaseID string `json:"lease_id"`
83
+ LeaseID string `json:"lease_id"`
84
+ ReverseToken string `json:"reverse_token"`
85
}
86
87
// RenewRequest is the lease renewal request.