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 )