feat(dialer): add HTTP/HTTPS proxy support via CONNECT (#10572)

Register HTTP and HTTPS proxy dialers and implement CONNECT-based
tunneling for HTTP proxies.

The new dialer supports:
- Plain HTTP proxies using CONNECT
- HTTPS proxies by performing a TLS handshake before CONNECT
- Optional basic authentication via Proxy-Authorization (with a warning
when creds are used over cleartext HTTP)

This allows all_proxy to be set to http:// or https:// URLs, enabling
data transfer through HTTP(S) proxies.

### Purpose

Allow peers to connect using HTTP Proxies (CONNECT)

### Testing

Tested with both HTTP and HTTPS proxy connection, using both no auth and
plain authentication.

### Screenshots

No visual change

### Documentation

https://github.com/syncthing/docs/pull/987

## Authorship

Your name and email will be added automatically to the AUTHORS file
based on the commit metadata.

---------

Signed-off-by: Luiz Angelo Daros de Luca <luizluca@gmail.com>
Signed-off-by: Jakob Borg <jakob@kastelo.net>
Co-authored-by: Jakob Borg <jakob@kastelo.net>
This commit is contained in:
Luiz Angelo Daros de Luca
2026-04-26 06:31:40 -03:00
committed by GitHub
parent 987e631176
commit 84c6b37913
+116
View File
@@ -7,11 +7,17 @@
package dialer
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"sync"
"time"
"golang.org/x/net/proxy"
@@ -21,6 +27,8 @@ var noFallback = os.Getenv("ALL_PROXY_NO_FALLBACK") != ""
func init() {
proxy.RegisterDialerType("socks", socksDialerFunction)
proxy.RegisterDialerType("http", httpDialerFunction)
proxy.RegisterDialerType("https", httpDialerFunction)
if proxyDialer := proxy.FromEnvironment(); proxyDialer != proxy.Direct {
http.DefaultTransport = &http.Transport{
@@ -59,6 +67,114 @@ func socksDialerFunction(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error)
return proxy.SOCKS5("tcp", u.Host, auth, forward)
}
type httpProxyDialer struct {
proxyURL *url.URL
forwardDialer proxy.Dialer
}
func httpDialerFunction(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error) {
return &httpProxyDialer{
proxyURL: u,
forwardDialer: forward,
}, nil
}
func (h *httpProxyDialer) Dial(network, addr string) (net.Conn, error) {
return h.DialContext(context.Background(), network, addr)
}
// bufferedConn wraps a bufio.Reader (needed by http.ReadResponse) while
// providing the other net.Conn methods via embedding.
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (c *bufferedConn) Read(b []byte) (int, error) {
return c.reader.Read(b)
}
var warnCleartextProxyAuthOnce sync.Once
func (h *httpProxyDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, fmt.Errorf("unsupported network for http proxy: %s", network)
}
var conn net.Conn
var err error
if cd, ok := h.forwardDialer.(proxy.ContextDialer); ok {
conn, err = cd.DialContext(ctx, "tcp", h.proxyURL.Host)
} else {
conn, err = h.forwardDialer.Dial("tcp", h.proxyURL.Host)
}
if err != nil {
return nil, fmt.Errorf("dial proxy %s: %w", h.proxyURL.Host, err)
}
if deadline, ok := ctx.Deadline(); ok {
if err := conn.SetDeadline(deadline); err != nil {
conn.Close()
return nil, fmt.Errorf("set proxy connection deadline: %w", err)
}
defer func() { _ = conn.SetDeadline(time.Time{}) }()
}
if h.proxyURL.Scheme == "https" {
tlsConn := tls.Client(conn, &tls.Config{
ServerName: h.proxyURL.Hostname(),
})
if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, fmt.Errorf("https proxy handshake: %w", err)
}
conn = tlsConn
}
req := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Host: addr},
Host: addr,
Header: make(http.Header),
}
req = req.WithContext(ctx)
if u := h.proxyURL.User; u != nil {
if h.proxyURL.Scheme == "http" {
warnCleartextProxyAuthOnce.Do(func() {
slog.WarnContext(ctx,
"Using basic auth over cleartext HTTP proxy",
"proxy", h.proxyURL.Redacted(),
)
})
}
password, _ := u.Password()
auth := base64.StdEncoding.EncodeToString([]byte(u.Username() + ":" + password))
req.Header.Set("Proxy-Authorization", "Basic "+auth)
}
if err := req.Write(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("write proxy CONNECT request: %w", err)
}
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, req)
if err != nil {
conn.Close()
return nil, fmt.Errorf("read proxy CONNECT response: %w", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
conn.Close()
return nil, fmt.Errorf("http proxy CONNECT failed: %s", resp.Status)
}
return &bufferedConn{Conn: conn, reader: br}, nil
}
// dialerConn is needed because proxy dialed connections have RemoteAddr() pointing at the proxy,
// which then screws up various things such as IsLAN checks, and "let's populate the relay invitation address from
// existing connection" shenanigans.