ConnectProxy (fork of sters/connectproxy)
A Go library implementing a proxy.ContextDialer over HTTP(S) CONNECT proxies. It is heavily based on jim3ma's gist and designed to complement the golang.org/x/net/proxy package.
This library allows you to proxy net/http connections (and by extension, any TCP connection that goes through a dialer) through an HTTP or HTTPS CONNECT proxy server. It is useful for:
- Routing HTTP client requests through a corporate proxy
- Tunneling arbitrary TCP traffic through a CONNECT proxy
- Domain fronting with different SNI names and Host headers
- Integrating with Go's
proxy.FromURLviaRegisterDialerType
| Scheme | Connection Type | TLS to Proxy |
|---|---|---|
http:// |
Plaintext TCP | No |
https:// |
Encrypted | Yes (configurable) |
Any other scheme (e.g., socks5://) returns ErrUnsupportedProxyScheme. Custom schemes registered via proxy.RegisterDialerType (e.g. myproxy://) are treated as HTTP proxies — the Scheme is normalized to "http" and only the Host field determines the proxy address.
proxyURL, _ := url.Parse("http://user:pass@proxy.example.com:8080")
dialer, err := connectproxy.New(proxyURL, proxy.Direct)
if err != nil {
log.Fatal(err)
}
conn, err := dialer.DialContext(ctx, "tcp", "target.example.com:443")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// Use conn as a net.ConnRegister connectproxy as the handler for any scheme — not just "http"/"https". When used with proxy.FromURL, custom schemes are normalized to HTTP internally:
connectproxy.GeneratorWithConfig(&connectproxy.Config{
DialTimeout: 30 * time.Second,
})
proxy.RegisterDialerType("http", connectproxy.GeneratorWithConfig(nil))
proxy.RegisterDialerType("https", connectproxy.GeneratorWithConfig(&connectproxy.Config{
DialTimeout: 30 * time.Second,
}))
// Then use proxy.FromURL with any registered scheme:
proxyURL, _ := url.Parse("http://user:pass@proxy.example.com:8080")
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)Custom schemes work too — myproxy is normalized to "http" inside the generator:
proxy.RegisterDialerType("myproxy", connectproxy.GeneratorWithConfig(&connectproxy.Config{
DialTimeout: 30 * time.Second,
}))
// proxy.FromURL strips the scheme and passes a URL with empty Scheme but Host intact.
// NewWithConfig normalizes this back to "http" before creating the dialer.
myproxyURL, _ := url.Parse("myproxy://user:pass@proxy.example.com:8080")
dialer, err := proxy.FromURL(myproxyURL, proxy.Direct)This fork addresses a critical goroutine leak issue documented in golang.org/x/net/proxy:
"Custom dialers (registered via RegisterDialerType) that do not implement ContextDialer can leak a goroutine for as long as it takes the underlying Dialer implementation to timeout."
The parent fork's connectDialer implements proxy.ContextDialer, ensuring context cancellation propagates through the entire CONNECT handshake. This means:
- No leaked timer goroutines: The old pattern of using a separate timer for read-response timeouts has been replaced with direct
<-ctx.Done()select statements. - Deadline injection respects existing context deadlines:
DialTimeoutis applied only if it expires sooner than any deadline already set on the context (matchingproxy.DialContextbehavior). - GeneratorWithConfig bridges legacy and modern APIs: The returned generator matches
proxy.RegisterDialerType's expected signature (func(*url.URL, proxy.Dialer) (proxy.Dialer, error)), while internally converting to/fromContextDialer. A lightweightctxDialerwrapper ensures that legacy dialers passed viaRegisterDialerTypeare treated as context-aware.
The library uses a header merge strategy (not replacement) for CONNECT request headers:
- Entries from
config.Headerare appended to the request headers individually - Nil-valued header entries are skipped, preventing them from overwriting other headers
Proxy-Authorizationis set after config headers viareq.Header.Set("Proxy-Authorization", "Basic ..."), so it is never clobbered by a nil entry inconfig.Header
This fixes the issue where setting Header: http.Header{"X-Custom": {"value"}} would previously erase any Proxy-Authorization header derived from URL credentials.
| Field | Type | Default | Description |
|---|---|---|---|
ServerName |
string |
Derived from proxy URL host | TLS server name for SNI and certificate validation when connecting to an HTTPS proxy |
InsecureSkipVerify |
bool |
false |
Accept any certificate from the HTTPS proxy without verification |
Header |
http.Header |
nil |
Additional headers to append to the CONNECT request. Nil-valued entries are skipped (unlike direct assignment which would overwrite). Do not use for Proxy-Authorization; set credentials in the URL's User field instead. |
DialTimeout |
time.Duration |
0 (no timeout) |
Maximum duration for the CONNECT handshake only (not subsequent reads/writes). Applied as a deadline on the context — only if it expires sooner than any existing context deadline. |
TLSConfig |
*tls.Config |
nil |
Custom TLS configuration for HTTPS proxy connections. When set, completely replaces the minimal tls.Config built from ServerName and InsecureSkipVerify. |
The Config struct may be mutated between calls to DialContext. Changes take effect immediately on subsequent invocations. This allows runtime reconfiguration without creating new dialers.
See examples_test.go for comprehensive examples covering all exported functions and configuration options. Run them with:
go test -v -run ExampleBasic proxy usage:
proxyURL, _ := url.Parse("http://user:pass@proxy.example.com:8080")
dialer, err := connectproxy.New(proxyURL, proxy.Direct)
// dialer implements proxy.ContextDialer — use DialContext with context.Background() or a cancelled contextDomain fronting (different SNI from Host header):
dialer, err := connectproxy.NewWithConfig(
url.Parse("https://sneakyvhost.com:443"),
proxy.Direct,
&connectproxy.Config{ServerName: "normalhost.example.com"},
)
// TLS handshake uses SNI "normalhost.example.com" but Host header says "sneakyvhost.com"Registering with golang.org/x/net/proxy:
proxy.RegisterDialerType("http", connectproxy.GeneratorWithConfig(&connectproxy.Config{
DialTimeout: 30 * time.Second,
}))
// Now proxy.FromURL recognizes "http://" schemes:
proxyURL, _ := url.Parse("http://user:pass@proxy.example.com:8080")
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)HTTP client integration:
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, addr)
},
}
client := &http.Client{Transport: transport}
resp, err := client.Get("https://target.example.com")| Error | When Returned |
|---|---|
ErrUnsupportedProxyScheme |
URL scheme is neither "http" nor "https" |
ErrNonOKResponse |
Proxy server responds with a status other than 200 (e.g., 403 Forbidden) |
Both are exported as package-level variables for use with errors.Is():
if errors.Is(err, connectproxy.ErrUnsupportedProxyScheme) { ... }| Feature | This Fork | Parent Fork (magisterquis) |
|---|---|---|
| Context support | Full DialContext with deadline injection |
Legacy Dial only (goroutine leak risk) |
| Header merge strategy | Append non-nil entries, skip nils | Replace entire header map |
| Proxy-Authorization placement | Set after config headers | Risk of clobbering by nil entries |
GeneratorWithConfig |
Returns func(*url.URL, proxy.Dialer) matching RegisterDialerType signature |
N/A |
ctxDialer wrapper |
Bridges legacy proxy.Dialer to internal ContextDialer requirement |
N/A |
| Example tests | Comprehensive with GoDoc examples on pkg.go.dev | Minimal or none |
MIT — see LICENSE for details.