Skip to content
Open
15 changes: 15 additions & 0 deletions x/configurl/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ SOCKS5 proxy (works with both stream and packet dialers, package [golang.getoutl

USERINFO field is optional and only required if username and password authentication is used. It is in the format of username:password.

HTTP CONNECT proxy (streams only, package [golang.getoutline.org/sdk/x/httpconnect])

Three variants are available:

- httpconnect: HTTP/1.1, or HTTP/2 if negotiated via TLS ALPN. When H2 is negotiated, CONNECT streams are multiplexed over a single TCP connection.
- h2connect: Pure HTTP/2. Always multiplexed. Supports h2c (cleartext H2) via plain=true.
- h3connect: HTTP/3 over QUIC. Always multiplexed. Creates its own UDP socket.

The sni parameter sets the TLS SNI. The certname parameter sets the name to validate against the server certificate.
For h2connect, plain=true enables h2c (cleartext HTTP/2 without TLS).

httpconnect://[HOST]:[PORT][?sni=SNI][&certname=CERTNAME]
h2connect://[HOST]:[PORT][?sni=SNI][&certname=CERTNAME][&plain=true]
h3connect://[HOST]:[PORT][?sni=SNI][&certname=CERTNAME]

# Transports

TLS transport (currently streams only, package [golang.getoutline.org/sdk/transport/tls])
Expand Down
144 changes: 144 additions & 0 deletions x/configurl/httpconnect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright 2025 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package configurl

import (
"context"
"fmt"
"net"
"net/url"
"strings"

"golang.getoutline.org/sdk/transport"
"golang.getoutline.org/sdk/transport/tls"
"golang.getoutline.org/sdk/x/httpconnect"
)

// parseConnectOptions parses query parameters from a hierarchical config URL
// (e.g. h2connect://host:port?sni=example.com&plain=true) into TransportOptions.
//
// Supported parameters:
// - sni: TLS server name for SNI.
// - certname: name to validate against the server certificate.
// - plain: if "true", use cleartext (no TLS). Only meaningful for h2connect (h2c).
func parseConnectOptions(configURL url.URL) ([]httpconnect.TransportOption, error) {
values, err := url.ParseQuery(configURL.RawQuery)
if err != nil {
return nil, err
}
var opts []httpconnect.TransportOption
var tlsOpts []tls.ClientOption
for key, vals := range values {
switch strings.ToLower(key) {
case "sni":
if len(vals) != 1 {
return nil, fmt.Errorf("sni option must have one value, found %v", len(vals))
}
tlsOpts = append(tlsOpts, tls.WithSNI(vals[0]))
case "certname":
if len(vals) != 1 {
return nil, fmt.Errorf("certname option must have one value, found %v", len(vals))
}
tlsOpts = append(tlsOpts, tls.WithCertVerifier(&tls.StandardCertVerifier{CertificateName: vals[0]}))
case "plain":
if len(vals) != 1 {
return nil, fmt.Errorf("plain option must have one value, found %v", len(vals))
}
if vals[0] == "true" {
opts = append(opts, httpconnect.WithPlainHTTP())
}
default:
return nil, fmt.Errorf("unsupported option %v", key)
}
}
if len(tlsOpts) > 0 {
opts = append(opts, httpconnect.WithTLSOptions(tlsOpts...))
}
return opts, nil
}

// registerHTTPConnectStreamDialer registers an HTTP CONNECT proxy transport (H1.1, or H2 via ALPN).
//
// Config format: httpconnect://host:port[?sni=SNI][&certname=CERTNAME]
//
// The base dialer (from the previous element in the pipe chain) is used to establish
// the TCP connection to the proxy. TLS is negotiated by the transport itself.
// When H2 is negotiated via ALPN, CONNECT streams are multiplexed over the single TCP connection.
func registerHTTPConnectStreamDialer(r TypeRegistry[transport.StreamDialer], typeID string, newSD BuildFunc[transport.StreamDialer]) {
r.RegisterType(typeID, func(ctx context.Context, config *Config) (transport.StreamDialer, error) {
sd, err := newSD(ctx, config.BaseConfig)
if err != nil {
return nil, err
}
opts, err := parseConnectOptions(config.URL)
if err != nil {
return nil, err
}
tr, err := httpconnect.NewHTTPProxyTransport(sd, config.URL.Host, opts...)
if err != nil {
return nil, err
}
return httpconnect.NewConnectClient(tr)
})
}

// registerH2ConnectStreamDialer registers a pure HTTP/2 CONNECT proxy transport.
//
// Config format: h2connect://host:port[?sni=SNI][&certname=CERTNAME]
//
// Unlike httpconnect, all CONNECT streams are multiplexed over a single TCP connection
// to the proxy. The base dialer is used to establish that connection.
func registerH2ConnectStreamDialer(r TypeRegistry[transport.StreamDialer], typeID string, newSD BuildFunc[transport.StreamDialer]) {
r.RegisterType(typeID, func(ctx context.Context, config *Config) (transport.StreamDialer, error) {
sd, err := newSD(ctx, config.BaseConfig)
if err != nil {
return nil, err
}
opts, err := parseConnectOptions(config.URL)
if err != nil {
return nil, err
}
tr, err := httpconnect.NewH2ProxyTransport(sd, config.URL.Host, opts...)
if err != nil {
return nil, err
}
return httpconnect.NewConnectClient(tr)
})
}

// registerH3ConnectStreamDialer registers an HTTP/3 CONNECT proxy transport over QUIC.
//
// Config format: h3connect://host:port[?sni=SNI][&certname=CERTNAME]
//
// A UDP socket is created internally and shared across all CONNECT streams (QUIC multiplexing).
// The base stream dialer is not used; QUIC always runs over a fresh UDP connection.
func registerH3ConnectStreamDialer(r TypeRegistry[transport.StreamDialer], typeID string) {
r.RegisterType(typeID, func(ctx context.Context, config *Config) (transport.StreamDialer, error) {
opts, err := parseConnectOptions(config.URL)
if err != nil {
return nil, err
}
udpConn, err := net.ListenPacket("udp", ":0")
if err != nil {
return nil, fmt.Errorf("failed to create UDP socket: %w", err)
}
tr, err := httpconnect.NewH3ProxyTransport(udpConn, config.URL.Host, opts...)
if err != nil {
udpConn.Close()
return nil, err
}
return httpconnect.NewConnectClient(tr)
})
}
92 changes: 92 additions & 0 deletions x/configurl/httpconnect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2025 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package configurl_test

import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
"golang.getoutline.org/sdk/transport"
"golang.getoutline.org/sdk/x/configurl"
"golang.getoutline.org/sdk/x/httpproxy"
"golang.org/x/net/http2"
)

// Test_H2Connect_H2C tests the h2connect configurl type using h2c (cleartext HTTP/2).
// It starts a local h2c proxy, builds a stream dialer via "h2connect://host:port?plain=true",
// and verifies that an HTTP request is tunneled through to a target server.
func Test_H2Connect_H2C(t *testing.T) {
t.Parallel()

tcpDialer := &transport.TCPDialer{}

// Start an h2c proxy server (plain HTTP/2 without TLS).
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })

h2srv := &http2.Server{}
handler := httpproxy.NewConnectHandler(tcpDialer)
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go h2srv.ServeConn(conn, &http2.ServeConnOpts{Handler: handler})
}
}()

// Build a dialer using the configurl h2connect type.
providers := configurl.NewDefaultProviders()
dialer, err := providers.NewStreamDialer(context.Background(),
fmt.Sprintf("h2connect://%s?plain=true", ln.Addr().String()),
)
require.NoError(t, err)

// Start a target server that returns a JSON response.
type Response struct {
Message string `json:"message"`
}
want := Response{Message: "hello"}
targetSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(want)
}))
t.Cleanup(targetSrv.Close)

// Make an HTTP request through the tunnel.
hc := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
return dialer.DialStream(ctx, addr)
},
},
}
resp, err := hc.Get(targetSrv.URL)
require.NoError(t, err)
defer resp.Body.Close()

require.Equal(t, http.StatusOK, resp.StatusCode)
var got Response
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.Equal(t, want, got)
}
6 changes: 5 additions & 1 deletion x/configurl/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ func RegisterDefaultProviders(c *ProviderContainer) *ProviderContainer {
registerDO53StreamDialer(&c.StreamDialers, "do53", c.StreamDialers.NewInstance, c.PacketDialers.NewInstance)
registerDOHStreamDialer(&c.StreamDialers, "doh", c.StreamDialers.NewInstance)

registerH2ConnectStreamDialer(&c.StreamDialers, "h2connect", c.StreamDialers.NewInstance)
registerH3ConnectStreamDialer(&c.StreamDialers, "h3connect")
registerHTTPConnectStreamDialer(&c.StreamDialers, "httpconnect", c.StreamDialers.NewInstance)

registerOverrideStreamDialer(&c.StreamDialers, "override", c.StreamDialers.NewInstance)
registerOverridePacketDialer(&c.PacketDialers, "override", c.PacketDialers.NewInstance)

Expand Down Expand Up @@ -128,7 +132,7 @@ func SanitizeConfig(configStr string) (string, error) {
if err != nil {
return "", err
}
case "override", "split", "tls", "tlsfrag":
case "h2connect", "h3connect", "httpconnect", "override", "split", "tls", "tlsfrag":
// No sanitization needed
part = config.URL.String()
default:
Expand Down
9 changes: 8 additions & 1 deletion x/httpconnect/connect_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import (
//
// The package also includes transport builders:
// - NewHTTPProxyTransport
// - NewHTTP3ProxyTransport
// - NewH2ProxyTransport
// - NewH3ProxyTransport
//
// Options:
// - WithHeaders appends the provided headers to every CONNECT request.
Expand All @@ -39,13 +40,18 @@ type ConnectClient struct {

var _ transport.StreamDialer = (*ConnectClient)(nil)

// ProxyRoundTripper is the minimal interface required by ConnectClient to send HTTP CONNECT requests.
// The Scheme method is used to construct the request URL, and the RoundTrip method is used to send the request.
type ProxyRoundTripper interface {
http.RoundTripper
Scheme() string
}

// ClientOption is an option for configuring the ConnectClient.
type ClientOption func(c *clientConfig)

// NewConnectClient creates a new ConnectClient that uses the provided ProxyRoundTripper to send HTTP CONNECT requests.
// The returned client implements the [transport.StreamDialer] interface.
func NewConnectClient(proxyRT ProxyRoundTripper, opts ...ClientOption) (*ConnectClient, error) {
if proxyRT == nil {
return nil, fmt.Errorf("transport must not be nil")
Expand Down Expand Up @@ -75,6 +81,7 @@ type clientConfig struct {
headers http.Header
}

// DialStream implements the [transport.StreamDialer] interface by sending an HTTP CONNECT request to the proxy and returning a connection that tunnels to the target address.
func (cc *ConnectClient) DialStream(ctx context.Context, remoteAddr string) (transport.StreamConn, error) {
raddr, err := transport.MakeNetAddr("tcp", remoteAddr)
if err != nil {
Expand Down
Loading
Loading