sdk: add mitm detection in listener
Kim committed
Mar 25, 2026 at 15:05 UTC
e6a0a8debf7b145e76b4cf05ff665bfbe3ae6fbe
5 files changed
+705
-2
docs/architecture.md
+24
-1
@@ -34,8 +34,10 @@ UDP client
34
### TLS and Identity
35
36
- Relay terminates admin/API TLS on the root host and exposes `/v1/sign` for tenant-side keyless signing.
37
+- Control-plane HTTP (`/sdk/*`), reverse-session establishment (`/sdk/connect`), and tenant TLS are separate connections with different trust boundaries.
38
- Relay does not terminate tenant TLS. It peeks ClientHello for SNI and bridges raw encrypted bytes after routing.
39
- SDK/tunnel endpoints terminate tenant TLS locally with a keyless-backed signer that calls the relay.
40
+- In keyless TLS, the relay performs certificate private-key signing through `/v1/sign`, but the SDK/tunnel endpoint still runs the TLS server handshake and derives tenant TLS session keys locally.
41
- `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` are authorized by lease existence plus reverse token.
42
- `/sdk/register` requires the caller to supply the reverse token that later authorizes the lease lifecycle, but registration itself is not separately authenticated by that token.
43
- Relay URLs must use `https://`.
@@ -119,6 +121,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
121
- Entry points can opt out of registry defaults and call `utils.NormalizeRelayURLs` directly when they need explicit relay inputs only
122
- `Listener`: validates one relay URL locally, then starts relay compatibility checks, lease registration, reverse session maintenance, and lease renewal in the background until ready
123
- `api_client.go`: internal relay client for control-plane requests, reverse session dialing, and internal QUIC tunnel setup
124
+- `mitm.go`: tenant-side TLS passthrough self-probe. The SDK opens a probe connection to its own public URL, compares TLS exporter values on both SDK-controlled ends, and logs suspected relay-side TLS termination on mismatch
125
- `ListenerConfig.RetryCount <= 0` means retry forever; positive values close the listener after the retry budget is exhausted
126
- `NewListener` callers provide explicit normalized relay URLs
127
- Default exposure flow is `Expose{Discovery: true} -> PublicURLs -> http.Server.Serve(exposure)`, with an opt-out path for explicit relay inputs only
@@ -127,6 +130,8 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
130
- `Exposure.RelayURLs()` returns the configured normalized relay URLs, while `Exposure.PublicURLs()` returns only relays that are currently registered and ready
131
- Relay-aware entry inspection is reserved for advanced callers such as `portal-tunnel`
132
- Tenant TLS is created automatically through the relay keyless signer; callers do not provide a local self-signed fallback path
133
+- MITM self-probes are traffic-triggered, not periodic. A listener triggers at most one asynchronous probe per 30-second cooldown, and only after real tenant traffic performs I/O on an accepted connection
134
+- Probe identification does not use a dedicated ALPN or fixed plaintext marker. The first encrypted probe payload is `nonce + random padding`, and inbound probe matching is only attempted while a probe is in flight
135
- `Listener` embeds a `datagram.Session` for QUIC datagram transport (no separate UDP listener type)
136
- `Listener.AcceptDatagram()` / `SendDatagram()`: read/write datagram frames via the session
137
- `Listener.WaitDatagramReady()`: blocks until relay publishes `udp_addr` and `quic_addr`
@@ -163,6 +168,18 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
168
169
Result: the relay decides routing, but tenant TLS termination still happens at the SDK/tunnel side.
170
171
+### Tenant TLS Self-Probe Detection
172
+
173
+1. After a real tenant connection begins I/O, the SDK may start one asynchronous self-probe for that listener if no probe is in flight and the 30-second cooldown has expired.
174
+2. The SDK opens a new TLS connection to its own public URL using the same tenant-facing TLS characteristics as normal traffic.
175
+3. The probe client exports TLS keying material (`ExportKeyingMaterial`) from that probe connection and stores it under a random nonce.
176
+4. The first encrypted probe payload is `16-byte nonce + random padding`; there is no fixed probe magic or dedicated ALPN.
177
+5. When the probe connection comes back through the relay and reaches the SDK-side tenant TLS terminator, the SDK peeks only the first 16 encrypted application bytes while a probe is pending.
178
+6. If those bytes match a pending nonce, the SDK exports keying material on the server side and compares it with the client-side exporter value.
179
+7. Matching exporter values mean the probe observed passthrough for that connection. A mismatch is logged as suspected relay-side TLS termination. A timeout is logged as probe failure, not proof of MITM.
180
+
181
+Result: this is a detect-only signal. It raises the cost of adaptive relay-side TLS termination, but it does not prove passthrough for every user connection.
182
+
183
### UDP/QUIC Datagram Transport
184
185
1. SDK/tunnel registers a lease with `udp_enabled=true` via `POST /sdk/register`.
@@ -267,6 +284,9 @@ Cross-package public contract lives in:
284
- API envelope
285
- shared request/response DTOs
286
- lease metadata
287
+- `types/error.go`
288
+ - shared API error codes
289
+ - shared MITM self-probe reason codes
290
- `types/types.go`
291
- shared headers
292
- reverse marker constants
@@ -289,7 +309,9 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
309
- root host A record
310
- wildcard host A record
311
- relay certificate renewal
292
-- SDK/tunnel fetches the relay certificate chain, verifies it covers tenant hostnames, and uses `/v1/sign` for remote signatures during tenant TLS handshakes
312
+- SDK/tunnel fetches the relay certificate chain from the relay root host, verifies that the leaf covers tenant hostnames, and builds a tenant-side `tls.Config` with a remote signer backed by `/v1/sign`
313
+- During tenant TLS handshake, the SDK/tunnel endpoint acts as the TLS server and derives tenant session keys locally; the relay only signs handshake digests and does not receive tenant TLS traffic secrets
314
+- Relay control-plane TLS and reverse-session setup still terminate on the relay's admin/API listener and are not protected by the tenant keyless TLS path
315
316
## Design Properties
317
@@ -298,6 +320,7 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
320
- Raw public UDP exposure with an internal QUIC datagram backhaul
321
- SNI-based routing with root-host fallback
322
- End-to-end tenant TLS with relay-backed keyless signing
323
+- Traffic-triggered detect-only MITM self-probing for probable relay-side TLS termination
324
- Per-lease reverse token authorization for reverse session lifecycle
325
- Lease-local stream and datagram ownership through per-lease transport runtimes
326
- Optional QUIC/UDP datagram transport coexisting with TCP on the same lease
sdk/listener.go
+26
-1
@@ -73,6 +73,8 @@ type Listener struct {
73
metadata types.LeaseMetadata
74
tlsConfig *tls.Config
75
tlsCloser io.Closer
76
+
77
+ mitmManager *mitmManager
78
}
79
80
// NewListener creates one relay listener and its dedicated relay transport for one relay URL.
@@ -110,6 +112,7 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
112
registerBootstraps: append([]string(nil), initialBootstraps...),
113
metadata: cfg.Metadata.Copy(),
114
}
115
+ l.mitmManager = newMITMManager(listenerCtx, l)
116
l.stream = transport.NewClientStream(readyTarget, handshakeTimeout)
117
if cfg.UDPEnabled {
118
l.datagram = transport.NewClientDatagram(func(err error) {
@@ -211,6 +214,10 @@ func (l *Listener) Close() error {
214
l.tlsCloser = nil
215
l.mu.Unlock()
216
217
+ if l.mitmManager != nil {
218
+ l.mitmManager.reset()
219
+ }
220
+
221
if stream != nil {
222
stream.Drain()
223
}
@@ -237,7 +244,25 @@ func (l *Listener) Accept() (net.Conn, error) {
244
if l.stream == nil {
245
return nil, net.ErrClosed
246
}
240
- return l.stream.Accept(l.doneCh)
247
+ for {
248
+ conn, err := l.stream.Accept(l.doneCh)
249
+ if err != nil {
250
+ return nil, err
251
+ }
252
+
253
+ nextConn, handled, handleErr := l.mitmManager.maybeHandleConn(conn)
254
+ if handleErr != nil {
255
+ log.Debug().
256
+ Err(handleErr).
257
+ Str("relay_url", l.api.baseURL.String()).
258
+ Str("lease_id", l.LeaseID()).
259
+ Msg("mitm self-probe handling failed")
260
+ }
261
+ if handled {
262
+ continue
263
+ }
264
+ return wrapMITMProbeConn(l.mitmManager, nextConn), nil
265
+ }
266
}
267
268
func (l *Listener) Addr() net.Addr {
sdk/mitm.go
new
+377
@@ -0,0 +1,377 @@
1
+package sdk
2
+
3
+import (
4
+ "bufio"
5
+ "bytes"
6
+ "context"
7
+ "crypto/rand"
8
+ "crypto/tls"
9
+ "encoding/hex"
10
+ "errors"
11
+ "fmt"
12
+ "io"
13
+ "net"
14
+ "net/url"
15
+ "sync"
16
+ "time"
17
+
18
+ "github.com/rs/zerolog/log"
19
+
20
+ "github.com/gosuda/portal/v2/types"
21
+ "github.com/gosuda/portal/v2/utils"
22
+)
23
+
24
+const (
25
+ mitmProbeExporterLabel = "Portal-MITM-Probe-v1"
26
+ mitmProbePeekTimeout = 100 * time.Millisecond
27
+ mitmProbePaddingMin = 96
28
+ mitmProbePaddingMax = 320
29
+
30
+ defaultMITMProbeCooldown = 30 * time.Second
31
+ defaultMITMProbeTimeout = 5 * time.Second
32
+)
33
+
34
+type MITMProbeReport struct {
35
+ RelayURL string
36
+ PublicURL string
37
+ LeaseID string
38
+ CheckedAt time.Time
39
+ Detected bool
40
+ Reason string
41
+}
42
+
43
+type mitmProbePending struct {
44
+ expected []byte
45
+ resultCh chan mitmProbeResult
46
+}
47
+
48
+type mitmProbeResult struct {
49
+ matched bool
50
+ reason string
51
+}
52
+
53
+type mitmManager struct {
54
+ ctx context.Context
55
+ listener *Listener
56
+
57
+ mu sync.Mutex
58
+ pending map[string]*mitmProbePending
59
+ inFlight bool
60
+ lastAt time.Time
61
+}
62
+
63
+func newMITMManager(ctx context.Context, listener *Listener) *mitmManager {
64
+ return &mitmManager{
65
+ ctx: ctx,
66
+ listener: listener,
67
+ pending: make(map[string]*mitmProbePending),
68
+ }
69
+}
70
+
71
+func (m *mitmManager) reset() {
72
+ m.mu.Lock()
73
+ clear(m.pending)
74
+ m.inFlight = false
75
+ m.lastAt = time.Time{}
76
+ m.mu.Unlock()
77
+}
78
+
79
+func (m *mitmManager) probeTLSPassthrough(ctx context.Context) (MITMProbeReport, error) {
80
+ report := MITMProbeReport{
81
+ CheckedAt: time.Now(),
82
+ }
83
+ l := m.listener
84
+ if l == nil || l.api == nil || l.api.baseURL == nil {
85
+ return report, errors.New("listener is not ready")
86
+ }
87
+
88
+ publicURL := l.PublicURL()
89
+ if publicURL == "" {
90
+ return report, errors.New("listener is not registered")
91
+ }
92
+
93
+ hostname := l.Hostname()
94
+ if hostname == "" {
95
+ return report, errors.New("listener hostname is unavailable")
96
+ }
97
+
98
+ probeCtx, cancel := context.WithTimeout(ctx, defaultMITMProbeTimeout)
99
+ defer cancel()
100
+
101
+ nonceRaw := make([]byte, 16)
102
+ if _, err := io.ReadFull(rand.Reader, nonceRaw); err != nil {
103
+ return report, fmt.Errorf("generate probe nonce: %w", err)
104
+ }
105
+ nonceHex := hex.EncodeToString(nonceRaw)
106
+
107
+ report.RelayURL = l.api.baseURL.String()
108
+ report.PublicURL = publicURL
109
+ report.LeaseID = l.LeaseID()
110
+
111
+ parsedURL, err := url.Parse(publicURL)
112
+ if err != nil {
113
+ return report, fmt.Errorf("parse public url: %w", err)
114
+ }
115
+
116
+ dialer := &tls.Dialer{
117
+ NetDialer: &net.Dialer{Timeout: l.api.dialTimeout},
118
+ Config: m.clientTLSConfig(hostname),
119
+ }
120
+ conn, err := dialer.DialContext(probeCtx, "tcp", utils.EnsurePort(parsedURL.Host))
121
+ if err != nil {
122
+ return report, fmt.Errorf("dial mitm probe: %w", err)
123
+ }
124
+ defer conn.Close()
125
+
126
+ tlsConn, ok := conn.(*tls.Conn)
127
+ if !ok {
128
+ return report, errors.New("mitm probe connection is not tls")
129
+ }
130
+
131
+ clientState := tlsConn.ConnectionState()
132
+ expected, err := (&clientState).ExportKeyingMaterial(mitmProbeExporterLabel, nil, 32)
133
+ if err != nil {
134
+ return report, fmt.Errorf("export client probe keying material: %w", err)
135
+ }
136
+ resultCh, cleanupProbe := m.startProbe(nonceHex, expected)
137
+ defer cleanupProbe()
138
+
139
+ paddingLen := mitmProbePaddingMin
140
+ if mitmProbePaddingMax > mitmProbePaddingMin {
141
+ var paddingSeed [1]byte
142
+ if _, err := io.ReadFull(rand.Reader, paddingSeed[:]); err != nil {
143
+ return report, fmt.Errorf("generate probe padding length: %w", err)
144
+ }
145
+ paddingLen += int(paddingSeed[0]) % (mitmProbePaddingMax - mitmProbePaddingMin + 1)
146
+ }
147
+
148
+ frame := make([]byte, len(nonceRaw)+paddingLen)
149
+ copy(frame, nonceRaw)
150
+ if _, err := io.ReadFull(rand.Reader, frame[len(nonceRaw):]); err != nil {
151
+ return report, fmt.Errorf("generate probe padding: %w", err)
152
+ }
153
+ if _, err := conn.Write(frame); err != nil {
154
+ return report, fmt.Errorf("write mitm probe: %w", err)
155
+ }
156
+
157
+ select {
158
+ case result := <-resultCh:
159
+ report.Detected = !result.matched
160
+ report.Reason = result.reason
161
+ return report, nil
162
+ case <-probeCtx.Done():
163
+ report.Detected = false
164
+ report.Reason = types.MITMProbeReasonProbeTimeout
165
+ if errors.Is(probeCtx.Err(), context.DeadlineExceeded) {
166
+ return report, nil
167
+ }
168
+ return report, probeCtx.Err()
169
+ }
170
+}
171
+
172
+func (m *mitmManager) clientTLSConfig(hostname string) *tls.Config {
173
+ probeTLSConf := &tls.Config{
174
+ ServerName: hostname,
175
+ InsecureSkipVerify: true,
176
+ }
177
+
178
+ l := m.listener
179
+ l.mu.Lock()
180
+ defer l.mu.Unlock()
181
+
182
+ if l.tlsConfig != nil {
183
+ probeTLSConf.MinVersion = l.tlsConfig.MinVersion
184
+ probeTLSConf.MaxVersion = l.tlsConfig.MaxVersion
185
+ if len(l.tlsConfig.NextProtos) > 0 {
186
+ probeTLSConf.NextProtos = append([]string(nil), l.tlsConfig.NextProtos...)
187
+ }
188
+ }
189
+ return probeTLSConf
190
+}
191
+
192
+func (m *mitmManager) maybeStart() {
193
+ l := m.listener
194
+ if l.closed() {
195
+ return
196
+ }
197
+
198
+ m.mu.Lock()
199
+ if m.inFlight || !m.lastAt.IsZero() && time.Since(m.lastAt) < defaultMITMProbeCooldown {
200
+ m.mu.Unlock()
201
+ return
202
+ }
203
+ m.inFlight = true
204
+ m.mu.Unlock()
205
+
206
+ go func() {
207
+ report, err := m.probeTLSPassthrough(m.ctx)
208
+ m.finish(err == nil && report.Reason != types.MITMProbeReasonProbeTimeout)
209
+ m.logResult(report, err)
210
+ }()
211
+}
212
+
213
+func (m *mitmManager) finish(success bool) {
214
+ m.mu.Lock()
215
+ m.inFlight = false
216
+ if success {
217
+ m.lastAt = time.Now()
218
+ }
219
+ m.mu.Unlock()
220
+}
221
+
222
+func (m *mitmManager) logResult(report MITMProbeReport, err error) {
223
+ l := m.listener
224
+ switch {
225
+ case l.closed():
226
+ return
227
+ case err != nil:
228
+ if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) {
229
+ return
230
+ }
231
+ log.Warn().
232
+ Err(err).
233
+ Str("relay_url", l.api.baseURL.String()).
234
+ Str("lease_id", l.LeaseID()).
235
+ Msg("tls passthrough self-probe failed")
236
+ case report.Reason == types.MITMProbeReasonProbeTimeout:
237
+ log.Warn().
238
+ Str("relay_url", report.RelayURL).
239
+ Str("public_url", report.PublicURL).
240
+ Str("lease_id", report.LeaseID).
241
+ Msg("tls self-probe timed out before passthrough could be verified")
242
+ case report.Detected:
243
+ log.Warn().
244
+ Str("reason", report.Reason).
245
+ Str("relay_url", report.RelayURL).
246
+ Str("public_url", report.PublicURL).
247
+ Str("lease_id", report.LeaseID).
248
+ Msg("tls termination suspected by self-probe")
249
+ default:
250
+ log.Debug().
251
+ Str("relay_url", report.RelayURL).
252
+ Str("public_url", report.PublicURL).
253
+ Str("lease_id", report.LeaseID).
254
+ Msg("tls passthrough self-probe passed")
255
+ }
256
+}
257
+
258
+func (m *mitmManager) maybeHandleConn(conn net.Conn) (net.Conn, bool, error) {
259
+ if conn == nil {
260
+ return conn, false, nil
261
+ }
262
+
263
+ m.mu.Lock()
264
+ hasPending := len(m.pending) > 0
265
+ m.mu.Unlock()
266
+ if !hasPending {
267
+ return conn, false, nil
268
+ }
269
+
270
+ tlsConn, ok := conn.(*tls.Conn)
271
+ if !ok {
272
+ return conn, false, nil
273
+ }
274
+
275
+ frameSize := 16
276
+ reader := bufio.NewReaderSize(conn, frameSize)
277
+ _ = conn.SetReadDeadline(time.Now().Add(mitmProbePeekTimeout))
278
+ peeked, err := reader.Peek(frameSize)
279
+ defer conn.SetReadDeadline(time.Time{})
280
+ if err != nil {
281
+ return wrapBufferedConn(conn, reader), false, nil
282
+ }
283
+
284
+ nonceHex := hex.EncodeToString(peeked[:frameSize])
285
+ m.mu.Lock()
286
+ _, ok = m.pending[nonceHex]
287
+ m.mu.Unlock()
288
+ if !ok {
289
+ return wrapBufferedConn(conn, reader), false, nil
290
+ }
291
+
292
+ defer conn.Close()
293
+
294
+ frame := make([]byte, frameSize)
295
+ if _, err := io.ReadFull(reader, frame); err != nil {
296
+ return nil, true, fmt.Errorf("read mitm probe frame: %w", err)
297
+ }
298
+
299
+ serverState := tlsConn.ConnectionState()
300
+ actual, err := (&serverState).ExportKeyingMaterial(mitmProbeExporterLabel, nil, 32)
301
+ if err != nil {
302
+ return nil, true, fmt.Errorf("export server probe keying material: %w", err)
303
+ }
304
+
305
+ m.completeProbe(nonceHex, actual)
306
+ return nil, true, nil
307
+}
308
+
309
+func (m *mitmManager) startProbe(nonce string, expected []byte) (<-chan mitmProbeResult, func()) {
310
+ m.mu.Lock()
311
+ state := &mitmProbePending{
312
+ expected: append([]byte(nil), expected...),
313
+ resultCh: make(chan mitmProbeResult, 1),
314
+ }
315
+ m.pending[nonce] = state
316
+ m.mu.Unlock()
317
+
318
+ return state.resultCh, func() {
319
+ m.mu.Lock()
320
+ delete(m.pending, nonce)
321
+ m.mu.Unlock()
322
+ }
323
+}
324
+
325
+func (m *mitmManager) completeProbe(nonce string, actual []byte) {
326
+ m.mu.Lock()
327
+ state := m.pending[nonce]
328
+ m.mu.Unlock()
329
+ if state == nil {
330
+ return
331
+ }
332
+
333
+ result := mitmProbeResult{
334
+ matched: bytes.Equal(state.expected, actual),
335
+ }
336
+ if !result.matched {
337
+ result.reason = types.MITMProbeReasonExporterMismatch
338
+ }
339
+
340
+ select {
341
+ case state.resultCh <- result:
342
+ default:
343
+ }
344
+}
345
+
346
+func wrapMITMProbeConn(manager *mitmManager, conn net.Conn) net.Conn {
347
+ if conn == nil {
348
+ return conn
349
+ }
350
+ return &mitmProbeConn{Conn: conn, manager: manager}
351
+}
352
+
353
+type mitmProbeConn struct {
354
+ net.Conn
355
+ manager *mitmManager
356
+ startOnce sync.Once
357
+}
358
+
359
+func (c *mitmProbeConn) Read(p []byte) (int, error) {
360
+ n, err := c.Conn.Read(p)
361
+ if n > 0 {
362
+ c.startOnce.Do(func() {
363
+ c.manager.maybeStart()
364
+ })
365
+ }
366
+ return n, err
367
+}
368
+
369
+func (c *mitmProbeConn) Write(p []byte) (int, error) {
370
+ n, err := c.Conn.Write(p)
371
+ if n > 0 {
372
+ c.startOnce.Do(func() {
373
+ c.manager.maybeStart()
374
+ })
375
+ }
376
+ return n, err
377
+}
sdk/mitm_test.go
new
+275
@@ -0,0 +1,275 @@
1
+package sdk
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "crypto/ecdsa"
7
+ "crypto/elliptic"
8
+ "crypto/rand"
9
+ "crypto/tls"
10
+ "crypto/x509"
11
+ "crypto/x509/pkix"
12
+ "encoding/hex"
13
+ "encoding/pem"
14
+ "io"
15
+ "math/big"
16
+ "net"
17
+ "testing"
18
+ "time"
19
+
20
+ "github.com/gosuda/portal/v2/types"
21
+)
22
+
23
+func TestMITMProbeConnMatchesExporter(t *testing.T) {
24
+ clientConn, serverConn := newMITMProbeTLSPair(t)
25
+ defer closeMITMProbeTLSConn(clientConn)
26
+ defer closeMITMProbeTLSConn(serverConn)
27
+
28
+ listener := &Listener{}
29
+ listener.mitmManager = newMITMManager(context.Background(), listener)
30
+
31
+ nonce := make([]byte, 16)
32
+ if _, err := rand.Read(nonce); err != nil {
33
+ t.Fatalf("rand.Read() error = %v", err)
34
+ }
35
+ nonceHex := hex.EncodeToString(nonce)
36
+ clientState := clientConn.ConnectionState()
37
+ expected, err := (&clientState).ExportKeyingMaterial(mitmProbeExporterLabel, nil, 32)
38
+ if err != nil {
39
+ t.Fatalf("client ExportKeyingMaterial() error = %v", err)
40
+ }
41
+ resultCh, cleanupProbe := listener.mitmManager.startProbe(nonceHex, expected)
42
+ defer cleanupProbe()
43
+
44
+ handleDone := make(chan struct{})
45
+ go func() {
46
+ defer close(handleDone)
47
+ nextConn, handled, err := listener.mitmManager.maybeHandleConn(serverConn)
48
+ if err != nil {
49
+ t.Errorf("maybeHandleConn() error = %v", err)
50
+ return
51
+ }
52
+ if nextConn != nil {
53
+ t.Error("maybeHandleConn() returned passthrough conn for probe")
54
+ }
55
+ if !handled {
56
+ t.Error("maybeHandleConn() handled = false, want true")
57
+ }
58
+ }()
59
+
60
+ frame := append([]byte(nil), nonce...)
61
+ frame = append(frame, bytes.Repeat([]byte{0xAB}, 128)...)
62
+ if _, err := clientConn.Write(frame); err != nil {
63
+ t.Fatalf("clientConn.Write() error = %v", err)
64
+ }
65
+ _ = clientConn.Close()
66
+
67
+ select {
68
+ case result := <-resultCh:
69
+ if !result.matched {
70
+ t.Fatalf("probe matched = false, reason = %q", result.reason)
71
+ }
72
+ case <-time.After(2 * time.Second):
73
+ t.Fatal("timed out waiting for probe result")
74
+ }
75
+
76
+ select {
77
+ case <-handleDone:
78
+ case <-time.After(2 * time.Second):
79
+ t.Fatal("timed out waiting for probe handler")
80
+ }
81
+}
82
+
83
+func TestMITMProbeConnDetectsExporterMismatch(t *testing.T) {
84
+ clientConn, serverConn := newMITMProbeTLSPair(t)
85
+ defer closeMITMProbeTLSConn(clientConn)
86
+ defer closeMITMProbeTLSConn(serverConn)
87
+
88
+ listener := &Listener{}
89
+ listener.mitmManager = newMITMManager(context.Background(), listener)
90
+
91
+ nonce := make([]byte, 16)
92
+ if _, err := rand.Read(nonce); err != nil {
93
+ t.Fatalf("rand.Read() error = %v", err)
94
+ }
95
+ nonceHex := hex.EncodeToString(nonce)
96
+ resultCh, cleanupProbe := listener.mitmManager.startProbe(nonceHex, make([]byte, 32))
97
+ defer cleanupProbe()
98
+
99
+ handleDone := make(chan struct{})
100
+ go func() {
101
+ defer close(handleDone)
102
+ nextConn, handled, err := listener.mitmManager.maybeHandleConn(serverConn)
103
+ if err != nil {
104
+ t.Errorf("maybeHandleConn() error = %v", err)
105
+ return
106
+ }
107
+ if nextConn != nil {
108
+ t.Error("maybeHandleConn() returned passthrough conn for probe")
109
+ }
110
+ if !handled {
111
+ t.Error("maybeHandleConn() handled = false, want true")
112
+ }
113
+ }()
114
+
115
+ frame := append([]byte(nil), nonce...)
116
+ frame = append(frame, bytes.Repeat([]byte{0xCD}, 128)...)
117
+ if _, err := clientConn.Write(frame); err != nil {
118
+ t.Fatalf("clientConn.Write() error = %v", err)
119
+ }
120
+ _ = clientConn.Close()
121
+
122
+ select {
123
+ case result := <-resultCh:
124
+ if result.matched {
125
+ t.Fatal("probe matched = true, want false")
126
+ }
127
+ if result.reason != types.MITMProbeReasonExporterMismatch {
128
+ t.Fatalf("probe reason = %q, want %q", result.reason, types.MITMProbeReasonExporterMismatch)
129
+ }
130
+ case <-time.After(2 * time.Second):
131
+ t.Fatal("timed out waiting for probe result")
132
+ }
133
+
134
+ select {
135
+ case <-handleDone:
136
+ case <-time.After(2 * time.Second):
137
+ t.Fatal("timed out waiting for probe handler")
138
+ }
139
+}
140
+
141
+func TestMITMProbeConnPassesThroughNormalTraffic(t *testing.T) {
142
+ clientConn, serverConn := newMITMProbeTLSPair(t)
143
+ defer closeMITMProbeTLSConn(clientConn)
144
+ defer closeMITMProbeTLSConn(serverConn)
145
+
146
+ listener := &Listener{}
147
+ listener.mitmManager = newMITMManager(context.Background(), listener)
148
+
149
+ type handleResult struct {
150
+ conn net.Conn
151
+ handled bool
152
+ err error
153
+ }
154
+ handleResultCh := make(chan handleResult, 1)
155
+ go func() {
156
+ nextConn, handled, err := listener.mitmManager.maybeHandleConn(serverConn)
157
+ handleResultCh <- handleResult{conn: nextConn, handled: handled, err: err}
158
+ }()
159
+
160
+ payload := []byte("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
161
+ var result handleResult
162
+ select {
163
+ case result = <-handleResultCh:
164
+ case <-time.After(2 * time.Second):
165
+ t.Fatal("timed out waiting for passthrough result")
166
+ }
167
+
168
+ if result.err != nil {
169
+ t.Fatalf("maybeHandleConn() error = %v", result.err)
170
+ }
171
+ if result.handled {
172
+ t.Fatal("maybeHandleConn() handled = true, want false")
173
+ }
174
+ if result.conn == nil {
175
+ t.Fatal("maybeHandleConn() returned nil passthrough conn")
176
+ }
177
+
178
+ writeErrCh := make(chan error, 1)
179
+ go func() {
180
+ _, err := clientConn.Write(payload)
181
+ writeErrCh <- err
182
+ }()
183
+
184
+ got := make([]byte, len(payload))
185
+ if _, err := io.ReadFull(result.conn, got); err != nil {
186
+ t.Fatalf("ReadFull() error = %v", err)
187
+ }
188
+ select {
189
+ case err := <-writeErrCh:
190
+ if err != nil {
191
+ t.Fatalf("clientConn.Write() error = %v", err)
192
+ }
193
+ case <-time.After(2 * time.Second):
194
+ t.Fatal("timed out waiting for client write")
195
+ }
196
+ if !bytes.Equal(got, payload) {
197
+ t.Fatalf("passthrough payload = %q, want %q", got, payload)
198
+ }
199
+}
200
+
201
+func newMITMProbeTLSPair(t *testing.T) (*tls.Conn, *tls.Conn) {
202
+ t.Helper()
203
+
204
+ cert := newMITMProbeCertificate(t)
205
+ clientRaw, serverRaw := net.Pipe()
206
+ clientConn := tls.Client(clientRaw, &tls.Config{
207
+ InsecureSkipVerify: true,
208
+ MinVersion: tls.VersionTLS13,
209
+ NextProtos: []string{"http/1.1"},
210
+ })
211
+ serverConn := tls.Server(serverRaw, &tls.Config{
212
+ Certificates: []tls.Certificate{cert},
213
+ MinVersion: tls.VersionTLS13,
214
+ NextProtos: []string{"http/1.1"},
215
+ })
216
+
217
+ errCh := make(chan error, 2)
218
+ go func() { errCh <- serverConn.HandshakeContext(context.Background()) }()
219
+ go func() { errCh <- clientConn.HandshakeContext(context.Background()) }()
220
+ for range 2 {
221
+ if err := <-errCh; err != nil {
222
+ t.Fatalf("TLS handshake error = %v", err)
223
+ }
224
+ }
225
+
226
+ return clientConn, serverConn
227
+}
228
+
229
+func closeMITMProbeTLSConn(conn *tls.Conn) {
230
+ if conn == nil {
231
+ return
232
+ }
233
+ _ = conn.SetDeadline(time.Now())
234
+ _ = conn.Close()
235
+}
236
+
237
+func newMITMProbeCertificate(t *testing.T) tls.Certificate {
238
+ t.Helper()
239
+
240
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
241
+ if err != nil {
242
+ t.Fatalf("GenerateKey() error = %v", err)
243
+ }
244
+
245
+ template := &x509.Certificate{
246
+ SerialNumber: big.NewInt(1),
247
+ Subject: pkix.Name{
248
+ CommonName: "portal-mitm-probe",
249
+ },
250
+ NotBefore: time.Now().Add(-time.Hour),
251
+ NotAfter: time.Now().Add(time.Hour),
252
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
253
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
254
+ BasicConstraintsValid: true,
255
+ DNSNames: []string{"localhost"},
256
+ }
257
+
258
+ der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
259
+ if err != nil {
260
+ t.Fatalf("CreateCertificate() error = %v", err)
261
+ }
262
+
263
+ keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
264
+ if err != nil {
265
+ t.Fatalf("MarshalPKCS8PrivateKey() error = %v", err)
266
+ }
267
+
268
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
269
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
270
+ cert, err := tls.X509KeyPair(certPEM, keyPEM)
271
+ if err != nil {
272
+ t.Fatalf("X509KeyPair() error = %v", err)
273
+ }
274
+ return cert
275
+}
types/error.go
+3
@@ -24,4 +24,7 @@ const (
24
APIErrorCodeUDPDisabled = "udp_disabled"
25
APIErrorCodeUDPCapacityExceeded = "udp_capacity_exceeded"
26
APIErrorCodeTransportMismatch = "transport_mismatch"
27
+
28
+ MITMProbeReasonExporterMismatch = "tls_exporter_mismatch"
29
+ MITMProbeReasonProbeTimeout = "probe_timeout"
30
)