@@ -222,3 +222,183 @@ func TestTransparentBridge(t *testing.T) {
222222 t .Fatalf ("HandleTransparentConn did not return after conns closed" )
223223 }
224224}
225+
226+ // TestConnectBridge drives the full CONNECT → 200 → agent-TLS → decrypt →
227+ // pipeline → re-originate loop through the PUBLIC forward-proxy HTTP handler
228+ // (so the real net/http Hijack path runs). An httptest TLS origin stands in
229+ // for the upstream. The agent dials the proxy, issues CONNECT, reads the 200,
230+ // then speaks TLS over the SAME raw conn trusting the bridge's ephemeral CA;
231+ // the proxy forges a leaf, terminates, runs the pipeline (probe records the
232+ // plaintext), and re-originates to the real origin. Body must arrive intact.
233+ func TestConnectBridge (t * testing.T ) {
234+ // 1) TLS origin on a random port. CONNECT doesn't go through shouldSniff,
235+ // so any port works here.
236+ var gotOriginPath string
237+ handler := http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
238+ gotOriginPath = r .URL .Path
239+ if r .URL .Path == "/secret" {
240+ _ , _ = w .Write ([]byte ("OK-SECRET" ))
241+ return
242+ }
243+ w .WriteHeader (http .StatusNotFound )
244+ })
245+ origin := httptest .NewTLSServer (handler )
246+ defer origin .Close ()
247+
248+ originCAPEM := pem .EncodeToMemory (& pem.Block {
249+ Type : "CERTIFICATE" ,
250+ Bytes : origin .Certificate ().Raw ,
251+ })
252+
253+ originURL , err := url .Parse (origin .URL )
254+ if err != nil {
255+ t .Fatalf ("parse origin URL: %v" , err )
256+ }
257+ originHostPort := originURL .Host // "127.0.0.1:port"
258+
259+ // 2) Build the bridge Engine. ScopeAll so the loopback origin isn't
260+ // treated as in-cluster and skipped. Ports must include the origin's
261+ // random port (the CONNECT classify keys on portOf(r.Host)).
262+ src , err := tlsbridge .NewEphemeralSource ()
263+ if err != nil {
264+ t .Fatalf ("NewEphemeralSource: %v" , err )
265+ }
266+ minter := tlsbridge .NewMinter (src , tlsbridge.MinterOpts {})
267+ up , err := tlsbridge .NewUpstreamClient (originCAPEM )
268+ if err != nil {
269+ t .Fatalf ("NewUpstreamClient: %v" , err )
270+ }
271+ engine := & tlsbridge.Engine {
272+ Decision : tlsbridge .NewDecision (tlsbridge.DecisionOpts {
273+ Scope : tlsbridge .ScopeAll ,
274+ Ports : map [int ]bool {portOf (originHostPort ): true },
275+ }),
276+ Term : tlsbridge .NewTerminator (minter ),
277+ Skip : tlsbridge .NewSkipSet (),
278+ Upstream : up ,
279+ CAPEM : src .CACertPEM (),
280+ }
281+
282+ // 3) Server wired with a real OutboundPipeline carrying the recording probe.
283+ probe := & bridgeProbePlugin {}
284+ p , err := plugintesting .BuildPipeline ([]pipeline.Plugin {probe })
285+ if err != nil {
286+ t .Fatalf ("BuildPipeline: %v" , err )
287+ }
288+ srv := & Server {
289+ OutboundPipeline : pipeline .NewHolder (p ),
290+ Client : http .DefaultClient ,
291+ TLSBridge : engine ,
292+ }
293+
294+ // 4) Stand up the public forward-proxy HTTP handler so Hijack works.
295+ proxy := httptest .NewServer (srv .Handler ())
296+ defer proxy .Close ()
297+ proxyURL , err := url .Parse (proxy .URL )
298+ if err != nil {
299+ t .Fatalf ("parse proxy URL: %v" , err )
300+ }
301+
302+ // 5) Raw-dial the proxy and issue CONNECT to the origin host:port.
303+ rawConn , err := net .Dial ("tcp" , proxyURL .Host )
304+ if err != nil {
305+ t .Fatalf ("dial proxy: %v" , err )
306+ }
307+ defer func () { _ = rawConn .Close () }()
308+ _ = rawConn .SetDeadline (time .Now ().Add (10 * time .Second ))
309+
310+ connectReq , err := http .NewRequest (http .MethodConnect , "//" + originHostPort , nil )
311+ if err != nil {
312+ t .Fatalf ("new CONNECT request: %v" , err )
313+ }
314+ connectReq .Host = originHostPort
315+ connectReq .URL .Host = originHostPort
316+ if err := connectReq .Write (rawConn ); err != nil {
317+ t .Fatalf ("write CONNECT: %v" , err )
318+ }
319+
320+ // Read the 200 Connection Established. http.ReadResponse against the
321+ // CONNECT request consumes exactly the status line + headers (no body),
322+ // leaving the raw conn positioned at the first post-200 byte — which is
323+ // where the agent's ClientHello begins.
324+ br := bufio .NewReader (rawConn )
325+ connectResp , err := http .ReadResponse (br , connectReq )
326+ if err != nil {
327+ t .Fatalf ("read CONNECT response: %v" , err )
328+ }
329+ if connectResp .StatusCode != http .StatusOK {
330+ t .Fatalf ("CONNECT status = %d, want 200" , connectResp .StatusCode )
331+ }
332+ _ = connectResp .Body .Close ()
333+
334+ // 6) Agent-side TLS over the SAME raw conn (wrapped so any bytes ReadResponse
335+ // buffered past the 200 are replayed — there should be none, but be safe).
336+ pool := x509 .NewCertPool ()
337+ if ! pool .AppendCertsFromPEM (engine .CAPEM ) {
338+ t .Fatalf ("failed to append bridge CA PEM to pool" )
339+ }
340+ host := hostOnly (originHostPort ) // "127.0.0.1"
341+ tlsTransport := & bufferedConn {Conn : rawConn , r : br }
342+ tconn := tls .Client (tlsTransport , & tls.Config {
343+ ServerName : host ,
344+ RootCAs : pool ,
345+ NextProtos : []string {"http/1.1" },
346+ })
347+ _ = tconn .SetDeadline (time .Now ().Add (10 * time .Second ))
348+ if err := tconn .Handshake (); err != nil {
349+ t .Fatalf ("agent-side TLS handshake through CONNECT bridge failed: %v" , err )
350+ }
351+
352+ // 7) GET /secret over the bridged TLS conn. Same goroutine-write +
353+ // http.ReadResponse split as TestTransparentBridge: the server makes a
354+ // blocking upstream round-trip between reading the request and writing
355+ // the response, so a single-goroutine RoundTrip can wedge.
356+ req , err := http .NewRequest (http .MethodGet , "https://" + host + "/secret" , nil )
357+ if err != nil {
358+ t .Fatalf ("new request: %v" , err )
359+ }
360+ writeErr := make (chan error , 1 )
361+ go func () { writeErr <- req .Write (tconn ) }()
362+
363+ resp , err := http .ReadResponse (bufio .NewReader (tconn ), req )
364+ if err != nil {
365+ t .Fatalf ("read response over bridged TLS conn: %v" , err )
366+ }
367+ defer resp .Body .Close ()
368+ body , err := io .ReadAll (resp .Body )
369+ if err != nil {
370+ t .Fatalf ("read response body: %v" , err )
371+ }
372+ if werr := <- writeErr ; werr != nil {
373+ t .Fatalf ("write request over bridged TLS conn: %v" , werr )
374+ }
375+
376+ // 8) Assertions.
377+ if probe .calls == 0 {
378+ t .Fatalf ("probe plugin never ran — pipeline did not see decrypted request (CONNECT bridge branch missing?)" )
379+ }
380+ if probe .gotPath != "/secret" {
381+ t .Errorf ("probe recorded path = %q, want /secret" , probe .gotPath )
382+ }
383+ if probe .gotMethod != http .MethodGet {
384+ t .Errorf ("probe recorded method = %q, want GET" , probe .gotMethod )
385+ }
386+ if gotOriginPath != "/secret" {
387+ t .Errorf ("origin received path = %q, want /secret" , gotOriginPath )
388+ }
389+ if got := strings .TrimSpace (string (body )); got != "OK-SECRET" {
390+ t .Errorf ("response body = %q, want OK-SECRET" , got )
391+ }
392+ }
393+
394+ // bufferedConn wraps a net.Conn whose leading bytes were partly drained into a
395+ // bufio.Reader (e.g. by http.ReadResponse on the CONNECT 200), so a subsequent
396+ // reader (the agent's tls.Client) replays those buffered bytes before reading
397+ // from the wire. Mirrors peekedConn's Read-replays-buffered semantics for the
398+ // client side of the test harness.
399+ type bufferedConn struct {
400+ net.Conn
401+ r * bufio.Reader
402+ }
403+
404+ func (c * bufferedConn ) Read (p []byte ) (int , error ) { return c .r .Read (p ) }
0 commit comments