Skip to content

feat(httpconnect): add NewH2ProxyTransport for pure H2 CONNECT tunneling #589

Open
fortuna wants to merge 9 commits intomainfrom
feat/h2-proxy-transport
Open

feat(httpconnect): add NewH2ProxyTransport for pure H2 CONNECT tunneling #589
fortuna wants to merge 9 commits intomainfrom
feat/h2-proxy-transport

Conversation

@fortuna
Copy link
Contributor

@fortuna fortuna commented Mar 7, 2026

Summary

Adds HTTP CONNECT proxy tunnel support to the httpconnect package and integrates it into configurl.

x/httpconnect — new HTTP CONNECT tunnel client over H1, H2, and H3:

  • NewHTTPProxyTransport — H1.1 or H2 via TLS ALPN (multiplexed when H2 is negotiated)
  • NewH2ProxyTransport — pure HTTP/2; always multiplexed; supports h2c (cleartext) via
    WithPlainHTTP()
  • NewH3ProxyTransport — HTTP/3 over QUIC; always multiplexed (renamed from NewHTTP3ProxyTransport)
  • NewConnectClienttransport.StreamDialer that sends CONNECT requests through any of the above
  • Extended httpproxy.NewConnectHandler to support H2/H3 streams (no longer requires http.Hijacker)
  • Package docs with Caddy setup guide for manual testing

x/configurl — three new transport types:

  • httpconnect://host:port[?sni=SNI][&certname=CERTNAME]
  • h2connect://host:port[?sni=SNI][&certname=CERTNAME][&plain=true]
  • h3connect://host:port[?sni=SNI][&certname=CERTNAME]

This improves #319

TLS path correctness

When TLS is used, DialTLSContext performs the handshake using stdTLS.Client() and returns
*stdTLS.Conn directly. This is required because http2.Transport internally type-asserts to
*tls.Conn to read NegotiatedProtocol — returning the SDK's tls.WrapConn wrapper would
silently break protocol negotiation.

Tests

New tests covering all new paths, plus a refactor of the existing test suite:

Test What it covers
Test_ConnectClient_H2C h2c via NewH2ProxyTransport + WithPlainHTTP(), served by http2.Server.ServeConn on a raw TCP listener
Test_ConnectClient_H2_TLS_Multiplexed TLS path of NewH2ProxyTransport; asserts 3 concurrent CONNECT streams share exactly 1 TCP connection using a counting dialer

The existing test file was also refactored for readability:

  • Replaced the single table-driven test with standalone test functions
    (Test_ConnectClient_{protocol}_{modifier})
  • Extracted verifyTunnel() and newH2ConnectHandler() helpers to eliminate duplication
  • Replaced custom RSA CA generation with httptest's built-in certificate via proxySrv.Certificate()
  • Added doc comments to all test functions and helpers

fortuna and others added 3 commits March 7, 2026 17:23
Uses golang.org/x/net/http2.Transport directly, enabling:
- h2c (cleartext H2 via prior knowledge) with WithPlainHTTP() — no TLS required
- H2-TLS path with direct stdTLS.Client() handshake (required for http2.Transport
  type assertion to *tls.Conn for NegotiatedProtocol)
- Multiple concurrent CONNECT tunnels multiplexed over one TCP connection

Adds test case for h2c using http2.Server.ServeConn on a raw TCP listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…plexing assertion

Verifies the TLS path of NewH2ProxyTransport by opening 3 concurrent CONNECT
tunnels and asserting that only 1 TCP connection was established to the proxy.
Uses transport.FuncStreamDialer to count connections at the dial level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace table-driven test with standalone test functions per transport variant
- Consistent naming: Test_ConnectClient_{protocol}_{modifier}
- Extract verifyTunnel() and newH2ConnectHandler() helpers to eliminate duplication
- Replace generateRootCA() with tlsCertPool() backed by httptest's built-in cert
- Add doc comments to all test functions, helpers, and types
- Use t.Cleanup instead of defer throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@fortuna
Copy link
Contributor Author

fortuna commented Mar 7, 2026

/cc @nikolaikabanenkov

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds NewH2ProxyTransport, a new transport factory that uses golang.org/x/net/http2.Transport directly (instead of wrapping net/http.Transport with http2.ConfigureTransport). This enables two new capabilities not available in the existing NewHTTPProxyTransport: true H2 multiplexing (multiple concurrent CONNECT tunnels over a single TCP connection) and h2c (cleartext HTTP/2 via prior knowledge) via WithPlainHTTP(). The PR also refactors the existing test suite from a single table-driven test into standalone, well-documented test functions.

Changes:

  • NewH2ProxyTransport factory function supporting both h2c (plaintext) and TLS modes, with correct *tls.Conn return in DialTLSContext to satisfy http2.Transport's internal type assertion
  • Two new tests: Test_ConnectClient_H2C (h2c via raw TCP + http2.Server.ServeConn) and Test_ConnectClient_H2_TLS_Multiplexed (asserts 3 concurrent streams share 1 TCP connection using a counting dialer)
  • Test refactor: table-driven test split into standalone functions, shared helpers extracted (verifyTunnel, newH2ConnectHandler, tlsCertPool), custom RSA CA generation removed in favor of httptest's built-in certificate

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
x/httpconnect/transport.go Adds NewH2ProxyTransport with h2c and TLS paths using golang.org/x/net/http2.Transport
x/httpconnect/connect_client_test.go Refactors tests to standalone functions; adds Test_ConnectClient_H2C and Test_ConnectClient_H2_TLS_Multiplexed

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

- Detect http.Hijacker to branch between H1 (hijack+bufio) and H2/H3
  (ResponseWriter with explicit flushing) relay strategies
- Share relay logic after the branch: client→target goroutine with
  ReadFrom-preferred copy, target→client via clientWriter.ReadFrom
- Add flushingWriter for H2/H3: Write flushes after every call;
  ReadFrom shadows http.ResponseWriter's own ReadFrom (which doesn't
  flush) and prefers r.WriteTo to avoid an intermediate buffer
- Remove newH2ConnectHandler from httpconnect tests now that
  httpproxy.NewConnectHandler supports H2/H3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fortuna and others added 2 commits March 9, 2026 00:45
…me NewHTTP3ProxyTransport to NewH3ProxyTransport

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Register HTTP CONNECT proxy transports in the configurl package:
- httpconnect://host:port — H1.1 or H2 via ALPN (multiplexed when H2 is negotiated)
- h2connect://host:port — pure H2, always multiplexed; supports h2c via ?plain=true
- h3connect://host:port — H3/QUIC, always multiplexed

All three support ?sni= and ?certname= query parameters for TLS configuration.
Includes an end-to-end test exercising h2connect over h2c.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

x/httpconnect/connect_client_test.go:454

  • The flusherWriter type (lines 427–454) is defined but no longer used by any test in the refactored test suite. It was only used in the old inline H2 test-server code that has been replaced by httpproxy.NewConnectHandler. This dead code should be removed to keep the file clean.
// flusherWriter wraps an http.ResponseWriter so that every Write is followed by a Flush.
// This is required when relaying data over an H2 response stream: without explicit flushing,
// written bytes sit in the buffer and the remote end never receives them.
type flusherWriter struct {
	http.Flusher
	io.Writer
}

func (fw flusherWriter) ReadFrom(r io.Reader) (int64, error) {
	var (
		buf   = make([]byte, 32*1024)
		total int64
	)
	for {
		nr, er := r.Read(buf)
		if nr > 0 {
			nw, ew := fw.Writer.Write(buf[:nr])
			total += int64(nw)
			if ew != nil {
				return total, ew
			}
			fw.Flush()
		}
		if er != nil {
			if er == io.EOF {
				return total, nil
			}
			return total, er
		}
	}
}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

fortuna and others added 3 commits March 9, 2026 01:44
…Parser via option

NewConnectHandler no longer imports configurl. The Transport header feature
is now opt-in via WithStreamDialerParser, resolving the import cycle
httpconnect → httpproxy → configurl → httpconnect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- configurl/doc.go: replace broken [WithPlainHTTP] godoc link with plain text
- httpconnect/connect_client_test.go: fix Test_ConnectClient_H2_TLS comment
  (it uses NewH2ProxyTransport, not NewHTTPProxyTransport)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… builder list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants