From 67773d6bd456ae7451f63b65e236b4dfd6792586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 21 Sep 2026 15:32:41 +0800 Subject: [PATCH] Rewrite HTTP proxy protocol --- common/badhttp/badhttp.go | 36 ++ common/httpclient/http3_transport.go | 35 +- common/sniff/http.go | 4 +- dns/transport/https.go | 4 +- dns/transport/quic/http3.go | 4 +- docs/configuration/inbound/http.md | 33 +- docs/configuration/inbound/http.zh.md | 35 +- docs/configuration/outbound/http.md | 41 +- docs/configuration/outbound/http.zh.md | 39 ++ docs/schema.json | 109 +++++ include/quic.go | 1 + option/http.go | 83 +++- option/simple.go | 88 +++- protocol/http/inbound.go | 99 +++- protocol/http/outbound.go | 115 ++++- protocol/http/quic/inbound_init.go | 111 +++++ protocol/hysteria2/realm.go | 4 +- protocol/mixed/inbound.go | 50 +- protocol/naive/inbound.go | 6 +- service/ssmapi/api.go | 4 +- test/clash_test.go | 7 +- test/go.mod | 6 +- test/go.sum | 8 +- test/http_forward_test.go | 526 +++++++++++++++++++++ test/http_h2_test.go | 630 +++++++++++++++++++++++++ test/http_h3_test.go | 373 +++++++++++++++ test/http_json_test.go | 56 +++ test/http_udp_test.go | 371 +++++++++++++++ test/wrapper_test.go | 4 +- transport/http/capsule.go | 402 ++++++++++++++++ transport/http/client.go | 403 ++++++++++++++++ transport/http/client_h2.go | 127 +++++ transport/http/client_h3.go | 331 +++++++++++++ transport/http/connect_udp.go | 178 +++++++ transport/http/extended_connect.go | 14 + transport/http/forward.go | 250 ++++++++++ transport/http/header.go | 82 ++++ transport/http/reader.go | 65 +++ transport/http/server.go | 124 +++++ transport/http/server_conn.go | 173 +++++++ transport/http/server_h2.go | 277 +++++++++++ transport/http/stream_conn.go | 108 +++++ transport/v2raygrpclite/server.go | 4 +- transport/v2rayhttp/client.go | 4 +- transport/v2rayhttp/server.go | 4 +- transport/v2rayhttpupgrade/client.go | 4 +- transport/v2rayhttpupgrade/server.go | 4 +- transport/v2raywebsocket/client.go | 4 +- transport/v2raywebsocket/server.go | 4 +- 49 files changed, 5306 insertions(+), 138 deletions(-) create mode 100644 common/badhttp/badhttp.go create mode 100644 protocol/http/quic/inbound_init.go create mode 100644 test/http_forward_test.go create mode 100644 test/http_h2_test.go create mode 100644 test/http_h3_test.go create mode 100644 test/http_json_test.go create mode 100644 test/http_udp_test.go create mode 100644 transport/http/capsule.go create mode 100644 transport/http/client.go create mode 100644 transport/http/client_h2.go create mode 100644 transport/http/client_h3.go create mode 100644 transport/http/connect_udp.go create mode 100644 transport/http/extended_connect.go create mode 100644 transport/http/forward.go create mode 100644 transport/http/header.go create mode 100644 transport/http/reader.go create mode 100644 transport/http/server.go create mode 100644 transport/http/server_conn.go create mode 100644 transport/http/server_h2.go create mode 100644 transport/http/stream_conn.go diff --git a/common/badhttp/badhttp.go b/common/badhttp/badhttp.go new file mode 100644 index 000000000..f269d4ba8 --- /dev/null +++ b/common/badhttp/badhttp.go @@ -0,0 +1,36 @@ +package badhttp + +import ( + std_bufio "bufio" + "net/http" + "net/url" + "strings" + _ "unsafe" + + M "github.com/sagernet/sing/common/metadata" +) + +//go:linkname ReadRequest net/http.readRequest +func ReadRequest(b *std_bufio.Reader) (req *http.Request, err error) + +//go:linkname URLSetPath net/url.(*URL).setPath +func URLSetPath(u *url.URL, p string) error + +//go:linkname ParseBasicAuth net/http.parseBasicAuth +func ParseBasicAuth(auth string) (username, password string, valid bool) + +func SourceAddress(request *http.Request) M.Socksaddr { + return ForwardedSource(request, M.ParseSocksaddr(request.RemoteAddr).Unwrap()) +} + +func ForwardedSource(request *http.Request, source M.Socksaddr) M.Socksaddr { + for _, value := range request.Header.Values("X-Forwarded-For") { + for from := range strings.SplitSeq(value, ",") { + address := M.ParseAddr(strings.TrimSpace(from)) + if address.IsValid() { + return M.Socksaddr{Addr: address, Port: source.Port}.Unwrap() + } + } + } + return source +} diff --git a/common/httpclient/http3_transport.go b/common/httpclient/http3_transport.go index e312e5196..25f3a76aa 100644 --- a/common/httpclient/http3_transport.go +++ b/common/httpclient/http3_transport.go @@ -46,21 +46,7 @@ func newHTTP3RoundTripper( if baseTLSConfig != nil { handshakeTimeout = baseTLSConfig.HandshakeTimeout() } - quicConfig := &quic.Config{ - InitialStreamReceiveWindow: options.StreamReceiveWindow.Value(), - MaxStreamReceiveWindow: options.StreamReceiveWindow.Value(), - InitialConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(), - MaxConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(), - KeepAlivePeriod: time.Duration(options.KeepAlivePeriod), - MaxIdleTimeout: time.Duration(options.IdleTimeout), - DisablePathMTUDiscovery: options.DisablePathMTUDiscovery, - } - if options.InitialPacketSize > 0 { - quicConfig.InitialPacketSize = uint16(options.InitialPacketSize) - } - if options.MaxConcurrentStreams > 0 { - quicConfig.MaxIncomingStreams = int64(options.MaxConcurrentStreams) - } + quicConfig := NewQUICConfig(options) if handshakeTimeout > 0 { quicConfig.HandshakeIdleTimeout = handshakeTimeout } @@ -343,3 +329,22 @@ func (t *http3FallbackTransport) markH3Broken(authority string) { entry.until = time.Now().Add(entry.backoff) t.broken[authority] = entry } + +func NewQUICConfig(options option.QUICOptions) *quic.Config { + quicConfig := &quic.Config{ + InitialStreamReceiveWindow: options.StreamReceiveWindow.Value(), + MaxStreamReceiveWindow: options.StreamReceiveWindow.Value(), + InitialConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(), + MaxConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(), + KeepAlivePeriod: time.Duration(options.KeepAlivePeriod), + MaxIdleTimeout: time.Duration(options.IdleTimeout), + DisablePathMTUDiscovery: options.DisablePathMTUDiscovery, + } + if options.InitialPacketSize > 0 { + quicConfig.InitialPacketSize = uint16(options.InitialPacketSize) + } + if options.MaxConcurrentStreams > 0 { + quicConfig.MaxIncomingStreams = int64(options.MaxConcurrentStreams) + } + return quicConfig +} diff --git a/common/sniff/http.go b/common/sniff/http.go index cb69574ed..1bccfa1db 100644 --- a/common/sniff/http.go +++ b/common/sniff/http.go @@ -7,14 +7,14 @@ import ( "io" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" C "github.com/sagernet/sing-box/constant" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" - "github.com/sagernet/sing/protocol/http" ) func HTTPHost(_ context.Context, metadata *adapter.InboundContext, reader io.Reader) error { - request, err := http.ReadRequest(std_bufio.NewReader(reader)) + request, err := badhttp.ReadRequest(std_bufio.NewReader(reader)) if err != nil { if errors.Is(err, io.ErrUnexpectedEOF) { return E.Cause1(ErrNeedMoreData, err) diff --git a/dns/transport/https.go b/dns/transport/https.go index 07ee9d4d6..f40e8d248 100644 --- a/dns/transport/https.go +++ b/dns/transport/https.go @@ -12,6 +12,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" @@ -24,7 +25,6 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - sHTTP "github.com/sagernet/sing/protocol/http" mDNS "github.com/miekg/dns" "golang.org/x/net/http2" @@ -93,7 +93,7 @@ func NewHTTPS(ctx context.Context, logger log.ContextLogger, tag string, options if path == "" { path = "/dns-query" } - err = sHTTP.URLSetPath(&destinationURL, path) + err = badhttp.URLSetPath(&destinationURL, path) if err != nil { return nil, err } diff --git a/dns/transport/quic/http3.go b/dns/transport/quic/http3.go index 52e3258d9..37804086c 100644 --- a/dns/transport/quic/http3.go +++ b/dns/transport/quic/http3.go @@ -11,6 +11,7 @@ import ( "github.com/sagernet/quic-go" "github.com/sagernet/quic-go/http3" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" @@ -24,7 +25,6 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - sHTTP "github.com/sagernet/sing/protocol/http" mDNS "github.com/miekg/dns" ) @@ -87,7 +87,7 @@ func NewHTTP3(ctx context.Context, logger log.ContextLogger, tag string, options if path == "" { path = "/dns-query" } - err = sHTTP.URLSetPath(&destinationURL, path) + err = badhttp.URLSetPath(&destinationURL, path) if err != nil { return nil, err } diff --git a/docs/configuration/inbound/http.md b/docs/configuration/inbound/http.md index 00343e224..78be03c64 100644 --- a/docs/configuration/inbound/http.md +++ b/docs/configuration/inbound/http.md @@ -7,6 +7,7 @@ ... // Listen Fields + "version": [], "users": [ { "username": "admin", @@ -14,7 +15,9 @@ } ], "tls": {}, - "set_system_proxy": false + "set_system_proxy": false, + + ... // HTTP2 Fields / QUIC Fields } ``` @@ -24,6 +27,18 @@ See [Listen Fields](/configuration/shared/listen/) for details. ### Fields +#### version + +!!! question "Since sing-box 1.15.0" + +List of HTTP versions to serve. + +Available values: `1`, `2`, `3`. + +`1` and `2` are used by default. + +TLS is required for `3`. + #### tls TLS configuration, see [TLS](/configuration/shared/tls/#inbound). @@ -45,3 +60,19 @@ No authentication required if empty. To work on Android and Apple platforms without privileges, use tun.platform.http_proxy instead. Automatically set system proxy configuration when start and clean up when stop. + +### HTTP2 Fields + +!!! question "Since sing-box 1.15.0" + +When `version` contains `2`. + +See [HTTP2 Fields](/configuration/shared/http2/) for details. + +### QUIC Fields + +!!! question "Since sing-box 1.15.0" + +When `version` contains `3`, [HTTP2 Fields](#http2-fields) are replaced by QUIC Fields. + +See [QUIC Fields](/configuration/shared/quic/) for details. diff --git a/docs/configuration/inbound/http.zh.md b/docs/configuration/inbound/http.zh.md index e1dd876b7..0160c63e5 100644 --- a/docs/configuration/inbound/http.zh.md +++ b/docs/configuration/inbound/http.zh.md @@ -7,6 +7,7 @@ ... // 监听字段 + "version": [], "users": [ { "username": "admin", @@ -14,7 +15,9 @@ } ], "tls": {}, - "set_system_proxy": false + "set_system_proxy": false, + + ... // HTTP2 字段 / QUIC 字段 } ``` @@ -24,6 +27,18 @@ ### 字段 +#### version + +!!! question "自 sing-box 1.15.0 起" + +提供的 HTTP 版本列表。 + +可用值:`1`、`2`、`3`。 + +默认为 `1` 和 `2`。 + +`3` 需要 TLS。 + #### tls TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 @@ -44,4 +59,20 @@ HTTP 用户 要在无特权的 Android 和 iOS 上工作,请改用 tun.platform.http_proxy。 -启动时自动设置系统代理,停止时自动清理。 \ No newline at end of file +启动时自动设置系统代理,停止时自动清理。 + +### HTTP2 字段 + +!!! question "自 sing-box 1.15.0 起" + +当 `version` 包含 `2` 时。 + +参阅 [HTTP2 字段](/zh/configuration/shared/http2/)。 + +### QUIC 字段 + +!!! question "自 sing-box 1.15.0 起" + +当 `version` 包含 `3` 时,[HTTP2 字段](#http2-字段) 被 QUIC 字段替代。 + +参阅 [QUIC 字段](/zh/configuration/shared/quic/)。 diff --git a/docs/configuration/outbound/http.md b/docs/configuration/outbound/http.md index 0b9dfa236..957b64988 100644 --- a/docs/configuration/outbound/http.md +++ b/docs/configuration/outbound/http.md @@ -13,8 +13,11 @@ "password": "admin", "path": "", "headers": {}, + "version": 0, + "disable_version_fallback": false, "tls": {}, - + + ... // HTTP2 Fields / QUIC Fields ... // Dial Fields } ``` @@ -49,10 +52,46 @@ Path of HTTP request. Extra headers of HTTP request. +#### version + +!!! question "Since sing-box 1.15.0" + +HTTP version. + +Available values: `1`, `2`, `3`. + +`2` is used by default, or `1` if `path` or the `Host` header is set. + +`path` and the `Host` header are only available for `1`. + +When `3`, [HTTP2 Fields](#http2-fields) are replaced by [QUIC Fields](#quic-fields). + +#### disable_version_fallback + +!!! question "Since sing-box 1.15.0" + +Disable automatic fallback to lower HTTP version. + #### tls TLS configuration, see [TLS](/configuration/shared/tls/#outbound). +### HTTP2 Fields + +!!! question "Since sing-box 1.15.0" + +When `version` is `2` (default). + +See [HTTP2 Fields](/configuration/shared/http2/) for details. + +### QUIC Fields + +!!! question "Since sing-box 1.15.0" + +When `version` is `3`. + +See [QUIC Fields](/configuration/shared/quic/) for details. + ### Dial Fields See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/http.zh.md b/docs/configuration/outbound/http.zh.md index 55387a635..170a8a98b 100644 --- a/docs/configuration/outbound/http.zh.md +++ b/docs/configuration/outbound/http.zh.md @@ -13,8 +13,11 @@ "password": "admin", "path": "", "headers": {}, + "version": 0, + "disable_version_fallback": false, "tls": {}, + ... // HTTP2 字段 / QUIC 字段 ... // 拨号字段 } ``` @@ -49,10 +52,46 @@ HTTP 请求路径。 HTTP 请求的额外标头。 +#### version + +!!! question "自 sing-box 1.15.0 起" + +HTTP 版本。 + +可用值:`1`、`2`、`3`。 + +默认使用 `2`;设置了 `path` 或 `Host` 头时默认使用 `1`。 + +`path` 和 `Host` 头仅在 `1` 时可用。 + +当为 `3` 时,[HTTP2 字段](#http2-字段) 替换为 [QUIC 字段](#quic-字段)。 + +#### disable_version_fallback + +!!! question "自 sing-box 1.15.0 起" + +禁用自动回退到更低的 HTTP 版本。 + #### tls TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 +### HTTP2 字段 + +!!! question "自 sing-box 1.15.0 起" + +当 `version` 为 `2`(默认)时。 + +参阅 [HTTP2 字段](/zh/configuration/shared/http2/) 了解详情。 + +### QUIC 字段 + +!!! question "自 sing-box 1.15.0 起" + +当 `version` 为 `3` 时。 + +参阅 [QUIC 字段](/zh/configuration/shared/quic/) 了解详情。 + ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/schema.json b/docs/schema.json index 211379036..e90d9f45f 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -6019,8 +6019,68 @@ "set_system_proxy": { "type": "boolean" }, + "version": { + "anyOf": [ + { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + { + "type": "array", + "items": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + } + } + ] + }, "tls": { "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" } }, "required": [ @@ -11229,6 +11289,55 @@ }, "headers": { "$ref": "#/$defs/HTTPHeader" + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" } }, "required": [ diff --git a/include/quic.go b/include/quic.go index 42182c299..ec4f3a6f5 100644 --- a/include/quic.go +++ b/include/quic.go @@ -8,6 +8,7 @@ import ( "github.com/sagernet/sing-box/adapter/service" "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns/transport/quic" + _ "github.com/sagernet/sing-box/protocol/http/quic" "github.com/sagernet/sing-box/protocol/hysteria" "github.com/sagernet/sing-box/protocol/hysteria2" _ "github.com/sagernet/sing-box/protocol/naive/quic" diff --git a/option/http.go b/option/http.go index c51b26435..f15b7b73b 100644 --- a/option/http.go +++ b/option/http.go @@ -1,7 +1,9 @@ package option import ( + "context" "reflect" + "slices" "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/byteformats" @@ -69,17 +71,17 @@ func (o HTTPClientOptions) MarshalJSON() ([]byte, error) { return badjson.MarshallObjects(_HTTPClientOptions(o), httpClientVariant(_HTTPClientOptions(o))) } -func (o *HTTPClientOptions) UnmarshalJSON(content []byte) error { +func (o *HTTPClientOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { if len(content) > 0 && content[0] == '"' { *o = HTTPClientOptions{} return json.Unmarshal(content, &o.Tag) } var options _HTTPClientOptions - err := json.Unmarshal(content, &options) + err := json.UnmarshalContext(ctx, content, &options) if err != nil { return err } - err = unmarshalHTTPClientVersionOptions(content, &options, &options) + err = unmarshalHTTPVersionOptions(ctx, content, &options, options.Version, &options.HTTP2Options, &options.HTTP3Options) if err != nil { return err } @@ -92,39 +94,74 @@ func (h HTTPClient) MarshalJSON() ([]byte, error) { return badjson.MarshallObjects(_HTTPClientOptions(h), httpClientVariant(_HTTPClientOptions(h))) } -func (h *HTTPClient) UnmarshalJSON(content []byte) error { - err := json.Unmarshal(content, (*_HTTPClientOptions)(h)) +func (h *HTTPClient) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_HTTPClientOptions)(h)) if err != nil { return err } - return unmarshalHTTPClientVersionOptions(content, (*_HTTPClientOptions)(h), (*_HTTPClientOptions)(h)) + return unmarshalHTTPVersionOptions(ctx, content, (*_HTTPClientOptions)(h), h.Version, &h.HTTP2Options, &h.HTTP3Options) } -func unmarshalHTTPClientVersionOptions(content []byte, baseStruct any, options *_HTTPClientOptions) error { - switch options.Version { +func unmarshalHTTPVersionOptions(ctx context.Context, content []byte, baseStruct any, version int, http2Options *HTTP2Options, http3Options *QUICOptions) error { + switch version { case 1: - return json.UnmarshalDisallowUnknownFields(content, baseStruct) + return json.UnmarshalContextDisallowUnknownFields(ctx, content, baseStruct) case 0, 2: - options.Version = 2 - return badjson.UnmarshallExcluded(content, baseStruct, &options.HTTP2Options) + return badjson.UnmarshallExcludedContext(ctx, content, baseStruct, http2Options) case 3: - return badjson.UnmarshallExcluded(content, baseStruct, &options.HTTP3Options) + return badjson.UnmarshallExcludedContext(ctx, content, baseStruct, http3Options) default: - return E.New("unknown HTTP version: ", options.Version) + return E.New("unknown HTTP version: ", version) + } +} + +func unmarshalHTTPVersionsOptions(ctx context.Context, content []byte, baseStruct any, versions []int, http2Options *HTTP2Options, http3Options *QUICOptions) error { + for _, version := range versions { + if version < 1 || version > 3 { + return E.New("unknown HTTP version: ", version) + } + } + switch { + case slices.Contains(versions, 3): + err := badjson.UnmarshallExcludedContext(ctx, content, baseStruct, http3Options) + if err != nil { + return err + } + *http2Options = http3Options.HTTP2Options + return nil + case slices.Contains(versions, 2): + return badjson.UnmarshallExcludedContext(ctx, content, baseStruct, http2Options) + default: + return json.UnmarshalContextDisallowUnknownFields(ctx, content, baseStruct) + } +} + +func httpVersionsVariant(versions []int, http2Options HTTP2Options, http3Options QUICOptions) any { + switch { + case slices.Contains(versions, 3): + return http3Options + case slices.Contains(versions, 2): + return http2Options + default: + return nil + } +} + +func httpVersionVariant(version int, http2Options HTTP2Options, http3Options QUICOptions) any { + switch version { + case 1: + return nil + case 0, 2: + return http2Options + case 3: + return http3Options + default: + return nil } } func httpClientVariant(options _HTTPClientOptions) any { - switch options.Version { - case 1: - return nil - case 0, 2: - return options.HTTP2Options - case 3: - return options.HTTP3Options - default: - return nil - } + return httpVersionVariant(options.Version, options.HTTP2Options, options.HTTP3Options) } func describeHTTPClientObject(builder schema.Builder) (*schema.Node, error) { diff --git a/option/simple.go b/option/simple.go index 43cbb2c6c..b4bba00f2 100644 --- a/option/simple.go +++ b/option/simple.go @@ -1,7 +1,13 @@ package option import ( + "context" + "reflect" + + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" "github.com/sagernet/sing/common/json/badoption" ) @@ -19,6 +25,51 @@ type HTTPMixedInboundOptions struct { InboundTLSOptionsContainer } +type _HTTPInboundOptions struct { + ListenOptions + Users []auth.User `json:"users,omitempty"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` + SetSystemProxy bool `json:"set_system_proxy,omitempty"` + Version badoption.Listable[int] `json:"version,omitempty" enum:"1,2,3"` + InboundTLSOptionsContainer + HTTP2Options HTTP2Options `json:"-"` + HTTP3Options QUICOptions `json:"-"` +} + +type HTTPInboundOptions _HTTPInboundOptions + +func (o HTTPInboundOptions) Versions() []int { + if len(o.Version) > 0 { + return o.Version + } + return []int{1, 2} +} + +func (o HTTPInboundOptions) MarshalJSON() ([]byte, error) { + return badjson.MarshallObjects(_HTTPInboundOptions(o), httpVersionsVariant(o.Versions(), o.HTTP2Options, o.HTTP3Options)) +} + +func (o *HTTPInboundOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_HTTPInboundOptions)(o)) + if err != nil { + return err + } + return unmarshalHTTPVersionsOptions(ctx, content, (*_HTTPInboundOptions)(o), o.Versions(), &o.HTTP2Options, &o.HTTP3Options) +} + +func (o HTTPInboundOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[HTTPInboundOptions]()) + if err != nil { + return nil, err + } + err = builder.FlattenStruct(node, reflect.TypeFor[QUICOptions]()) + if err != nil { + return nil, err + } + return node, nil +} + type SOCKSOutboundOptions struct { DialerOptions ServerOptions @@ -29,12 +80,43 @@ type SOCKSOutboundOptions struct { UDPOverTCP *UDPOverTCPOptions `json:"udp_over_tcp,omitempty"` } -type HTTPOutboundOptions struct { +type _HTTPOutboundOptions struct { DialerOptions ServerOptions Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` OutboundTLSOptionsContainer - Path string `json:"path,omitempty"` - Headers badoption.HTTPHeader `json:"headers,omitempty"` + Path string `json:"path,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` + Version int `json:"version,omitempty" enum:"0,1,2,3"` + DisableVersionFallback bool `json:"disable_version_fallback,omitempty"` + HTTP2Options HTTP2Options `json:"-"` + HTTP3Options QUICOptions `json:"-"` +} + +type HTTPOutboundOptions _HTTPOutboundOptions + +func (o HTTPOutboundOptions) MarshalJSON() ([]byte, error) { + return badjson.MarshallObjects(_HTTPOutboundOptions(o), httpVersionVariant(o.Version, o.HTTP2Options, o.HTTP3Options)) +} + +func (o *HTTPOutboundOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_HTTPOutboundOptions)(o)) + if err != nil { + return err + } + return unmarshalHTTPVersionOptions(ctx, content, (*_HTTPOutboundOptions)(o), o.Version, &o.HTTP2Options, &o.HTTP3Options) +} + +func (o HTTPOutboundOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[HTTPOutboundOptions]()) + if err != nil { + return nil, err + } + err = builder.FlattenStruct(node, reflect.TypeFor[QUICOptions]()) + if err != nil { + return nil, err + } + return node, nil } diff --git a/protocol/http/inbound.go b/protocol/http/inbound.go index 6e0c3ebd0..f5a0edd3b 100644 --- a/protocol/http/inbound.go +++ b/protocol/http/inbound.go @@ -1,9 +1,11 @@ package http import ( - std_bufio "bufio" "context" + "io" "net" + std_http "net/http" + "slices" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -13,34 +15,62 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/http" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/auth" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/protocol/http" + + "golang.org/x/net/http2" ) +var ConfigureHTTP3ListenerFunc func(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler std_http.Handler, tlsConfig tls.ServerConfig, options option.QUICOptions) (io.Closer, error) + func RegisterInbound(registry *inbound.Registry) { - inbound.Register[option.HTTPMixedInboundOptions](registry, C.TypeHTTP, NewInbound) + inbound.Register[option.HTTPInboundOptions](registry, C.TypeHTTP, NewInbound) } var _ adapter.TCPInjectableInbound = (*Inbound)(nil) type Inbound struct { inbound.Adapter - router adapter.ConnectionRouterEx - logger log.ContextLogger - listener *listener.Listener - authenticator *auth.Authenticator - tlsConfig tls.ServerConfig + ctx context.Context + router adapter.ConnectionRouterEx + logger log.ContextLogger + listener *listener.Listener + server *http.Server + tlsConfig tls.ServerConfig + http3 bool + quicOptions option.QUICOptions } -func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPMixedInboundOptions) (adapter.Inbound, error) { +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPInboundOptions) (adapter.Inbound, error) { + versions := options.Versions() + serveHTTP1 := slices.Contains(versions, 1) + serveHTTP2 := slices.Contains(versions, 2) + serveHTTP3 := slices.Contains(versions, 3) + if serveHTTP3 && (options.TLS == nil || !options.TLS.Enabled) { + return nil, E.New("TLS is required for HTTP/3") + } + if !serveHTTP1 && !serveHTTP2 && options.SetSystemProxy { + return nil, E.New("set_system_proxy requires HTTP/1 or HTTP/2") + } inbound := &Inbound{ - Adapter: inbound.NewAdapter(C.TypeHTTP, tag), - router: uot.NewRouter(router, logger), - logger: logger, - authenticator: auth.NewAuthenticator(options.Users), + Adapter: inbound.NewAdapter(C.TypeHTTP, tag), + ctx: ctx, + router: uot.NewRouter(router, logger), + logger: logger, + server: http.NewServer(http.ServerOptions{ + Authenticator: auth.NewAuthenticator(options.Users), + Logger: logger, + HTTP1: serveHTTP1, + HTTP2: serveHTTP2, + HTTP2Options: options.HTTP2Options, + UDP: true, + }), + http3: serveHTTP3, + quicOptions: options.HTTP3Options, } if options.TLS != nil { tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{ @@ -52,12 +82,26 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if err != nil { return nil, err } + if tlsConfig != nil && len(tlsConfig.NextProtos()) == 0 { + var nextProtos []string + if serveHTTP2 { + nextProtos = append(nextProtos, http2.NextProtoTLS) + } + if serveHTTP1 { + nextProtos = append(nextProtos, "http/1.1") + } + tlsConfig.SetNextProtos(nextProtos) + } inbound.tlsConfig = tlsConfig } + var network []string + if serveHTTP1 || serveHTTP2 { + network = []string{N.NetworkTCP} + } inbound.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, - Network: []string{N.NetworkTCP}, + Network: network, Listen: options.ListenOptions, ConnectionHandler: inbound, SetSystemProxy: options.SetSystemProxy, @@ -82,6 +126,27 @@ func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { return err } scope.Add(h.listener.Close) + if h.http3 { + return h.startHTTP3(scope) + } + return nil +} + +func (h *Inbound) startHTTP3(scope *adapter.Scope) error { + if ConfigureHTTP3ListenerFunc == nil { + return C.ErrQUICNotIncluded + } + if !slices.Contains(h.tlsConfig.NextProtos(), "h3") { + h.tlsConfig.SetNextProtos(append([]string{"h3"}, h.tlsConfig.NextProtos()...)) + } + var metadata adapter.InboundContext + //nolint:staticcheck + metadata.InboundDetour = h.listener.ListenOptions().Detour + http3Server, err := ConfigureHTTP3ListenerFunc(h.ctx, h.logger, h.listener, h.server.HTTP3Handler(adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection)), h.tlsConfig, h.quicOptions) + if err != nil { + return err + } + scope.Add(http3Server.Close) return nil } @@ -95,11 +160,7 @@ func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata ada } conn = tlsConn } - err := http.HandleConnectionEx(ctx, conn, std_bufio.NewReader(conn), h.authenticator, adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose) - if err != nil { - N.CloseOnHandshakeFailure(conn, onClose, err) - h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) - } + h.server.ServeConnection(ctx, conn, http.NewReader(conn), adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose) } func (h *Inbound) newUserConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { diff --git a/protocol/http/outbound.go b/protocol/http/outbound.go index 48c4be6b6..ef65b2e71 100644 --- a/protocol/http/outbound.go +++ b/protocol/http/outbound.go @@ -3,7 +3,7 @@ package http import ( "context" "net" - "os" + "slices" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" @@ -12,21 +12,27 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/http" "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - sHTTP "github.com/sagernet/sing/protocol/http" + + "golang.org/x/net/http2" ) func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.HTTPOutboundOptions](registry, C.TypeHTTP, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger - client *sHTTP.Client + client *http.Client } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPOutboundOptions) (adapter.Outbound, error) { @@ -34,32 +40,109 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if err != nil { return nil, err } - detour, err := tls.NewDialerFromOptions(ctx, logger, outboundDialer, options.Server, common.PtrValueOrDefault(options.TLS)) + headers := options.Headers.Build() + version := http.ResolveVersion(options.Version, options.Path, headers.Get("Host")) + tlsOptions := common.PtrValueOrDefault(options.TLS) + if version == 3 && !tlsOptions.Enabled { + return nil, C.ErrTLSRequired + } + alpnIsDefault := tlsOptions.Enabled && len(tlsOptions.ALPN) == 0 + if alpnIsDefault { + switch version { + case 1: + tlsOptions.ALPN = []string{"http/1.1"} + default: + tlsOptions.ALPN = []string{http2.NextProtoTLS, "http/1.1"} + } + } + detour, err := tls.NewDialerFromOptions(ctx, logger, outboundDialer, options.Server, tlsOptions) + if err != nil { + return nil, err + } + var http1Detour N.Dialer + if version >= 2 && (alpnIsDefault || slices.Contains(tlsOptions.ALPN, "http/1.1")) { + http1TLSOptions := tlsOptions + http1TLSOptions.ALPN = []string{"http/1.1"} + http1Detour, err = tls.NewDialerFromOptions(ctx, logger, outboundDialer, options.Server, http1TLSOptions) + if err != nil { + return nil, err + } + } + var quicTLSConfig tls.Config + if version == 3 { + if alpnIsDefault { + tlsOptions.ALPN = []string{"h3"} + } + quicTLSConfig, err = tls.NewClient(ctx, logger, options.Server, tlsOptions) + if err != nil { + return nil, err + } + } + client, err := http.NewClient(http.ClientOptions{ + Dialer: detour, + HTTP1Dialer: http1Detour, + RawDialer: outboundDialer, + TLSConfig: quicTLSConfig, + Server: options.ServerOptions.Build(), + Username: options.Username, + Password: options.Password, + Path: options.Path, + Headers: headers, + Version: version, + DisableVersionFallback: options.DisableVersionFallback, + HTTP2Options: options.HTTP2Options, + HTTP3Options: options.HTTP3Options, + }) if err != nil { return nil, err } return &Outbound{ - Adapter: outbound.NewAdapterWithDialerOptions(C.TypeHTTP, tag, []string{N.NetworkTCP}, options.DialerOptions), + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeHTTP, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), logger: logger, - client: sHTTP.NewClient(sHTTP.Options{ - Dialer: detour, - Server: options.ServerOptions.Build(), - Username: options.Username, - Password: options.Password, - Path: options.Path, - Headers: options.Headers.Build(), - }), + client: client, }, nil } +func (h *Outbound) MultiplexEnabled() bool { + return h.client.MultiplexEnabled() +} + +func (h *Outbound) InterfaceUpdated(ctx context.Context) { + h.client.ResetConnections() +} + +func (h *Outbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { + if stage != adapter.StartStateInitialize { + return nil + } + scope.Add(h.client.Close) + return nil +} + func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { ctx, metadata := adapter.ExtendContext(ctx) metadata.Outbound = h.Tag() metadata.Destination = destination - h.logger.InfoContext(ctx, "outbound connection to ", destination) - return h.client.DialContext(ctx, network, destination) + switch N.NetworkName(network) { + case N.NetworkTCP: + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.DialContext(ctx, network, destination) + case N.NetworkUDP: + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + packetConn, err := h.client.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(packetConn, destination), nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } } func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return nil, os.ErrInvalid + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return h.client.ListenPacket(ctx, destination) } diff --git a/protocol/http/quic/inbound_init.go b/protocol/http/quic/inbound_init.go new file mode 100644 index 000000000..fe49cc68e --- /dev/null +++ b/protocol/http/quic/inbound_init.go @@ -0,0 +1,111 @@ +//go:build with_quic + +package quic + +import ( + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + "github.com/sagernet/sing-box/common/httpclient" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + boxHTTP "github.com/sagernet/sing-box/protocol/http" + transportHTTP "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing-quic" + congestion_meta2 "github.com/sagernet/sing-quic/congestion_meta2" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +func init() { + boxHTTP.ConfigureHTTP3ListenerFunc = func(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler http.Handler, tlsConfig tls.ServerConfig, options option.QUICOptions) (io.Closer, error) { + err := qtls.ConfigureHTTP3(tlsConfig) + if err != nil { + return nil, err + } + udpConn, err := listener.ListenUDP() + if err != nil { + return nil, err + } + quicConfig := httpclient.NewQUICConfig(options) + if quicConfig.MaxIncomingStreams == 0 { + quicConfig.MaxIncomingStreams = 1 << 60 + } + quicConfig.Allow0RTT = true + quicConfig.DisablePathManager = true + quicConfig.EnableDatagrams = true + quicListener, err := qtls.ListenEarly(udpConn, tlsConfig, quicConfig) + if err != nil { + udpConn.Close() + return nil, err + } + http3Server := &http3.Server{ + Handler: handler, + EnableDatagrams: true, + ConnContext: func(ctx context.Context, conn *quic.Conn) context.Context { + conn.SetCongestionControl(congestion_meta2.NewBbrSenderWithProfile(conn.InitialPacketSize(), congestion_meta2.ProfileStandard)) + return log.ContextWithNewID(ctx) + }, + } + go func() { + serveErr := http3Server.ServeListener(quicListener) + udpConn.Close() + if serveErr != nil && !E.IsClosedOrCanceled(serveErr) { + logger.Error("http3 server closed: ", serveErr) + } + }() + return quicListener, nil + } + transportHTTP.HTTP3StreamFunc = func(ctx context.Context, writer http.ResponseWriter) (transportHTTP.DatagramStream, bool) { + streamer, isStreamer := writer.(http3.HTTPStreamer) + if !isStreamer { + return nil, false + } + settingser, isSettingser := writer.(http3.Settingser) + if !isSettingser { + return nil, false + } + select { + case <-settingser.ReceivedSettings(): + case <-ctx.Done(): + return nil, false + } + return &datagramStream{ + Stream: streamer.HTTPStream(), + datagramsEnabled: settingser.Settings().EnableDatagrams, + }, true + } +} + +type datagramStream struct { + *http3.Stream + datagramsEnabled bool +} + +func (s *datagramStream) SendDatagram(payload []byte) error { + if !s.datagramsEnabled { + return transportHTTP.ErrDatagramUnsupported + } + err := s.Stream.SendDatagram(payload) + if err == nil { + return nil + } + var tooLarge *quic.DatagramTooLargeError + if errors.As(err, &tooLarge) { + return transportHTTP.ErrDatagramUnsupported + } + return err +} + +func (s *datagramStream) Close() error { + s.Stream.SetWriteDeadline(time.Now()) + s.Stream.CancelRead(0) + return s.Stream.Close() +} diff --git a/protocol/hysteria2/realm.go b/protocol/hysteria2/realm.go index a5b703300..338e3ebf7 100644 --- a/protocol/hysteria2/realm.go +++ b/protocol/hysteria2/realm.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-box/adapter" boxService "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/listener" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" @@ -18,7 +19,6 @@ import ( E "github.com/sagernet/sing/common/exceptions" N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" - sHTTP "github.com/sagernet/sing/protocol/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -65,7 +65,7 @@ func NewRealmService(ctx context.Context, logger log.ContextLogger, tag string, chiRouter.Use(middleware.RequestSize(maxRequestBodyBytes)) chiRouter.Use(func(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - logger.DebugContext(r.Context(), r.Method, " ", r.RequestURI, " ", sHTTP.SourceAddress(r)) + logger.DebugContext(r.Context(), r.Method, " ", r.RequestURI, " ", badhttp.SourceAddress(r)) handler.ServeHTTP(w, r) }) }) diff --git a/protocol/mixed/inbound.go b/protocol/mixed/inbound.go index 22c25f30f..8bae23ee4 100644 --- a/protocol/mixed/inbound.go +++ b/protocol/mixed/inbound.go @@ -1,7 +1,6 @@ package mixed import ( - std_bufio "bufio" "context" "net" "time" @@ -14,12 +13,12 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/transport/http" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/auth" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/protocol/http" "github.com/sagernet/sing/protocol/socks" "github.com/sagernet/sing/protocol/socks/socks4" "github.com/sagernet/sing/protocol/socks/socks5" @@ -37,6 +36,7 @@ type Inbound struct { logger log.ContextLogger listener *listener.Listener authenticator *auth.Authenticator + server *http.Server tlsConfig tls.ServerConfig udpTimeout time.Duration } @@ -55,6 +55,13 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo authenticator: auth.NewAuthenticator(options.Users), udpTimeout: udpTimeout, } + inbound.server = http.NewServer(http.ServerOptions{ + Authenticator: inbound.authenticator, + Logger: logger, + HTTP1: true, + HTTP2: true, + UDP: true, + }) if options.TLS != nil { tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{ Context: ctx, @@ -99,35 +106,40 @@ func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { } func (h *Inbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { - err := h.newConnection(ctx, conn, metadata, onClose) - N.CloseOnHandshakeFailure(conn, onClose, err) - if err != nil { - if E.IsClosedOrCanceled(err) { - h.logger.DebugContext(ctx, "connection closed: ", err) - } else { - h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) - } - } -} - -func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { if h.tlsConfig != nil { tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) if err != nil { - return E.Cause(err, "TLS handshake") + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": TLS handshake")) + return } conn = tlsConn } - reader := std_bufio.NewReader(conn) + reader := http.NewReader(conn) headerBytes, err := reader.Peek(1) if err != nil { - return E.Cause(err, "peek first byte") + N.CloseOnHandshakeFailure(conn, onClose, err) + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": peek first byte")) + } + return } + handler := adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection) switch headerBytes[0] { case socks4.Version, socks5.Version: - return socks.HandleConnectionEx(ctx, conn, reader, h.authenticator, adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), h.listener, h.udpTimeout, metadata.Source, onClose) + err = socks.HandleConnectionEx(ctx, conn, reader.Reader, h.authenticator, handler, h.listener, h.udpTimeout, metadata.Source, onClose) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + if E.IsClosedOrCanceled(err) { + h.logger.DebugContext(ctx, "connection closed: ", err) + } else { + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) + } + } default: - return http.HandleConnectionEx(ctx, conn, reader, h.authenticator, adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), metadata.Source, onClose) + h.server.ServeConnection(ctx, conn, reader, handler, metadata.Source, onClose) } } diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go index d19cc642d..d93ddb701 100644 --- a/protocol/naive/inbound.go +++ b/protocol/naive/inbound.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/listener" "github.com/sagernet/sing-box/common/tls" "github.com/sagernet/sing-box/common/uot" @@ -25,7 +26,6 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" - sHttp "github.com/sagernet/sing/protocol/http" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" //nolint:staticcheck @@ -155,7 +155,7 @@ func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) { n.badRequest(ctx, request, E.New("missing naive padding")) return } - userName, password, authOk := sHttp.ParseBasicAuth(request.Header.Get("Proxy-Authorization")) + userName, password, authOk := badhttp.ParseBasicAuth(request.Header.Get("Proxy-Authorization")) if authOk { authOk = n.authenticator.Verify(userName, password) } @@ -175,7 +175,7 @@ func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) { hostPort = request.Host } } - source := sHttp.SourceAddress(request) + source := badhttp.SourceAddress(request) destination := M.ParseSocksaddr(hostPort).Unwrap() if hijacker, isHijacker := writer.(http.Hijacker); isHijacker { diff --git a/service/ssmapi/api.go b/service/ssmapi/api.go index 6a067c50c..9a75a5e48 100644 --- a/service/ssmapi/api.go +++ b/service/ssmapi/api.go @@ -3,9 +3,9 @@ package ssmapi import ( "net/http" + "github.com/sagernet/sing-box/common/badhttp" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common/logger" - sHTTP "github.com/sagernet/sing/protocol/http" "github.com/go-chi/chi/v5" "github.com/go-chi/render" @@ -29,7 +29,7 @@ func (s *APIServer) Route(r chi.Router) { r.Route("/server/v1", func(r chi.Router) { r.Use(func(handler http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - s.logger.Debug(request.Method, " ", request.RequestURI, " ", sHTTP.SourceAddress(request)) + s.logger.Debug(request.Method, " ", request.RequestURI, " ", badhttp.SourceAddress(request)) handler.ServeHTTP(writer, request) }) }) diff --git a/test/clash_test.go b/test/clash_test.go index bba7f3beb..44bdec074 100644 --- a/test/clash_test.go +++ b/test/clash_test.go @@ -411,9 +411,10 @@ func testLargeDataWithPacketConnSize(t *testing.T, port uint16, chunkSize int, p buf := make([]byte, 64*1024) for i := 0; i < times; i++ { - _, rAddr, err = l.ReadFrom(buf) - if err != nil { - t.Log(err.Error()) + var readErr error + _, rAddr, readErr = l.ReadFrom(buf) + if readErr != nil { + t.Log(readErr.Error()) return } hash := md5.Sum(buf[:chunkSize]) diff --git a/test/go.mod b/test/go.mod index d4b91ffc7..77c2046cc 100644 --- a/test/go.mod +++ b/test/go.mod @@ -12,12 +12,12 @@ require ( github.com/gofrs/uuid/v5 v5.5.1 github.com/opencontainers/image-spec v1.1.0 github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 - github.com/sagernet/sing v0.9.5-0.20260914135810-80bfa92a0607 + github.com/sagernet/sing v0.9.6-0.20260920093640-814d533a21ef github.com/sagernet/sing-openvpn v0.0.0-20260903200517-e060dda5b1f1 github.com/sagernet/sing-quic v0.7.1-0.20260904135313-497364e8ee3e github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 - github.com/sagernet/sing-tun v0.9.4-0.20260917030658-12dce01d6d8d + github.com/sagernet/sing-tun v0.9.6-0.20260919141142-a39eab51450b github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.5.0.20260916060041-a8fbeb4b0838 github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.12.0 @@ -157,7 +157,7 @@ require ( github.com/sagernet/nftables v0.3.0-mod.4 // indirect github.com/sagernet/sing-anytls v0.0.0-20260904135308-cec2d74334be // indirect github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect - github.com/sagernet/sing-mux v0.3.7-0.20260905054442-91d1502591ce // indirect + github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a // indirect github.com/sagernet/sing-openconnect v0.0.0-20260903200519-8b89c968949d // indirect github.com/sagernet/sing-shadowtls v0.2.1 // indirect github.com/sagernet/sing-snell v0.0.0-20260904135315-bc5a12ac736f // indirect diff --git a/test/go.sum b/test/go.sum index 19b58acbd..61aa45014 100644 --- a/test/go.sum +++ b/test/go.sum @@ -331,14 +331,14 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE= github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.5-0.20260914135810-80bfa92a0607 h1:YzA14QltKgRqFKEZYRP1JBL2meuf3Mx3DiDK/U5+/PA= -github.com/sagernet/sing v0.9.5-0.20260914135810-80bfa92a0607/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/sing v0.9.6-0.20260920093640-814d533a21ef h1:xutL8qCHi73T2KBXc3AAF97qUFZJRAJAe43yCkqenR0= +github.com/sagernet/sing v0.9.6-0.20260920093640-814d533a21ef/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-anytls v0.0.0-20260904135308-cec2d74334be h1:0uMXu8kcJU0XLc2vfjGH6q6khmECCc5WYpD9dqlu1Xg= github.com/sagernet/sing-anytls v0.0.0-20260904135308-cec2d74334be/go.mod h1:VRKLnj0ObEsN228h+zuzf91p+6sjtYE8SBaLPVsBy5o= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= -github.com/sagernet/sing-mux v0.3.7-0.20260905054442-91d1502591ce h1:ay1pGbzMF4e7iggj+uESNhwMNPbZZajDghcdC4P92Dc= -github.com/sagernet/sing-mux v0.3.7-0.20260905054442-91d1502591ce/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a h1:VrlU4QE8Bh4kEeLgvvWLBtyTiuXqc6/5vf3B+xQCTRE= +github.com/sagernet/sing-mux v0.3.9-0.20260927144857-0257b84c582a/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= github.com/sagernet/sing-openconnect v0.0.0-20260903200519-8b89c968949d h1:Jt/Tb0boIr/xRr4sWr2vC/rrVlYNoumZITLEQpqZ8UE= github.com/sagernet/sing-openconnect v0.0.0-20260903200519-8b89c968949d/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= github.com/sagernet/sing-openvpn v0.0.0-20260903200517-e060dda5b1f1 h1:Zn7+pNY5mX1e4DO8oKhTHz5sxqUKNdCWKbfZUtM26Qg= diff --git a/test/http_forward_test.go b/test/http_forward_test.go new file mode 100644 index 000000000..17ca494bf --- /dev/null +++ b/test/http_forward_test.go @@ -0,0 +1,526 @@ +package main + +import ( + std_bufio "bufio" + "bytes" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "net/http/httptest" + "net/http/httptrace" + "net/netip" + "net/textproto" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" + + "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +type forwardOrigin struct { + *httptest.Server + connections atomic.Int32 +} + +func newForwardOrigin(t *testing.T) *forwardOrigin { + origin := &forwardOrigin{} + mux := http.NewServeMux() + mux.HandleFunc("/hello", func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Proxy-Authorization") != "" || request.Header.Get("Proxy-Connection") != "" { + writer.WriteHeader(http.StatusBadRequest) + return + } + if _, loaded := request.Header["User-Agent"]; loaded { + writer.WriteHeader(http.StatusBadRequest) + return + } + writer.Header().Set("X-Host", request.Host) + writer.Write([]byte("hello")) + }) + mux.HandleFunc("/echo", func(writer http.ResponseWriter, request *http.Request) { + body, err := io.ReadAll(request.Body) + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + writer.Write(body) + }) + mux.HandleFunc("/chunked", func(writer http.ResponseWriter, request *http.Request) { + flusher := writer.(http.Flusher) + for i := 0; i < 3; i++ { + writer.Write([]byte("chunk")) + flusher.Flush() + } + }) + mux.HandleFunc("/trailer", func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Trailer", "X-Checksum") + writer.Write([]byte("hello")) + writer.Header().Set("X-Checksum", "5d41402a") + }) + mux.HandleFunc("/stream", func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + time.Sleep(2 * time.Second) + writer.Write([]byte("late")) + }) + mux.HandleFunc("/hints", func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Link", "; rel=preload") + writer.WriteHeader(http.StatusEarlyHints) + writer.Write([]byte("body")) + }) + mux.HandleFunc("/ws", func(writer http.ResponseWriter, request *http.Request) { + conn, err := websocket.Accept(writer, request, nil) + if err != nil { + return + } + defer conn.CloseNow() + messageType, message, err := conn.Read(request.Context()) + if err != nil { + return + } + conn.Write(request.Context(), messageType, message) + conn.Close(websocket.StatusNormalClosure, "") + }) + origin.Server = httptest.NewUnstartedServer(mux) + origin.Config.ConnState = func(conn net.Conn, state http.ConnState) { + if state == http.StateNew { + origin.connections.Add(1) + } + } + origin.Start() + t.Cleanup(origin.Close) + return origin +} + +func (o *forwardOrigin) url(path string) string { + return o.URL + path +} + +func (o *forwardOrigin) host() string { + return strings.TrimPrefix(o.URL, "http://") +} + +func startForwardProxy(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeHTTP, + Options: &option.HTTPInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{{Username: "sekai", Password: "password"}}, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + }) +} + +func proxyClient(t *testing.T, port uint16) *http.Client { + proxyURL, err := url.Parse("http://127.0.0.1:" + strconv.Itoa(int(port))) + require.NoError(t, err) + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + Timeout: 10 * time.Second, + } + t.Cleanup(client.CloseIdleConnections) + return client +} + +func TestHTTPForwardKeepAlive(t *testing.T) { + startForwardProxy(t) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + for i := 0; i < 3; i++ { + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + response, err := client.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, "hello", string(body)) + require.Equal(t, origin.host(), response.Header.Get("X-Host")) + } + response, err := client.Post(origin.url("/echo"), "application/octet-stream", strings.NewReader(strings.Repeat("payload", 1024))) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, strings.Repeat("payload", 1024), string(body)) + request, err := http.NewRequest(http.MethodHead, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + response, err = client.Do(request) + require.NoError(t, err) + response.Body.Close() + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, int64(5), response.ContentLength) + require.Equal(t, int32(1), origin.connections.Load()) +} + +func TestHTTPForwardChunked(t *testing.T) { + startForwardProxy(t) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + response, err := client.Get(origin.url("/chunked")) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "chunkchunkchunk", string(body)) + require.Equal(t, []string{"chunked"}, response.TransferEncoding) + response, err = client.Get(origin.url("/hello")) + require.NoError(t, err) + response.Body.Close() + require.Equal(t, int32(1), origin.connections.Load()) +} + +func TestHTTPForwardEarlyHints(t *testing.T) { + startForwardProxy(t) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + var hints atomic.Int32 + trace := &httptrace.ClientTrace{ + Got1xxResponse: func(code int, header textproto.MIMEHeader) error { + if code == http.StatusEarlyHints && header.Get("Link") != "" { + hints.Add(1) + } + return nil + }, + } + request, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, origin.url("/hints"), nil) + require.NoError(t, err) + response, err := client.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "body", string(body)) + require.Equal(t, int32(1), hints.Load()) + + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(serverPort))) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("GET " + origin.url("/hints") + " HTTP/1.1\r\nHost: " + origin.host() + "\r\nProxy-Authorization: " + proxyAuthorization + "\r\n\r\n")) + require.NoError(t, err) + reader := std_bufio.NewReader(conn) + interim, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + require.Equal(t, http.StatusEarlyHints, interim.StatusCode) + require.Empty(t, interim.Header.Values("Content-Length")) + require.Empty(t, interim.Header.Values("Transfer-Encoding")) + final, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + body, err = io.ReadAll(final.Body) + require.NoError(t, err) + require.Equal(t, "body", string(body)) +} + +func TestHTTPForwardWebSocket(t *testing.T) { + startForwardProxy(t) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, "ws://"+origin.host()+"/ws", &websocket.DialOptions{HTTPClient: client}) + require.NoError(t, err) + defer conn.CloseNow() + err = conn.Write(ctx, websocket.MessageText, []byte("ping")) + require.NoError(t, err) + messageType, message, err := conn.Read(ctx) + require.NoError(t, err) + require.Equal(t, websocket.MessageText, messageType) + require.Equal(t, "ping", string(message)) +} + +func TestHTTPForwardConnect(t *testing.T) { + startForwardProxy(t) + origin := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Write([]byte("secure")) + })) + defer origin.Close() + client := proxyClient(t, clientPort) + response, err := client.Get(origin.URL) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "secure", string(body)) +} + +func TestHTTPForwardLegacyClient(t *testing.T) { + startForwardProxy(t) + origin := newForwardOrigin(t) + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(clientPort))) + require.NoError(t, err) + defer conn.Close() + reader := std_bufio.NewReader(conn) + for i := 0; i < 2; i++ { + _, err = conn.Write([]byte("GET " + origin.url("/hello") + " HTTP/1.0\r\nHost: " + origin.host() + "\r\nUser-Agent: \r\nProxy-Connection: keep-alive\r\n\r\n")) + require.NoError(t, err) + response, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, int64(5), response.ContentLength) + require.Equal(t, "keep-alive", response.Header.Get("Connection")) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + } + _, err = conn.Write([]byte("GET " + origin.url("/chunked") + " HTTP/1.0\r\nHost: " + origin.host() + "\r\nProxy-Connection: keep-alive\r\n\r\n")) + require.NoError(t, err) + response, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Empty(t, response.TransferEncoding) + require.Equal(t, int64(-1), response.ContentLength) + require.True(t, response.Close) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, "chunkchunkchunk", string(body)) + _, err = reader.ReadByte() + require.ErrorIs(t, err, io.EOF) + require.Equal(t, int32(1), origin.connections.Load()) +} + +func TestHTTPForwardAuthRetry(t *testing.T) { + startForwardProxy(t) + origin := newForwardOrigin(t) + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(serverPort))) + require.NoError(t, err) + defer conn.Close() + reader := std_bufio.NewReader(conn) + _, err = conn.Write([]byte("GET " + origin.url("/hello") + " HTTP/1.1\r\nHost: " + origin.host() + "\r\nUser-Agent: \r\n\r\n")) + require.NoError(t, err) + response, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + require.Equal(t, http.StatusProxyAuthRequired, response.StatusCode) + require.Contains(t, response.Header.Get("Proxy-Authenticate"), "Basic") + require.False(t, response.Close) + _, err = io.ReadAll(response.Body) + require.NoError(t, err) + response.Body.Close() + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + request.Header.Set("Proxy-Authorization", "Basic c2VrYWk6cGFzc3dvcmQ=") + err = request.WriteProxy(conn) + require.NoError(t, err) + response, err = http.ReadResponse(reader, request) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + _, err = conn.Write([]byte("CONNECT " + origin.host() + " HTTP/1.1\r\nHost: " + origin.host() + "\r\nProxy-Authorization: Basic c2VrYWk6cGFzc3dvcmQ=\r\n\r\n")) + require.NoError(t, err) + response, err = http.ReadResponse(reader, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + request, err = http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + err = request.Write(conn) + require.NoError(t, err) + response, err = http.ReadResponse(reader, request) + require.NoError(t, err) + body, err = io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) +} + +func TestHTTPForwardCloseOnError(t *testing.T) { + startForwardProxy(t) + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(serverPort))) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("GET http://127.0.0.1:1/ HTTP/1.1\r\nHost: 127.0.0.1:1\r\nConnection: close\r\nProxy-Authorization: " + proxyAuthorization + "\r\n\r\n")) + require.NoError(t, err) + reader := std_bufio.NewReader(conn) + response, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + require.Equal(t, http.StatusBadGateway, response.StatusCode) + require.True(t, response.Close) + _, err = io.ReadAll(response.Body) + require.NoError(t, err) + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, err = reader.ReadByte() + require.ErrorIs(t, err, io.EOF) +} + +func TestHTTPForwardUserIsolation(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + {Username: "sekai", Password: "password"}, + {Username: "blocked", Password: "password"}, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + {Type: C.TypeDirect}, + {Type: C.TypeBlock, Tag: "block"}, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + AuthUser: []string{"blocked"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{Outbound: "block"}, + }, + }, + }, + }, + }, + }) + origin := newForwardOrigin(t) + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(serverPort))) + require.NoError(t, err) + defer conn.Close() + reader := std_bufio.NewReader(conn) + for _, authorization := range []string{proxyAuthorization, "Basic YmxvY2tlZDpwYXNzd29yZA==", proxyAuthorization} { + _, err = conn.Write([]byte("GET " + origin.url("/hello") + " HTTP/1.1\r\nHost: " + origin.host() + "\r\nUser-Agent: \r\nProxy-Authorization: " + authorization + "\r\n\r\n")) + require.NoError(t, err) + response, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + _, err = io.ReadAll(response.Body) + require.NoError(t, err) + response.Body.Close() + if authorization == proxyAuthorization { + require.Equal(t, http.StatusOK, response.StatusCode) + } else { + require.Equal(t, http.StatusBadGateway, response.StatusCode) + } + require.False(t, response.Close) + } + require.Equal(t, int32(2), origin.connections.Load()) +} + +func startEarlyResponseOrigin(t *testing.T) *net.TCPAddr { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + stop := make(chan struct{}) + t.Cleanup(func() { + close(stop) + listener.Close() + }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + reader := std_bufio.NewReader(conn) + _, err := reader.ReadString('\n') + if err == nil { + _, err = textproto.NewReader(reader).ReadMIMEHeader() + } + if err != nil { + return + } + _, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")) + if err != nil { + return + } + <-stop + }() + } + }() + return listener.Addr().(*net.TCPAddr) +} + +func startHugeHeaderOrigin(t *testing.T) *net.TCPAddr { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + listener.Close() + }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + _, err := http.ReadRequest(std_bufio.NewReader(conn)) + if err != nil { + return + } + _, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nX-Padding: ")) + if err != nil { + return + } + padding := bytes.Repeat([]byte("a"), 64<<10) + for { + _, err = conn.Write(padding) + if err != nil { + return + } + } + }() + } + }() + return listener.Addr().(*net.TCPAddr) +} + +func TestHTTPForwardHugeUpstreamHeader(t *testing.T) { + startForwardProxy(t) + origin := startHugeHeaderOrigin(t) + client := proxyClient(t, clientPort) + response, err := client.Get("http://" + origin.String() + "/") + require.NoError(t, err) + response.Body.Close() + require.Equal(t, http.StatusBadGateway, response.StatusCode) +} diff --git a/test/http_h2_test.go b/test/http_h2_test.go new file mode 100644 index 000000000..ad91d6fcd --- /dev/null +++ b/test/http_h2_test.go @@ -0,0 +1,630 @@ +package main + +import ( + std_bufio "bufio" + "context" + "crypto/rand" + "crypto/tls" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strconv" + "sync/atomic" + "testing" + "time" + + sTLS "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + sHTTP "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +const proxyAuthorization = "Basic c2VrYWk6cGFzc3dvcmQ=" + +func startTLSHTTPInbound(t *testing.T, certPem string, keyPem string, versions []int, extraOutbounds []option.Outbound) { + outbounds := append([]option.Outbound{{Type: C.TypeDirect}}, extraOutbounds...) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Version: versions, + Users: []auth.User{{Username: "sekai", Password: "password"}}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: outbounds, + }) +} + +func dialHTTP2Proxy(t *testing.T, port uint16) *http2.ClientConn { + tlsConn, err := tls.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(port)), &tls.Config{ + ServerName: "example.org", + InsecureSkipVerify: true, + NextProtos: []string{http2.NextProtoTLS}, + }) + require.NoError(t, err) + require.Equal(t, http2.NextProtoTLS, tlsConn.ConnectionState().NegotiatedProtocol) + clientConn, err := (&http2.Transport{}).NewClientConn(tlsConn) + require.NoError(t, err) + t.Cleanup(func() { + clientConn.Close() + }) + return clientConn +} + +type http2Tunnel struct { + writer *io.PipeWriter + response *http.Response +} + +func openHTTP2Tunnel(t *testing.T, clientConn *http2.ClientConn, host string, authorization string) *http2Tunnel { + pipeReader, pipeWriter := io.Pipe() + request := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Host: host}, + Host: host, + Header: make(http.Header), + Body: pipeReader, + } + if authorization != "" { + request.Header.Set("Proxy-Authorization", authorization) + } + response, err := clientConn.RoundTrip(request) + require.NoError(t, err) + return &http2Tunnel{writer: pipeWriter, response: response} +} + +func (t *http2Tunnel) close() { + t.writer.Close() + t.response.Body.Close() +} + +func TestHTTPInboundHTTP2(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, nil, nil) + origin := newForwardOrigin(t) + clientConn := dialHTTP2Proxy(t, serverPort) + + rejected := openHTTP2Tunnel(t, clientConn, origin.host(), "") + require.Equal(t, http.StatusProxyAuthRequired, rejected.response.StatusCode) + require.Contains(t, rejected.response.Header.Get("Proxy-Authenticate"), "Basic") + rejected.close() + + first := openHTTP2Tunnel(t, clientConn, origin.host(), proxyAuthorization) + require.Equal(t, http.StatusOK, first.response.StatusCode) + second := openHTTP2Tunnel(t, clientConn, origin.host(), proxyAuthorization) + require.Equal(t, http.StatusOK, second.response.StatusCode) + for _, tunnel := range []*http2Tunnel{second, first} { + _, err := tunnel.writer.Write([]byte("GET /hello HTTP/1.1\r\nHost: " + origin.host() + "\r\n\r\n")) + require.NoError(t, err) + response, err := http.ReadResponse(std_bufio.NewReader(tunnel.response.Body), nil) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + tunnel.close() + } + + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + request.Header.Set("Proxy-Authorization", proxyAuthorization) + response, err := clientConn.RoundTrip(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, "hello", string(body)) + require.Equal(t, origin.host(), response.Header.Get("X-Host")) + + response, err = clientConn.RoundTrip(request) + require.NoError(t, err) + body, err = io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + require.Equal(t, int32(4), origin.connections.Load()) +} + +type http2ProxyServer struct { + listener net.Listener + connections atomic.Int32 + streams atomic.Int32 +} + +func startHTTP2ProxyServer(t *testing.T, certPem string, keyPem string) *http2ProxyServer { + certificate, err := tls.LoadX509KeyPair(certPem, keyPem) + require.NoError(t, err) + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{certificate}, + NextProtos: []string{http2.NextProtoTLS}, + }) + require.NoError(t, err) + server := &http2ProxyServer{listener: listener} + h2Server := &http2.Server{} + handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + server.streams.Add(1) + if request.Method != http.MethodConnect || request.Header.Get("Proxy-Authorization") != proxyAuthorization { + writer.WriteHeader(http.StatusProxyAuthRequired) + return + } + conn, err := net.Dial("tcp", request.Host) + if err != nil { + writer.WriteHeader(http.StatusBadGateway) + return + } + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + go func() { + io.Copy(conn, request.Body) + conn.(*net.TCPConn).CloseWrite() + }() + buffer := make([]byte, 4096) + for { + n, err := conn.Read(buffer) + if n > 0 { + _, err = writer.Write(buffer[:n]) + if err != nil { + break + } + writer.(http.Flusher).Flush() + } + if err != nil { + break + } + } + conn.Close() + }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + server.connections.Add(1) + go func() { + err = conn.(*tls.Conn).Handshake() + if err != nil { + conn.Close() + return + } + h2Server.ServeConn(conn, &http2.ServeConnOpts{Handler: handler}) + }() + } + }() + t.Cleanup(func() { + listener.Close() + }) + return server +} + +func (s *http2ProxyServer) port() uint16 { + return uint16(s.listener.Addr().(*net.TCPAddr).Port) +} + +func TestHTTPOutboundHTTP2(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + proxyServer := startHTTP2ProxyServer(t, certPem, keyPem) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: proxyServer.port(), + }, + Username: "sekai", + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + }) + origin := newForwardOrigin(t) + for i := 0; i < 3; i++ { + client := proxyClient(t, clientPort) + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + response, err := client.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + client.CloseIdleConnections() + } + require.Equal(t, int32(1), proxyServer.connections.Load()) + require.Equal(t, int32(3), proxyServer.streams.Load()) +} + +func TestHTTPSelfTLS(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, nil, nil) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + }) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + for i := 0; i < 3; i++ { + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + response, err := client.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + } + require.Equal(t, int32(1), origin.connections.Load()) +} + +func TestHTTPForwardEarlyResponse(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, nil, nil) + origin := startEarlyResponseOrigin(t) + clientConn := dialHTTP2Proxy(t, serverPort) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+origin.String()+"/upload", io.LimitReader(rand.Reader, 4<<20)) + require.NoError(t, err) + request.ContentLength = 4 << 20 + request.Header.Set("Proxy-Authorization", proxyAuthorization) + response, err := clientConn.RoundTrip(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, "ok", string(body)) +} + +func TestHTTPInboundHTTP2Cleartext(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Version: []int{2}, + Users: []auth.User{{Username: "sekai", Password: "password"}}, + }, + }, + }, + Outbounds: []option.Outbound{{Type: C.TypeDirect}}, + }) + origin := newForwardOrigin(t) + http1Conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(serverPort))) + require.NoError(t, err) + defer http1Conn.Close() + _, err = http1Conn.Write([]byte("CONNECT " + origin.host() + " HTTP/1.1\r\nHost: " + origin.host() + "\r\nProxy-Authorization: " + proxyAuthorization + "\r\n\r\n")) + require.NoError(t, err) + http1Response, err := http.ReadResponse(std_bufio.NewReader(http1Conn), nil) + require.NoError(t, err) + require.Equal(t, http.StatusHTTPVersionNotSupported, http1Response.StatusCode) + require.True(t, http1Response.Close) + + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(serverPort))) + require.NoError(t, err) + clientConn, err := (&http2.Transport{AllowHTTP: true}).NewClientConn(conn) + require.NoError(t, err) + defer clientConn.Close() + tunnel := openHTTP2Tunnel(t, clientConn, origin.host(), proxyAuthorization) + require.Equal(t, http.StatusOK, tunnel.response.StatusCode) + _, err = tunnel.writer.Write([]byte("GET /hello HTTP/1.1\r\nHost: " + origin.host() + "\r\n\r\n")) + require.NoError(t, err) + response, err := http.ReadResponse(std_bufio.NewReader(tunnel.response.Body), nil) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + tunnel.close() + + forwardRequest, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + forwardRequest.Header.Set("Proxy-Authorization", proxyAuthorization) + forwardResponse, err := clientConn.RoundTrip(forwardRequest) + require.NoError(t, err) + forwardResponse.Body.Close() + require.Equal(t, http.StatusBadRequest, forwardResponse.StatusCode) +} + +func TestHTTPOutboundHTTP2Deadline(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, nil, nil) + origin := newForwardOrigin(t) + detour, err := sTLS.NewDialerFromOptions(globalCtx, log.NewNOPFactory().Logger(), N.SystemDialer, "127.0.0.1", option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + ALPN: []string{http2.NextProtoTLS}, + }) + require.NoError(t, err) + client, err := sHTTP.NewClient(sHTTP.ClientOptions{ + Dialer: detour, + Server: M.ParseSocksaddrHostPort("127.0.0.1", serverPort), + Username: "sekai", + Password: "password", + Version: 2, + }) + require.NoError(t, err) + defer client.Close() + conn, err := client.DialContext(context.Background(), N.NetworkTCP, M.ParseSocksaddr(origin.host())) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(100*time.Millisecond))) + _, err = conn.Read(make([]byte, 1)) + require.True(t, E.IsTimeout(err)) + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + _, err = conn.Write([]byte("GET /hello HTTP/1.1\r\nHost: " + origin.host() + "\r\n\r\n")) + require.NoError(t, err) + response, err := http.ReadResponse(std_bufio.NewReader(conn), nil) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, "hello", string(body)) +} + +func TestHTTPOutboundHTTP2NoFallback(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Version: []int{1}, + Users: []auth.User{{Username: "sekai", Password: "password"}}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{{Type: C.TypeDirect}}, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + Version: 2, + DisableVersionFallback: true, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + }) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + for i := 0; i < 2; i++ { + response, err := client.Get(origin.url("/hello")) + require.NoError(t, err) + response.Body.Close() + require.Equal(t, http.StatusBadGateway, response.StatusCode) + } + require.Equal(t, int32(0), origin.connections.Load()) +} + +func startSwitchingProtocolsOrigin(t *testing.T) *net.TCPAddr { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + listener.Close() + }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + _, err := http.ReadRequest(std_bufio.NewReader(conn)) + if err != nil { + return + } + conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")) + }() + } + }() + return listener.Addr().(*net.TCPAddr) +} + +func TestHTTPForwardUnexpectedSwitchingProtocols(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, nil, nil) + origin := startSwitchingProtocolsOrigin(t) + clientConn := dialHTTP2Proxy(t, serverPort) + request, err := http.NewRequest(http.MethodGet, "http://"+origin.String()+"/", nil) + require.NoError(t, err) + request.Header.Set("Proxy-Authorization", proxyAuthorization) + response, err := clientConn.RoundTrip(request) + require.NoError(t, err) + response.Body.Close() + require.Equal(t, http.StatusBadGateway, response.StatusCode) +} + +func startTruncatedChunkedOrigin(t *testing.T) *net.TCPAddr { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + listener.Close() + }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + _, err := http.ReadRequest(std_bufio.NewReader(conn)) + if err != nil { + return + } + conn.Write([]byte("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n")) + }() + } + }() + return listener.Addr().(*net.TCPAddr) +} + +func TestHTTPForwardHTTP2Responses(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, nil, nil) + clientConn := dialHTTP2Proxy(t, serverPort) + + origin := newForwardOrigin(t) + headRequest, err := http.NewRequest(http.MethodHead, origin.url("/hello"), nil) + require.NoError(t, err) + headRequest.Header.Set("User-Agent", "") + headRequest.Header.Set("Proxy-Authorization", proxyAuthorization) + headResponse, err := clientConn.RoundTrip(headRequest) + require.NoError(t, err) + headResponse.Body.Close() + require.Equal(t, http.StatusOK, headResponse.StatusCode) + require.Equal(t, int64(5), headResponse.ContentLength) + + trailerRequest, err := http.NewRequest(http.MethodGet, origin.url("/trailer"), nil) + require.NoError(t, err) + trailerRequest.Header.Set("Proxy-Authorization", proxyAuthorization) + trailerResponse, err := clientConn.RoundTrip(trailerRequest) + require.NoError(t, err) + trailerBody, err := io.ReadAll(trailerResponse.Body) + require.NoError(t, err) + trailerResponse.Body.Close() + require.Equal(t, "hello", string(trailerBody)) + require.Equal(t, "5d41402a", trailerResponse.Trailer.Get("X-Checksum")) + + streamRequest, err := http.NewRequest(http.MethodGet, origin.url("/stream"), nil) + require.NoError(t, err) + streamRequest.Header.Set("Proxy-Authorization", proxyAuthorization) + startTime := time.Now() + streamResponse, err := clientConn.RoundTrip(streamRequest) + require.NoError(t, err) + require.Less(t, time.Since(startTime), time.Second) + streamBody, err := io.ReadAll(streamResponse.Body) + streamResponse.Body.Close() + require.NoError(t, err) + require.Equal(t, "late", string(streamBody)) + + truncated := startTruncatedChunkedOrigin(t) + request, err := http.NewRequest(http.MethodGet, "http://"+truncated.String()+"/", nil) + require.NoError(t, err) + request.Header.Set("Proxy-Authorization", proxyAuthorization) + response, err := clientConn.RoundTrip(request) + require.NoError(t, err) + _, err = io.ReadAll(response.Body) + response.Body.Close() + require.Error(t, err) +} diff --git a/test/http_h3_test.go b/test/http_h3_test.go new file mode 100644 index 000000000..09f6c2400 --- /dev/null +++ b/test/http_h3_test.go @@ -0,0 +1,373 @@ +//go:build with_quic + +package main + +import ( + std_bufio "bufio" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strconv" + "testing" + "time" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + sTLS "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + sHTTP "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + + "github.com/stretchr/testify/require" +) + +func dialHTTP3Proxy(t *testing.T, port uint16, enableDatagrams bool) *http3.ClientConn { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + quicConn, err := quic.DialAddrEarly(ctx, "127.0.0.1:"+strconv.Itoa(int(port)), &tls.Config{ + ServerName: "example.org", + InsecureSkipVerify: true, + NextProtos: []string{http3.NextProtoH3}, + }, &quic.Config{EnableDatagrams: enableDatagrams}) + require.NoError(t, err) + transport := &http3.Transport{EnableDatagrams: enableDatagrams} + clientConn := transport.NewClientConn(quicConn) + t.Cleanup(func() { + clientConn.CloseWithError(0, "") + transport.Close() + }) + return clientConn +} + +func TestHTTPInboundHTTP3(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, []int{1, 2, 3}, nil) + origin := newForwardOrigin(t) + clientConn := dialHTTP3Proxy(t, serverPort, true) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + stream, err := clientConn.OpenRequestStream(ctx) + require.NoError(t, err) + err = stream.SendRequestHeader(&http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Host: origin.host()}, + Host: origin.host(), + Header: http.Header{"Proxy-Authorization": []string{proxyAuthorization}}, + }) + require.NoError(t, err) + response, err := stream.ReadResponse() + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + _, err = stream.Write([]byte("GET /hello HTTP/1.1\r\nHost: " + origin.host() + "\r\n\r\n")) + require.NoError(t, err) + originResponse, err := http.ReadResponse(std_bufio.NewReader(stream), nil) + require.NoError(t, err) + body, err := io.ReadAll(originResponse.Body) + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + stream.Close() + + forward, err := clientConn.OpenRequestStream(ctx) + require.NoError(t, err) + err = forward.SendRequestHeader(&http.Request{ + Method: http.MethodGet, + URL: &url.URL{Scheme: "http", Host: origin.host(), Path: "/hello"}, + Host: origin.host(), + Header: http.Header{ + "Proxy-Authorization": []string{proxyAuthorization}, + "User-Agent": nil, + }, + }) + require.NoError(t, err) + require.NoError(t, forward.Close()) + forwardResponse, err := forward.ReadResponse() + require.NoError(t, err) + body, err = io.ReadAll(forwardResponse.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, forwardResponse.StatusCode) + require.Equal(t, "hello", string(body)) + forward.Close() + + rejected, err := clientConn.OpenRequestStream(ctx) + require.NoError(t, err) + err = rejected.SendRequestHeader(&http.Request{ + Method: http.MethodGet, + URL: &url.URL{Scheme: "https", Host: origin.host(), Path: "/hello"}, + Host: origin.host(), + Header: http.Header{"Proxy-Authorization": []string{proxyAuthorization}}, + }) + require.NoError(t, err) + require.NoError(t, rejected.Close()) + rejectedResponse, err := rejected.ReadResponse() + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, rejectedResponse.StatusCode) +} + +func openHTTP3ConnectUDP(t *testing.T, ctx context.Context, clientConn *http3.ClientConn, path string) *http3.RequestStream { + stream, err := clientConn.OpenRequestStream(ctx) + require.NoError(t, err) + err = stream.SendRequestHeader(&http.Request{ + Method: http.MethodConnect, + Proto: "connect-udp", + URL: &url.URL{ + Scheme: "https", + Host: "example.org", + Path: path, + }, + Host: "example.org", + Header: http.Header{ + "Capsule-Protocol": []string{"?1"}, + "Proxy-Authorization": []string{proxyAuthorization}, + }, + }) + require.NoError(t, err) + response, err := stream.ReadResponse() + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + return stream +} + +func TestHTTPInboundConnectUDPHTTP3(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, []int{1, 2, 3}, nil) + echo := startUDPEcho(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + clientConn := dialHTTP3Proxy(t, serverPort, true) + stream := openHTTP3ConnectUDP(t, ctx, clientConn, connectUDPPath(echo)) + for i := 0; i < 3; i++ { + err := stream.SendDatagram(append([]byte{0}, []byte("ping")...)) + require.NoError(t, err) + datagram, err := stream.ReceiveDatagram(ctx) + require.NoError(t, err) + require.Equal(t, append([]byte{0}, []byte("ping")...), datagram) + } + writeDatagramCapsule(t, stream, []byte("capsule")) + datagram, err := stream.ReceiveDatagram(ctx) + require.NoError(t, err) + require.Equal(t, append([]byte{0}, []byte("capsule")...), datagram) + err = stream.SendDatagram(append([]byte{0x40, 0x00}, []byte("varint")...)) + require.NoError(t, err) + datagram, err = stream.ReceiveDatagram(ctx) + require.NoError(t, err) + require.Equal(t, append([]byte{0}, []byte("varint")...), datagram) + stream.Close() + + capsuleConn := dialHTTP3Proxy(t, serverPort, false) + capsuleStream := openHTTP3ConnectUDP(t, ctx, capsuleConn, connectUDPPath(echo)) + reader := std_bufio.NewReader(capsuleStream) + for i := 0; i < 3; i++ { + writeDatagramCapsule(t, capsuleStream, []byte("capsule")) + require.Equal(t, "capsule", string(readDatagramCapsule(t, reader))) + } + capsuleStream.Close() +} + +func startHTTP3OnlyInbound(t *testing.T, certPem string, keyPem string) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Version: []int{3}, + Users: []auth.User{{Username: "sekai", Password: "password"}}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{{Type: C.TypeDirect}}, + }) +} + +func startHTTP3Outbound(t *testing.T, certPem string, disableFallback bool) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + Version: 3, + DisableVersionFallback: disableFallback, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + }, + }, + }, + }) +} + +func TestHTTPOutboundHTTP3(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startHTTP3OnlyInbound(t, certPem, keyPem) + _, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(int(serverPort)), time.Second) + require.Error(t, err) + startHTTP3Outbound(t, certPem, true) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + for i := 0; i < 3; i++ { + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + response, err := client.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + } + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + dialUDP := func() (net.PacketConn, error) { + return dialer.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + require.NoError(t, testPingPongWithPacketConn(t, testPort, dialUDP)) + require.NoError(t, testLargeDataWithPacketConn(t, testPort, dialUDP)) +} + +func TestHTTPOutboundHTTP3Fallback(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Version: []int{1, 2}, + Users: []auth.User{{Username: "sekai", Password: "password"}}, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{{Type: C.TypeDirect}}, + }) + startHTTP3Outbound(t, certPem, false) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + response, err := client.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) +} + +func TestHTTPOutboundHTTP3NoFallback(t *testing.T) { + _, certPem, _ := createSelfSignedCertificate(t, "example.org") + startHTTP3Outbound(t, certPem, true) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + response, err := client.Do(request) + require.NoError(t, err) + response.Body.Close() + require.Equal(t, http.StatusBadGateway, response.StatusCode) +} + +func TestHTTPOutboundHTTP3Cancel(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + certificate, err := tls.LoadX509KeyPair(certPem, keyPem) + require.NoError(t, err) + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + quicListener, err := quic.ListenEarly(udpConn, &tls.Config{ + Certificates: []tls.Certificate{certificate}, + NextProtos: []string{http3.NextProtoH3}, + }, &quic.Config{EnableDatagrams: true}) + require.NoError(t, err) + server := &http3.Server{ + EnableDatagrams: true, + Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + <-request.Context().Done() + }), + } + go server.ServeListener(quicListener) + t.Cleanup(func() { + server.Close() + udpConn.Close() + }) + serverAddress := M.SocksaddrFromNet(udpConn.LocalAddr()) + tlsConfig, err := sTLS.NewClient(context.Background(), log.NewNOPFactory().Logger(), serverAddress.AddrString(), option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + ALPN: []string{http3.NextProtoH3}, + }) + require.NoError(t, err) + client, err := sHTTP.NewClient(sHTTP.ClientOptions{ + RawDialer: N.SystemDialer, + TLSConfig: tlsConfig, + Server: serverAddress, + Version: 3, + DisableVersionFallback: true, + }) + require.NoError(t, err) + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + startTime := time.Now() + _, err = client.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Less(t, time.Since(startTime), 5*time.Second) + packetCtx, packetCancel := context.WithTimeout(context.Background(), time.Second) + defer packetCancel() + _, err = client.ListenPacket(packetCtx, M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + require.ErrorIs(t, err, context.DeadlineExceeded) +} diff --git a/test/http_json_test.go b/test/http_json_test.go new file mode 100644 index 000000000..17b95821f --- /dev/null +++ b/test/http_json_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "io" + "net/http" + "strconv" + "testing" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json" + + "github.com/stretchr/testify/require" +) + +func TestHTTPJSONConfig(t *testing.T) { + serverOptions, err := json.UnmarshalExtendedContext[option.Options](globalCtx, []byte(`{ + "inbounds": [{ + "type": "http", + "listen": "127.0.0.1", + "listen_port": `+strconv.Itoa(int(serverPort))+`, + "users": [{"username": "sekai", "password": "password"}], + "stream_receive_window": "1 MB" + }], + "outbounds": [{"type": "direct"}] + }`)) + require.NoError(t, err) + startInstance(t, serverOptions) + clientOptions, err := json.UnmarshalExtendedContext[option.Options](globalCtx, []byte(`{ + "inbounds": [{ + "type": "mixed", + "listen": "127.0.0.1", + "listen_port": `+strconv.Itoa(int(clientPort))+` + }], + "outbounds": [{ + "type": "http", + "server": "127.0.0.1", + "server_port": `+strconv.Itoa(int(serverPort))+`, + "username": "sekai", + "password": "password", + "path": "/proxy" + }] + }`)) + require.NoError(t, err) + startInstance(t, clientOptions) + origin := newForwardOrigin(t) + client := proxyClient(t, clientPort) + request, err := http.NewRequest(http.MethodGet, origin.url("/hello"), nil) + require.NoError(t, err) + request.Header.Set("User-Agent", "") + response, err := client.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + require.Equal(t, "hello", string(body)) +} diff --git a/test/http_udp_test.go b/test/http_udp_test.go new file mode 100644 index 000000000..f6335898e --- /dev/null +++ b/test/http_udp_test.go @@ -0,0 +1,371 @@ +package main + +import ( + std_bufio "bufio" + "context" + "encoding/binary" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strconv" + "testing" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + sHTTP "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +func appendVarint(b []byte, value uint64) []byte { + switch { + case value < 1<<6: + return append(b, byte(value)) + case value < 1<<14: + return binary.BigEndian.AppendUint16(b, uint16(value)|0x4000) + case value < 1<<30: + return binary.BigEndian.AppendUint32(b, uint32(value)|0x80000000) + default: + return binary.BigEndian.AppendUint64(b, value|0xC000000000000000) + } +} + +func readVarint(t *testing.T, reader *std_bufio.Reader) uint64 { + first, err := reader.ReadByte() + require.NoError(t, err) + length := 1 << (first >> 6) + value := uint64(first & 0x3f) + for i := 1; i < length; i++ { + next, err := reader.ReadByte() + require.NoError(t, err) + value = value<<8 | uint64(next) + } + return value +} + +func writeDatagramCapsule(t *testing.T, writer io.Writer, payload []byte) { + capsule := appendVarint(nil, 0) + capsule = appendVarint(capsule, uint64(1+len(payload))) + capsule = append(capsule, 0) + capsule = append(capsule, payload...) + _, err := writer.Write(capsule) + require.NoError(t, err) +} + +func readDatagramCapsule(t *testing.T, reader *std_bufio.Reader) []byte { + capsuleType := readVarint(t, reader) + require.Equal(t, uint64(0), capsuleType) + length := readVarint(t, reader) + contextID, err := reader.ReadByte() + require.NoError(t, err) + require.Equal(t, byte(0), contextID) + payload := make([]byte, length-1) + _, err = io.ReadFull(reader, payload) + require.NoError(t, err) + return payload +} + +func startUDPEcho(t *testing.T) *net.UDPAddr { + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + t.Cleanup(func() { + conn.Close() + }) + go func() { + buffer := make([]byte, 65535) + for { + n, addr, err := conn.ReadFromUDP(buffer) + if err != nil { + return + } + conn.WriteToUDP(buffer[:n], addr) + } + }() + return conn.LocalAddr().(*net.UDPAddr) +} + +func connectUDPPath(target *net.UDPAddr) string { + return "/.well-known/masque/udp/" + target.IP.String() + "/" + strconv.Itoa(target.Port) + "/" +} + +func TestHTTPInboundConnectUDP(t *testing.T) { + startForwardProxy(t) + echo := startUDPEcho(t) + conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(serverPort))) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("GET " + connectUDPPath(echo) + " HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\nUpgrade: connect-udp\r\nCapsule-Protocol: ?1\r\nProxy-Authorization: " + proxyAuthorization + "\r\n\r\n")) + require.NoError(t, err) + reader := std_bufio.NewReader(conn) + response, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, response.StatusCode) + require.Equal(t, "connect-udp", response.Header.Get("Upgrade")) + for i := 0; i < 3; i++ { + writeDatagramCapsule(t, conn, []byte("ping")) + require.Equal(t, "ping", string(readDatagramCapsule(t, reader))) + } +} + +func TestHTTPInboundConnectUDPHTTP2(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, nil, nil) + echo := startUDPEcho(t) + clientConn := dialHTTP2Proxy(t, serverPort) + pipeReader, pipeWriter := io.Pipe() + request := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{ + Scheme: "https", + Host: "example.org", + Path: connectUDPPath(echo), + }, + Host: "example.org", + Header: http.Header{ + ":protocol": []string{"connect-udp"}, + "Capsule-Protocol": []string{"?1"}, + "Proxy-Authorization": []string{proxyAuthorization}, + }, + Body: pipeReader, + } + response, err := clientConn.RoundTrip(request) + require.NoError(t, err) + defer response.Body.Close() + defer pipeWriter.Close() + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, "?1", response.Header.Get("Capsule-Protocol")) + reader := std_bufio.NewReader(response.Body) + for i := 0; i < 3; i++ { + writeDatagramCapsule(t, pipeWriter, []byte("ping")) + require.Equal(t, "ping", string(readDatagramCapsule(t, reader))) + } +} + +func testHTTPOutboundUDP(t *testing.T, tlsOptions *option.OutboundTLSOptions) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: tlsOptions, + }, + }, + }, + }, + }) + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + dialUDP := func() (net.PacketConn, error) { + return dialer.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + } + require.NoError(t, testPingPongWithPacketConn(t, testPort, dialUDP)) + require.NoError(t, testLargeDataWithPacketConn(t, testPort, dialUDP)) +} + +func TestHTTPOutboundUDP(t *testing.T) { + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{{Username: "sekai", Password: "password"}}, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + }) + testHTTPOutboundUDP(t, nil) +} + +func TestHTTPOutboundUDPHTTP2(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startTLSHTTPInbound(t, certPem, keyPem, nil, nil) + testHTTPOutboundUDP(t, &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + ALPN: []string{http2.NextProtoTLS}, + }) +} + +func TestHTTPOutboundUDPIPv6Path(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + listener.Close() + }) + pathErr := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + reader := std_bufio.NewReader(conn) + request, err := http.ReadRequest(reader) + if err != nil { + pathErr <- err + return + } + expectedPath := "/.well-known/masque/udp/::1/" + strconv.Itoa(int(testPort)) + "/" + expectedRawPath := "/.well-known/masque/udp/%3A%3A1/" + strconv.Itoa(int(testPort)) + "/" + if request.URL.Path != expectedPath || request.URL.RawPath != expectedRawPath { + pathErr <- E.New("unexpected request URI: ", request.RequestURI) + return + } + pathErr <- nil + _, err = conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: connect-udp\r\nCapsule-Protocol: ?1\r\n\r\n")) + if err != nil { + return + } + io.Copy(conn, reader) + }() + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeHTTP, + Options: &option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: uint16(listener.Addr().(*net.TCPAddr).Port), + }, + }, + }, + }, + }) + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", clientPort), socks.Version5, "", "") + packetConn, err := dialer.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("::1", testPort)) + require.NoError(t, err) + defer packetConn.Close() + destination := &net.UDPAddr{IP: net.IPv6loopback, Port: int(testPort)} + _, err = packetConn.WriteTo([]byte("ping"), destination) + require.NoError(t, err) + require.NoError(t, <-pathErr) + packetConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + buffer := make([]byte, 64) + n, _, err := packetConn.ReadFrom(buffer) + require.NoError(t, err) + require.Equal(t, "ping", string(buffer[:n])) + packetConn.SetReadDeadline(time.Now()) + _, _, err = packetConn.ReadFrom(buffer) + require.True(t, E.IsTimeout(err)) +} + +func TestHTTPOutboundUDPDialContext(t *testing.T) { + startForwardProxy(t) + echo := startUDPEcho(t) + client, err := sHTTP.NewClient(sHTTP.ClientOptions{ + Server: M.ParseSocksaddrHostPort("127.0.0.1", serverPort), + Username: "sekai", + Password: "password", + Version: 1, + }) + require.NoError(t, err) + defer client.Close() + ctx, cancel := context.WithCancel(context.Background()) + packetConn, err := client.ListenPacket(ctx, M.SocksaddrFromNet(echo)) + require.NoError(t, err) + defer packetConn.Close() + cancel() + packetConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, err = packetConn.WriteTo([]byte("ping"), echo) + require.NoError(t, err) + buffer := make([]byte, 64) + n, _, err := packetConn.ReadFrom(buffer) + require.NoError(t, err) + require.Equal(t, "ping", string(buffer[:n])) + packetConn.SetReadDeadline(time.Now()) + _, _, err = packetConn.ReadFrom(buffer) + require.True(t, E.IsTimeout(err)) +} + +func TestHTTPOutboundUDPStreamClosed(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + listener.Close() + }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + reader := std_bufio.NewReader(conn) + _, err := http.ReadRequest(reader) + if err != nil { + return + } + _, err = conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: connect-udp\r\nCapsule-Protocol: ?1\r\n\r\n")) + if err != nil { + return + } + io.ReadFull(reader, make([]byte, 7)) + }() + } + }() + client, err := sHTTP.NewClient(sHTTP.ClientOptions{ + Server: M.SocksaddrFromNet(listener.Addr()), + Version: 1, + }) + require.NoError(t, err) + defer client.Close() + packetConn, err := client.ListenPacket(context.Background(), M.ParseSocksaddrHostPort("127.0.0.1", testPort)) + require.NoError(t, err) + defer packetConn.Close() + _, err = packetConn.WriteTo([]byte("ping"), &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: int(testPort)}) + require.NoError(t, err) + packetConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, _, err = packetConn.ReadFrom(make([]byte, 64)) + require.Error(t, err) + require.False(t, E.IsTimeout(err)) +} diff --git a/test/wrapper_test.go b/test/wrapper_test.go index 9eb9ed787..8eea0d4ff 100644 --- a/test/wrapper_test.go +++ b/test/wrapper_test.go @@ -12,7 +12,7 @@ import ( func TestOptionsWrapper(t *testing.T) { inbound := option.Inbound{ Type: C.TypeHTTP, - Options: &option.HTTPMixedInboundOptions{ + Options: &option.HTTPInboundOptions{ InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ TLS: &option.InboundTLSOptions{ Enabled: true, @@ -26,5 +26,5 @@ func TestOptionsWrapper(t *testing.T) { require.NotNil(t, tlsOptions, "find inbound tls options") tlsOptions.Enabled = false tlsOptionsWrapper.ReplaceInboundTLSOptions(tlsOptions) - require.False(t, inbound.Options.(*option.HTTPMixedInboundOptions).TLS.Enabled, "replace tls enabled") + require.False(t, inbound.Options.(*option.HTTPInboundOptions).TLS.Enabled, "replace tls enabled") } diff --git a/transport/http/capsule.go b/transport/http/capsule.go new file mode 100644 index 000000000..60b247862 --- /dev/null +++ b/transport/http/capsule.go @@ -0,0 +1,402 @@ +package http + +import ( + std_bufio "bufio" + "context" + "encoding/binary" + "errors" + "io" + "net" + "net/http" + "os" + "sync" + "time" + + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +const ( + capsuleTypeDatagram = 0 + capsuleHeadroom = 1 + 4 + 1 + maxCapsuleLength = 1 << 20 +) + +func readVarint(reader *std_bufio.Reader) (uint64, int, error) { + first, err := reader.ReadByte() + if err != nil { + return 0, 0, err + } + length := 1 << (first >> 6) + value := uint64(first & 0x3f) + for i := 1; i < length; i++ { + var next byte + next, err = reader.ReadByte() + if err != nil { + return 0, 0, err + } + value = value<<8 | uint64(next) + } + return value, length, nil +} + +func decodeVarint(b []byte) (uint64, int, bool) { + if len(b) == 0 { + return 0, 0, false + } + length := 1 << (b[0] >> 6) + if len(b) < length { + return 0, 0, false + } + value := uint64(b[0] & 0x3f) + for i := 1; i < length; i++ { + value = value<<8 | uint64(b[i]) + } + return value, length, true +} + +func varintLen(value uint64) int { + switch { + case value < 1<<6: + return 1 + case value < 1<<14: + return 2 + case value < 1<<30: + return 4 + default: + return 8 + } +} + +func putVarint(b []byte, value uint64) int { + switch varintLen(value) { + case 1: + b[0] = byte(value) + return 1 + case 2: + binary.BigEndian.PutUint16(b, uint16(value)|0x4000) + return 2 + case 4: + binary.BigEndian.PutUint32(b, uint32(value)|0x80000000) + return 4 + default: + binary.BigEndian.PutUint64(b, value|0xC000000000000000) + return 8 + } +} + +func readDatagramCapsule(reader *std_bufio.Reader, buffer *buf.Buffer) error { + for { + capsuleType, _, err := readVarint(reader) + if err != nil { + return err + } + length, _, err := readVarint(reader) + if err != nil { + return err + } + if length > maxCapsuleLength { + return E.New("capsule too large: ", length) + } + if capsuleType != capsuleTypeDatagram { + _, err = reader.Discard(int(length)) + if err != nil { + return err + } + continue + } + contextID, contextLength, err := readVarint(reader) + if err != nil { + return err + } + if uint64(contextLength) > length { + return E.New("malformed datagram capsule") + } + payloadLength := int(length) - contextLength + if contextID != 0 || payloadLength > buffer.FreeLen() { + _, err = reader.Discard(payloadLength) + if err != nil { + return err + } + continue + } + _, err = buffer.ReadFullFrom(reader, payloadLength) + return err + } +} + +func prependContextID(buffer *buf.Buffer) *buf.Buffer { + if buffer.Start() >= 1 { + buffer.ExtendHeader(1)[0] = 0 + return buffer + } + datagram := buf.NewSize(1 + buffer.Len()) + datagram.WriteByte(0) + datagram.Write(buffer.Bytes()) + buffer.Release() + return datagram +} + +func writeDatagramCapsule(writer io.Writer, datagram *buf.Buffer) error { + length := uint64(datagram.Len()) + headerLength := 1 + varintLen(length) + var capsule *buf.Buffer + if datagram.Start() >= headerLength { + header := datagram.ExtendHeader(headerLength) + header[0] = capsuleTypeDatagram + putVarint(header[1:], length) + capsule = datagram + } else { + capsule = buf.NewSize(headerLength + datagram.Len()) + header := capsule.Extend(headerLength) + header[0] = capsuleTypeDatagram + putVarint(header[1:], length) + capsule.Write(datagram.Bytes()) + datagram.Release() + } + defer capsule.Release() + _, err := writer.Write(capsule.Bytes()) + return err +} + +type capsuleConn struct { + reader *std_bufio.Reader + writer io.Writer + upstream net.Conn + destination M.Socksaddr + writeAccess sync.Mutex +} + +func newCapsuleConn(reader *std_bufio.Reader, upstream net.Conn, destination M.Socksaddr) *capsuleConn { + return &capsuleConn{ + reader: reader, + writer: upstream, + upstream: upstream, + destination: destination, + } +} + +func (c *capsuleConn) ReadPacket(buffer *buf.Buffer) (M.Socksaddr, error) { + err := readDatagramCapsule(c.reader, buffer) + if err != nil { + return M.Socksaddr{}, err + } + return c.destination, nil +} + +func (c *capsuleConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + c.writeAccess.Lock() + defer c.writeAccess.Unlock() + return writeDatagramCapsule(c.writer, prependContextID(buffer)) +} + +func (c *capsuleConn) Close() error { + return c.upstream.Close() +} + +func (c *capsuleConn) LocalAddr() net.Addr { + return c.upstream.LocalAddr() +} + +func (c *capsuleConn) SetDeadline(t time.Time) error { + return c.upstream.SetDeadline(t) +} + +func (c *capsuleConn) SetReadDeadline(t time.Time) error { + return c.upstream.SetReadDeadline(t) +} + +func (c *capsuleConn) SetWriteDeadline(t time.Time) error { + return c.upstream.SetWriteDeadline(t) +} + +func (c *capsuleConn) NeedAdditionalReadDeadline() bool { + return true +} + +func (c *capsuleConn) FrontHeadroom() int { + return capsuleHeadroom +} + +func (c *capsuleConn) Upstream() any { + return c.upstream +} + +var _ N.PacketConn = (*capsuleConn)(nil) + +type DatagramStream interface { + io.ReadWriteCloser + SendDatagram(payload []byte) error + ReceiveDatagram(ctx context.Context) ([]byte, error) +} + +var ( + HTTP3StreamFunc func(ctx context.Context, writer http.ResponseWriter) (DatagramStream, bool) + ErrDatagramUnsupported = E.New("datagram unsupported") +) + +type http3PacketConn struct { + stream DatagramStream + reader *std_bufio.Reader + destination M.Socksaddr + localAddr net.Addr + packets chan *buf.Buffer + ctx context.Context + cancel context.CancelFunc + closeOnce sync.Once + waitGroup sync.WaitGroup + writeAccess sync.Mutex + err error +} + +func newHTTP3PacketConn(stream DatagramStream, destination M.Socksaddr, localAddr net.Addr) *http3PacketConn { + ctx, cancel := context.WithCancel(context.Background()) + conn := &http3PacketConn{ + stream: stream, + reader: std_bufio.NewReader(stream), + destination: destination, + localAddr: localAddr, + packets: make(chan *buf.Buffer, 64), + ctx: ctx, + cancel: cancel, + } + conn.waitGroup.Add(2) + go conn.loopDatagram() + go conn.loopCapsule() + return conn +} + +func (c *http3PacketConn) loopDatagram() { + defer c.waitGroup.Done() + for { + datagram, err := c.stream.ReceiveDatagram(c.ctx) + if err != nil { + c.closeWithError(err) + return + } + contextID, contextLength, valid := decodeVarint(datagram) + if !valid || contextID != 0 { + continue + } + buffer := buf.NewSize(len(datagram) - contextLength) + buffer.Write(datagram[contextLength:]) + select { + case c.packets <- buffer: + case <-c.ctx.Done(): + buffer.Release() + return + } + } +} + +func (c *http3PacketConn) loopCapsule() { + defer c.waitGroup.Done() + for { + buffer := buf.NewPacket() + err := readDatagramCapsule(c.reader, buffer) + if err != nil { + buffer.Release() + c.closeWithError(err) + return + } + select { + case c.packets <- buffer: + case <-c.ctx.Done(): + buffer.Release() + return + } + } +} + +func (c *http3PacketConn) ReadPacket(buffer *buf.Buffer) (M.Socksaddr, error) { + for { + select { + case packet := <-c.packets: + if packet.Len() > buffer.FreeLen() { + packet.Release() + continue + } + buffer.Write(packet.Bytes()) + packet.Release() + return c.destination, nil + case <-c.ctx.Done(): + return M.Socksaddr{}, c.err + } + } +} + +func (c *http3PacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + datagram := prependContextID(buffer) + err := c.stream.SendDatagram(datagram.Bytes()) + if err == nil { + datagram.Release() + return nil + } + if !errors.Is(err, ErrDatagramUnsupported) { + datagram.Release() + return err + } + c.writeAccess.Lock() + defer c.writeAccess.Unlock() + return writeDatagramCapsule(c.stream, datagram) +} + +func (c *http3PacketConn) closeWithError(err error) { + c.closeOnce.Do(func() { + c.err = err + c.cancel() + c.stream.Close() + go func() { + c.waitGroup.Wait() + for { + select { + case packet := <-c.packets: + packet.Release() + default: + return + } + } + }() + }) +} + +func (c *http3PacketConn) Close() error { + c.closeWithError(net.ErrClosed) + return nil +} + +func (c *http3PacketConn) wait(ctx context.Context) { + select { + case <-c.ctx.Done(): + case <-ctx.Done(): + c.Close() + } +} + +func (c *http3PacketConn) LocalAddr() net.Addr { + return c.localAddr +} + +func (c *http3PacketConn) SetDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *http3PacketConn) SetReadDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *http3PacketConn) SetWriteDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *http3PacketConn) NeedAdditionalReadDeadline() bool { + return true +} + +func (c *http3PacketConn) FrontHeadroom() int { + return capsuleHeadroom +} + +var _ N.PacketConn = (*http3PacketConn)(nil) diff --git a/transport/http/client.go b/transport/http/client.go new file mode 100644 index 000000000..24720a312 --- /dev/null +++ b/transport/http/client.go @@ -0,0 +1,403 @@ +package http + +import ( + std_bufio "bufio" + "context" + "encoding/base64" + "errors" + "maps" + "net" + "net/http" + "net/url" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/common/badhttp" + "github.com/sagernet/sing-box/common/httpclient" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/bufio/deadline" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" + + "golang.org/x/net/http2" +) + +type tlsDialer interface { + DialTLSContext(ctx context.Context, destination M.Socksaddr) (aTLS.Conn, error) +} + +type ClientOptions struct { + Dialer N.Dialer + HTTP1Dialer N.Dialer + RawDialer N.Dialer + TLSConfig aTLS.Config + Server M.Socksaddr + Username string + Password string + Path string + Headers http.Header + Version int + DisableVersionFallback bool + HTTP2Options option.HTTP2Options + HTTP3Options option.QUICOptions +} + +type http3Client interface { + DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error) + ListenPacket(ctx context.Context, destination M.Socksaddr) (N.PacketConn, error) + ResetConnection() + Close() error +} + +var NewHTTP3Client func(options ClientOptions, authorization string) (http3Client, error) + +type Client struct { + dialer N.Dialer + http1Dialer N.Dialer + tlsDialer tlsDialer + server M.Socksaddr + authorization string + host string + path string + headers http.Header + version int + disableVersionFallback bool + http2Transport *http2.Transport + http2Access sync.Mutex + http2Conns []*http2ClientConn + http2Unsupported atomic.Bool + http2ConnectUDPUnsupported atomic.Bool + http3 http3Client + http3Broken atomic.Int64 + http3Backoff atomic.Int64 +} + +func NewClient(options ClientOptions) (*Client, error) { + client := &Client{ + dialer: options.Dialer, + http1Dialer: options.HTTP1Dialer, + server: options.Server, + path: options.Path, + headers: options.Headers.Clone(), + version: options.Version, + disableVersionFallback: options.DisableVersionFallback, + } + if client.headers != nil { + client.host = client.headers.Get("Host") + client.headers.Del("Host") + } + if client.host != "" && client.path != "" { + return nil, E.New("Host header and path are not allowed at the same time") + } + client.version = ResolveVersion(client.version, client.path, client.host) + if client.version >= 2 && (client.path != "" || client.host != "") { + return nil, E.New("path and Host header are only supported by HTTP/1") + } + if client.dialer == nil { + client.dialer = N.SystemDialer + } + if client.http1Dialer == nil { + client.http1Dialer = client.dialer + } + if dialer, isTLSDialer := options.Dialer.(tlsDialer); isTLSDialer && client.version >= 2 { + client.tlsDialer = dialer + http2Transport, err := httpclient.ConfigureHTTP2Transport(options.HTTP2Options) + if err != nil { + return nil, err + } + http2Transport.DisableCompression = true + client.http2Transport = http2Transport + } else if client.version == 2 && options.DisableVersionFallback { + return nil, E.New("HTTP/2 requires TLS") + } + if client.version == 3 && NewHTTP3Client == nil { + return nil, C.ErrQUICNotIncluded + } + if options.Username != "" { + client.authorization = "Basic " + base64.StdEncoding.EncodeToString([]byte(options.Username+":"+options.Password)) + } + if client.version == 3 { + http3, err := NewHTTP3Client(options, client.authorization) + if err != nil { + return nil, err + } + client.http3 = http3 + } + return client, nil +} + +func ResolveVersion(version int, path string, host string) int { + if version != 0 { + return version + } + if path != "" || host != "" { + return 1 + } + return 2 +} + +func (c *Client) MultiplexEnabled() bool { + return c.http3Available() || (c.tlsDialer != nil && !c.http2Unsupported.Load()) +} + +func (c *Client) http3Available() bool { + if c.http3 == nil { + return false + } + brokenUntil := c.http3Broken.Load() + return brokenUntil == 0 || time.Now().UnixNano() >= brokenUntil +} + +func (c *Client) markHTTP3Broken() { + backoff := time.Duration(c.http3Backoff.Load()) + if backoff == 0 { + backoff = http3BrokenBackoffInitial + } else { + backoff = min(backoff*2, http3BrokenBackoffMax) + } + c.http3Backoff.Store(int64(backoff)) + c.http3Broken.Store(time.Now().Add(backoff).UnixNano()) +} + +func (c *Client) clearHTTP3Broken() { + c.http3Broken.Store(0) + c.http3Backoff.Store(0) +} + +func (c *Client) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + case N.NetworkUDP: + return nil, os.ErrInvalid + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } + if c.http3Available() { + conn, err := c.http3.DialContext(ctx, destination) + if err == nil { + c.clearHTTP3Broken() + return conn, nil + } + if c.disableVersionFallback || !errors.Is(err, ErrHTTP3Unavailable) { + return nil, err + } + c.markHTTP3Broken() + } + if c.tlsDialer != nil && !c.http2Unsupported.Load() { + clientConn, conn, err := c.acquireHTTP2(ctx) + if err != nil { + return nil, err + } + if clientConn != nil { + return c.connectHTTP2(ctx, clientConn, destination) + } + if c.disableVersionFallback { + conn.Close() + return nil, ErrHTTP2Unsupported + } + return c.connectAndClose(ctx, conn, destination) + } + conn, err := c.http1Dialer.DialContext(ctx, N.NetworkTCP, c.server) + if err != nil { + return nil, err + } + return c.connectAndClose(ctx, conn, destination) +} + +func (c *Client) closeHTTP2Locked() { + for _, clientConn := range c.http2Conns { + clientConn.Close() + } + c.http2Conns = nil +} + +func (c *Client) ResetConnections() { + c.http2Access.Lock() + c.closeHTTP2Locked() + c.http2Access.Unlock() + c.http2Unsupported.Store(false) + c.http2ConnectUDPUnsupported.Store(false) + if c.http3 != nil { + c.http3.ResetConnection() + c.clearHTTP3Broken() + } +} + +func (c *Client) connect(ctx context.Context, conn net.Conn, destination M.Socksaddr) (net.Conn, error) { + stop := context.AfterFunc(ctx, func() { + conn.Close() + }) + defer stop() + request := &http.Request{ + Method: http.MethodConnect, + Header: http.Header{ + "Proxy-Connection": []string{"Keep-Alive"}, + }, + } + if c.host != "" && c.host != destination.Fqdn { + request.Host = c.host + request.URL = &url.URL{Opaque: destination.String()} + } else { + request.URL = &url.URL{Host: destination.String()} + } + if c.path != "" { + err := badhttp.URLSetPath(request.URL, c.path) + if err != nil { + return nil, err + } + } + maps.Copy(request.Header, c.headers) + if _, loaded := request.Header["User-Agent"]; !loaded { + request.Header["User-Agent"] = nil + } + if c.authorization != "" { + request.Header.Set("Proxy-Authorization", c.authorization) + } + err := request.Write(conn) + if err != nil { + return nil, E.Cause(err, "write request") + } + reader := std_bufio.NewReader(conn) + response, err := http.ReadResponse(reader, request) + if err != nil { + return nil, E.Cause(err, "read response") + } + if response.StatusCode != http.StatusOK { + return nil, statusError(response) + } + if !stop() { + return nil, ctx.Err() + } + if reader.Buffered() > 0 { + buffer := buf.NewSize(reader.Buffered()) + _, err = buffer.ReadFullFrom(reader, buffer.FreeLen()) + if err != nil { + buffer.Release() + return nil, err + } + return bufio.NewCachedConn(conn, buffer), nil + } + return conn, nil +} + +func (c *Client) Close() error { + c.http2Access.Lock() + defer c.http2Access.Unlock() + c.closeHTTP2Locked() + if c.http3 != nil { + c.http3.Close() + } + return nil +} + +const ( + http3BrokenBackoffInitial = 5 * time.Second + http3BrokenBackoffMax = 5 * time.Minute +) + +var ( + ErrHTTP2Unsupported = E.New("server does not support HTTP/2") + ErrHTTP3Unavailable = E.New("HTTP/3 unavailable") + errExtendedConnectUnsupported = E.New("server does not support HTTP/2 extended CONNECT") +) + +func statusError(response *http.Response) error { + switch response.StatusCode { + case http.StatusProxyAuthRequired: + return E.New("authentication required") + case http.StatusMethodNotAllowed: + return E.New("method not allowed") + default: + return E.New("unexpected status: ", response.Status) + } +} + +func (c *Client) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, err := c.listenPacket(ctx, destination) + if err != nil { + return nil, err + } + return deadline.NewPacketConn(bufio.NewNetPacketConn(&boundPacketConn{PacketConn: packetConn, destination: destination.Unwrap()})), nil +} + +func (c *Client) listenPacket(ctx context.Context, destination M.Socksaddr) (N.PacketConn, error) { + if c.http3Available() { + packetConn, err := c.http3.ListenPacket(ctx, destination) + if err == nil { + c.clearHTTP3Broken() + return packetConn, nil + } + if c.disableVersionFallback || !errors.Is(err, ErrHTTP3Unavailable) { + return nil, err + } + c.markHTTP3Broken() + } + if c.tlsDialer != nil && !c.http2Unsupported.Load() && !c.http2ConnectUDPUnsupported.Load() { + clientConn, conn, err := c.acquireHTTP2(ctx) + if err != nil { + return nil, err + } + if clientConn != nil { + packetConn, connectErr := c.connectUDPHTTP2(ctx, clientConn, destination) + if connectErr == nil { + return packetConn, nil + } + if !errors.Is(connectErr, errExtendedConnectUnsupported) || c.disableVersionFallback { + return nil, connectErr + } + c.http2ConnectUDPUnsupported.Store(true) + } else if c.disableVersionFallback { + conn.Close() + return nil, ErrHTTP2Unsupported + } else { + return c.connectUDPHTTP1AndClose(ctx, conn, destination) + } + } + conn, err := c.http1Dialer.DialContext(ctx, N.NetworkTCP, c.server) + if err != nil { + return nil, err + } + return c.connectUDPHTTP1AndClose(ctx, conn, destination) +} + +func (c *Client) connectUDPHTTP1AndClose(ctx context.Context, conn net.Conn, destination M.Socksaddr) (N.PacketConn, error) { + packetConn, err := c.connectUDPHTTP1(ctx, conn, destination) + if err != nil { + conn.Close() + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, err + } + return packetConn, nil +} + +var _ N.Dialer = (*Client)(nil) + +type boundPacketConn struct { + N.PacketConn + destination M.Socksaddr +} + +func (c *boundPacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { + if destination.Unwrap() != c.destination { + buffer.Release() + return E.New("connect-udp: destination mismatch: ", destination) + } + return c.PacketConn.WritePacket(buffer, destination) +} + +func (c *boundPacketConn) NeedAdditionalReadDeadline() bool { + return true +} + +func (c *boundPacketConn) Upstream() any { + return c.PacketConn +} diff --git a/transport/http/client_h2.go b/transport/http/client_h2.go new file mode 100644 index 000000000..1f191bde2 --- /dev/null +++ b/transport/http/client_h2.go @@ -0,0 +1,127 @@ +package http + +import ( + "context" + "net" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/pipe" + + "golang.org/x/net/http2" +) + +type http2ClientConn struct { + *http2.ClientConn +} + +func (c *Client) acquireHTTP2(ctx context.Context) (*http2ClientConn, net.Conn, error) { + c.http2Access.Lock() + defer c.http2Access.Unlock() + c.http2Conns = slices.DeleteFunc(c.http2Conns, func(it *http2ClientConn) bool { return it.State().Closed }) + for _, clientConn := range c.http2Conns { + if clientConn.ReserveNewRequest() { + return clientConn, nil, nil + } + } + conn, err := c.tlsDialer.DialTLSContext(adapter.ContextForMultiplexSession(ctx), c.server) + if err != nil { + return nil, nil, err + } + if conn.ConnectionState().NegotiatedProtocol != http2.NextProtoTLS { + if !c.disableVersionFallback { + c.http2Unsupported.Store(true) + } + return nil, conn, nil + } + rawClientConn, err := c.http2Transport.NewClientConn(conn) + if err != nil { + conn.Close() + return nil, nil, E.Cause(err, "create HTTP/2 connection") + } + clientConn := &http2ClientConn{ClientConn: rawClientConn} + clientConn.ReserveNewRequest() + c.http2Conns = append(c.http2Conns, clientConn) + return clientConn, nil, nil +} + +func (c *Client) connectHTTP2(ctx context.Context, clientConn *http2ClientConn, destination M.Socksaddr) (net.Conn, error) { + request := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Host: destination.String()}, + Host: destination.String(), + Header: c.headers.Clone(), + } + streamConn, err := c.roundTripHTTP2(ctx, clientConn, request, destination) + if err != nil { + return nil, err + } + return newClientTunnelConn(streamConn), nil +} + +func (c *Client) roundTripHTTP2(ctx context.Context, clientConn *http2ClientConn, request *http.Request, destination M.Socksaddr) (*clientStreamConn, error) { + pipeReader, pipeWriter := pipe.Pipe() + streamCtx, cancel := context.WithCancel(context.Background()) + request.Body = pipeReader + request = request.WithContext(streamCtx) + if request.Header == nil { + request.Header = make(http.Header) + } + if _, loaded := request.Header["User-Agent"]; !loaded { + request.Header["User-Agent"] = nil + } + if c.authorization != "" { + request.Header.Set("Proxy-Authorization", c.authorization) + } + stop := context.AfterFunc(ctx, cancel) + response, err := clientConn.RoundTrip(request) + stopped := stop() + if err == nil && !stopped { + response.Body.Close() + err = ctx.Err() + } + if err != nil { + cancel() + pipeWriter.Close() + if ctx.Err() != nil { + return nil, ctx.Err() + } + // golang.org/x/net/http2 has no exported sentinel for a peer that lacks + // SETTINGS_ENABLE_CONNECT_PROTOCOL; it returns errExtendedConnectNotSupported + // ("net/http: extended connect not supported by peer") from RoundTrip. + if strings.Contains(err.Error(), "extended connect not supported") { + return nil, E.Cause1(errExtendedConnectUnsupported, err) + } + return nil, E.Cause(err, "HTTP/2 CONNECT") + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + cancel() + pipeWriter.Close() + return nil, statusError(response) + } + return &clientStreamConn{ + reader: response.Body, + writer: pipeWriter, + cancel: cancel, + localAddr: M.Socksaddr{}, + remoteAddr: destination, + }, nil +} + +func (c *Client) connectAndClose(ctx context.Context, conn net.Conn, destination M.Socksaddr) (net.Conn, error) { + result, err := c.connect(ctx, conn, destination) + if err != nil { + conn.Close() + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, err + } + return result, nil +} diff --git a/transport/http/client_h3.go b/transport/http/client_h3.go new file mode 100644 index 000000000..5eabf84bb --- /dev/null +++ b/transport/http/client_h3.go @@ -0,0 +1,331 @@ +//go:build with_quic + +package http + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/quic-go" + "github.com/sagernet/quic-go/http3" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/httpclient" + "github.com/sagernet/sing-quic" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + aTLS "github.com/sagernet/sing/common/tls" +) + +func init() { + NewHTTP3Client = newHTTP3Client +} + +type http3ClientImpl struct { + dialer N.Dialer + tlsConfig aTLS.Config + server M.Socksaddr + authority string + headers http.Header + authorization string + quicConfig *quic.Config + transport *http3.Transport + access sync.Mutex + conn *http3.ClientConn + rawConn net.Conn +} + +func newHTTP3Client(options ClientOptions, authorization string) (http3Client, error) { + if options.TLSConfig == nil { + return nil, E.New("HTTP/3 requires TLS") + } + dialer := options.RawDialer + if dialer == nil { + dialer = N.SystemDialer + } + quicConfig := httpclient.NewQUICConfig(options.HTTP3Options) + quicConfig.EnableDatagrams = true + headers := options.Headers.Clone() + authority := options.Server.String() + if headers != nil { + if host := headers.Get("Host"); host != "" { + authority = host + } + headers.Del("Host") + } + return &http3ClientImpl{ + dialer: dialer, + tlsConfig: options.TLSConfig, + server: options.Server, + authority: authority, + headers: headers, + authorization: authorization, + quicConfig: quicConfig, + transport: &http3.Transport{EnableDatagrams: true, DisableCompression: true}, + }, nil +} + +func (c *http3ClientImpl) acquire(ctx context.Context) (*http3.ClientConn, error) { + c.access.Lock() + defer c.access.Unlock() + if c.conn != nil && c.conn.Context().Err() == nil { + return c.conn, nil + } + if c.rawConn != nil { + c.rawConn.Close() + c.rawConn = nil + } + rawConn, err := c.dialer.DialContext(adapter.ContextForMultiplexSession(ctx), N.NetworkUDP, c.server) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, E.Cause1(ErrHTTP3Unavailable, err) + } + quicConn, err := qtls.DialEarly(ctx, rawConn, c.tlsConfig, c.quicConfig) + if err != nil { + rawConn.Close() + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, E.Cause1(ErrHTTP3Unavailable, err) + } + c.conn = c.transport.NewClientConn(quicConn) + c.rawConn = rawConn + return c.conn, nil +} + +func (c *http3ClientImpl) openStream(ctx context.Context, request *http.Request) (*http3.RequestStream, *http3.ClientConn, error) { + clientConn, err := c.acquire(ctx) + if err != nil { + return nil, nil, err + } + stream, err := clientConn.OpenRequestStream(ctx) + if err != nil { + if ctx.Err() != nil { + return nil, nil, ctx.Err() + } + return nil, nil, E.Cause1(ErrHTTP3Unavailable, err) + } + if request.Header == nil { + request.Header = make(http.Header) + } + if _, loaded := request.Header["User-Agent"]; !loaded { + request.Header["User-Agent"] = nil + } + if c.authorization != "" { + request.Header.Set("Proxy-Authorization", c.authorization) + } + stop := context.AfterFunc(ctx, func() { + stream.CancelRead(0) + stream.CancelWrite(0) + }) + var response *http.Response + err = stream.SendRequestHeader(request) + if err == nil { + response, err = stream.ReadResponse() + } + if err == nil { + select { + case <-clientConn.ReceivedSettings(): + case <-clientConn.Context().Done(): + err = context.Cause(clientConn.Context()) + case <-ctx.Done(): + err = ctx.Err() + } + } + if !stop() { + err = ctx.Err() + } + if err != nil { + stream.CancelRead(0) + stream.Close() + if ctx.Err() != nil { + return nil, nil, ctx.Err() + } + return nil, nil, E.Cause(err, "HTTP/3 CONNECT") + } + if response.StatusCode != http.StatusOK { + stream.CancelRead(0) + stream.Close() + return nil, nil, statusError(response) + } + return stream, clientConn, nil +} + +func (c *http3ClientImpl) DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error) { + stream, _, err := c.openStream(ctx, &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Host: destination.String()}, + Host: destination.String(), + Header: c.headers.Clone(), + }) + if err != nil { + return nil, err + } + return &http3StreamConn{stream: stream, remoteAddr: destination}, nil +} + +func (c *http3ClientImpl) ListenPacket(ctx context.Context, destination M.Socksaddr) (N.PacketConn, error) { + header := c.headers.Clone() + if header == nil { + header = make(http.Header) + } + header.Set("Capsule-Protocol", "?1") + stream, clientConn, err := c.openStream(ctx, &http.Request{ + Method: http.MethodConnect, + Proto: connectUDPProtocol, + URL: connectUDPURL(c.authority, destination), + Host: c.authority, + Header: header, + }) + if err != nil { + return nil, err + } + return newHTTP3PacketConn(&http3RequestDatagramStream{stream: stream, datagramsEnabled: clientConn.Settings().EnableDatagrams}, destination, M.Socksaddr{}), nil +} + +func (c *http3ClientImpl) ResetConnection() { + c.access.Lock() + defer c.access.Unlock() + if c.conn != nil { + c.conn.CloseWithError(0, "") + c.conn = nil + } + if c.rawConn != nil { + c.rawConn.Close() + c.rawConn = nil + } +} + +func (c *http3ClientImpl) Close() error { + c.access.Lock() + defer c.access.Unlock() + if c.conn != nil { + c.conn.CloseWithError(0, "") + c.conn = nil + } + if c.rawConn != nil { + c.rawConn.Close() + c.rawConn = nil + } + return c.transport.Close() +} + +type http3StreamConn struct { + stream *http3.RequestStream + remoteAddr net.Addr + writeAccess sync.Mutex + closed atomic.Bool +} + +func (c *http3StreamConn) Read(p []byte) (int, error) { + n, err := c.stream.Read(p) + return n, c.wrapError(err) +} + +func (c *http3StreamConn) Write(p []byte) (int, error) { + c.writeAccess.Lock() + defer c.writeAccess.Unlock() + n, err := c.stream.Write(p) + return n, c.wrapError(err) +} + +func (c *http3StreamConn) wrapError(err error) error { + if err == nil { + return nil + } + if c.closed.Load() { + return net.ErrClosed + } + return qtls.WrapError(err) +} + +func (c *http3StreamConn) CloseWrite() error { + c.writeAccess.Lock() + defer c.writeAccess.Unlock() + return c.stream.Close() +} + +func (c *http3StreamConn) Close() error { + c.closed.Store(true) + c.stream.SetWriteDeadline(time.Now()) + c.writeAccess.Lock() + defer c.writeAccess.Unlock() + c.stream.CancelRead(0) + return c.stream.Close() +} + +func (c *http3StreamConn) LocalAddr() net.Addr { + return M.Socksaddr{} +} + +func (c *http3StreamConn) RemoteAddr() net.Addr { + return c.remoteAddr +} + +func (c *http3StreamConn) SetDeadline(t time.Time) error { + return c.stream.SetDeadline(t) +} + +func (c *http3StreamConn) SetReadDeadline(t time.Time) error { + return c.stream.SetReadDeadline(t) +} + +func (c *http3StreamConn) SetWriteDeadline(t time.Time) error { + return c.stream.SetWriteDeadline(t) +} + +func (c *http3StreamConn) NeedAdditionalReadDeadline() bool { + return true +} + +type http3RequestDatagramStream struct { + stream *http3.RequestStream + datagramsEnabled bool +} + +func (s *http3RequestDatagramStream) Read(p []byte) (int, error) { + return s.stream.Read(p) +} + +func (s *http3RequestDatagramStream) Write(p []byte) (int, error) { + return s.stream.Write(p) +} + +func (s *http3RequestDatagramStream) Close() error { + s.stream.SetWriteDeadline(time.Now()) + s.stream.CancelRead(0) + return s.stream.Close() +} + +func (s *http3RequestDatagramStream) SendDatagram(payload []byte) error { + if !s.datagramsEnabled { + return ErrDatagramUnsupported + } + err := s.stream.SendDatagram(payload) + if err == nil { + return nil + } + var tooLarge *quic.DatagramTooLargeError + if errors.As(err, &tooLarge) { + return ErrDatagramUnsupported + } + return err +} + +func (s *http3RequestDatagramStream) ReceiveDatagram(ctx context.Context) ([]byte, error) { + return s.stream.ReceiveDatagram(ctx) +} + +var ( + _ net.Conn = (*http3StreamConn)(nil) + _ N.WriteCloser = (*http3StreamConn)(nil) + _ DatagramStream = (*http3RequestDatagramStream)(nil) +) diff --git a/transport/http/connect_udp.go b/transport/http/connect_udp.go new file mode 100644 index 000000000..f927fe401 --- /dev/null +++ b/transport/http/connect_udp.go @@ -0,0 +1,178 @@ +package http + +import ( + std_bufio "bufio" + "context" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/sagernet/sing-box/transport/v2rayhttp" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +const ( + connectUDPProtocol = "connect-udp" + connectUDPPathPrefix = "/.well-known/masque/udp/" +) + +func parseConnectUDPTarget(path string) (M.Socksaddr, bool) { + rest, found := strings.CutPrefix(path, connectUDPPathPrefix) + if !found { + return M.Socksaddr{}, false + } + segments := strings.Split(strings.TrimSuffix(rest, "/"), "/") + if len(segments) != 2 { + return M.Socksaddr{}, false + } + host, err := url.PathUnescape(segments[0]) + if err != nil || host == "" { + return M.Socksaddr{}, false + } + port, err := strconv.ParseUint(segments[1], 10, 16) + if err != nil || port == 0 { + return M.Socksaddr{}, false + } + destination := M.ParseSocksaddrHostPort(host, uint16(port)).Unwrap() + if !destination.IsValid() { + return M.Socksaddr{}, false + } + return destination, true +} + +func connectUDPURL(authority string, destination M.Socksaddr) *url.URL { + host := destination.AddrString() + port := "/" + strconv.Itoa(int(destination.Port)) + "/" + requestURL := &url.URL{ + Path: connectUDPPathPrefix + host + port, + RawPath: connectUDPPathPrefix + strings.ReplaceAll(url.PathEscape(host), ":", "%3A") + port, + } + if authority != "" { + requestURL.Scheme = "https" + requestURL.Host = authority + } + return requestURL +} + +func requestIsConnectUDP(request *http.Request) bool { + return request.Method == http.MethodGet && requestIsUpgrade(request) && strings.EqualFold(request.Header.Get("Upgrade"), connectUDPProtocol) +} + +func (c *serverConn) serveConnectUDP(ctx context.Context, request *http.Request, source M.Socksaddr) (requestResult, error) { + destination, valid := parseConnectUDPTarget(request.URL.EscapedPath()) + if !valid || !request.ProtoAtLeast(1, 1) { + return c.reject(request, requestKeepAlive(request), http.StatusBadRequest, E.New("invalid connect-udp request: ", request.URL.Path)) + } + _, err := c.conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: connect-udp\r\nCapsule-Protocol: ?1\r\n\r\n")) + if err != nil { + return requestClose, E.Cause(err, "write response") + } + c.handler.NewPacketConnectionEx(ctx, newCapsuleConn(c.reader.Reader, c.conn, destination), source, destination, c.onClose) + return requestHandedOff, nil +} + +func (h *httpHandler) serveConnectUDP(ctx context.Context, writer http.ResponseWriter, request *http.Request, source M.Socksaddr) { + destination, valid := parseConnectUDPTarget(request.URL.EscapedPath()) + if !valid { + h.server.logger.ErrorContext(ctx, "process connection from ", source, ": invalid connect-udp target: ", request.URL.Path) + writer.WriteHeader(http.StatusBadRequest) + return + } + writer.Header().Set("Capsule-Protocol", "?1") + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + if request.ProtoMajor == 3 && HTTP3StreamFunc != nil { + stream, isDatagramStream := HTTP3StreamFunc(request.Context(), writer) + if isDatagramStream { + localAddr, _ := request.Context().Value(http.LocalAddrContextKey).(net.Addr) + conn := newHTTP3PacketConn(stream, destination, localAddr) + h.handler.NewPacketConnectionEx(ctx, conn, source, destination, nil) + conn.wait(request.Context()) + return + } + } + conn := v2rayhttp.NewHTTP2Wrapper(&v2rayhttp.ServerHTTPConn{ + HTTP2Conn: v2rayhttp.NewHTTPConn(request.Body, writer), + Flusher: writer.(http.Flusher), + }) + done := make(chan struct{}) + h.handler.NewPacketConnectionEx(ctx, newCapsuleConn(std_bufio.NewReader(conn), conn, destination), source, destination, N.OnceClose(func(it error) { + close(done) + })) + <-done + conn.CloseWrapper() +} + +func (c *Client) connectUDPHTTP1(ctx context.Context, conn net.Conn, destination M.Socksaddr) (N.PacketConn, error) { + stop := context.AfterFunc(ctx, func() { + conn.Close() + }) + defer stop() + request := &http.Request{ + Method: http.MethodGet, + URL: connectUDPURL("", destination), + Host: c.authority(), + Header: c.headers.Clone(), + } + if request.Header == nil { + request.Header = make(http.Header) + } + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", connectUDPProtocol) + request.Header.Set("Capsule-Protocol", "?1") + if _, loaded := request.Header["User-Agent"]; !loaded { + request.Header["User-Agent"] = nil + } + if c.authorization != "" { + request.Header.Set("Proxy-Authorization", c.authorization) + } + err := request.Write(conn) + if err != nil { + return nil, E.Cause(err, "write request") + } + reader := std_bufio.NewReader(conn) + response, err := http.ReadResponse(reader, request) + if err != nil { + return nil, E.Cause(err, "read response") + } + if response.StatusCode != http.StatusSwitchingProtocols { + return nil, statusError(response) + } + if !strings.EqualFold(response.Header.Get("Upgrade"), connectUDPProtocol) { + return nil, E.New("unexpected upgrade protocol: ", response.Header.Get("Upgrade")) + } + if !stop() { + return nil, ctx.Err() + } + return newCapsuleConn(reader, conn, destination), nil +} + +func (c *Client) connectUDPHTTP2(ctx context.Context, clientConn *http2ClientConn, destination M.Socksaddr) (N.PacketConn, error) { + request := &http.Request{ + Method: http.MethodConnect, + URL: connectUDPURL(c.authority(), destination), + Host: c.authority(), + Header: c.headers.Clone(), + } + if request.Header == nil { + request.Header = make(http.Header) + } + request.Header.Set(":protocol", connectUDPProtocol) + request.Header.Set("Capsule-Protocol", "?1") + stream, err := c.roundTripHTTP2(ctx, clientConn, request, destination) + if err != nil { + return nil, err + } + return newCapsuleConn(std_bufio.NewReader(stream), stream, destination), nil +} + +func (c *Client) authority() string { + if c.host != "" { + return c.host + } + return c.server.String() +} diff --git a/transport/http/extended_connect.go b/transport/http/extended_connect.go new file mode 100644 index 000000000..5a8e31148 --- /dev/null +++ b/transport/http/extended_connect.go @@ -0,0 +1,14 @@ +package http + +import ( + _ "unsafe" + + _ "golang.org/x/net/http2" +) + +//go:linkname disableExtendedConnectProtocol golang.org/x/net/http2.disableExtendedConnectProtocol +var disableExtendedConnectProtocol bool + +func init() { + disableExtendedConnectProtocol = false +} diff --git a/transport/http/forward.go b/transport/http/forward.go new file mode 100644 index 000000000..c5d4b04d2 --- /dev/null +++ b/transport/http/forward.go @@ -0,0 +1,250 @@ +package http + +import ( + std_bufio "bufio" + "bytes" + "context" + "io" + "net" + "net/http" + "time" + + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" +) + +type upstreamConn struct { + net.Conn + reader *std_bufio.Reader + limiter *readLimiter + user string + source M.Socksaddr + destination M.Socksaddr + done chan struct{} + err error +} + +func (c *upstreamConn) closed() bool { + select { + case <-c.done: + return true + default: + return false + } +} + +func (c *upstreamConn) readResponse(request *http.Request) (*http.Response, error) { + c.limiter.remaining = maxHeaderBytes + response, err := http.ReadResponse(c.reader, request) + c.limiter.remaining = -1 + return response, err +} + +func (c *upstreamConn) closeErr() error { + select { + case <-c.done: + return c.err + default: + return nil + } +} + +func (c *serverConn) openUpstream(ctx context.Context, source M.Socksaddr, destination M.Socksaddr) *upstreamConn { + user, _ := auth.UserFromContext[string](ctx) + if c.upstream != nil { + if c.upstream.user == user && c.upstream.source == source && c.upstream.destination == destination && !c.upstream.closed() { + return c.upstream + } + c.closeUpstream() + } + c.upstream = newUpstreamConn(ctx, c.handler, source, destination) + c.upstream.user = user + return c.upstream +} + +func (c *serverConn) serveForward(ctx context.Context, request *http.Request, source M.Socksaddr, upgrade bool) (requestResult, error) { + destination, valid := forwardDestination(request) + if !valid { + return c.reject(request, requestKeepAlive(request), http.StatusBadRequest, E.New("invalid forward target: ", request.URL.String())) + } + keepAlive := requestKeepAlive(request) + upgradeProtocol := request.Header.Get("Upgrade") + removeHopByHopHeaders(request.Header) + if upgrade { + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", upgradeProtocol) + } + if _, loaded := request.Header["User-Agent"]; !loaded { + request.Header["User-Agent"] = nil + } + request.Close = false + // Request.Write and Response.Write close the body, and net/http's (*body).Close + // drains an unread body to EOF from the connection reader. + if request.Body != http.NoBody { + request.Body = io.NopCloser(request.Body) + } + + upstream := c.openUpstream(ctx, source, destination) + writeDone := make(chan error, 1) + go func() { + err := request.Write(upstream) + if err != nil { + upstream.Close() + } + writeDone <- err + }() + + var response *http.Response + for { + var err error + response, err = upstream.readResponse(request) + if err != nil { + c.closeUpstream() + writeErr := c.finishRequestWrite(writeDone) + err = E.Errors(upstream.closeErr(), E.Cause(err, "read upstream response")) + if !keepAlive || (writeErr != nil && request.Body != http.NoBody) { + return c.rejectAndClose(request, http.StatusBadGateway, err) + } + request.Body = http.NoBody + return c.reject(request, true, http.StatusBadGateway, err) + } + if response.StatusCode >= 200 || response.StatusCode == http.StatusSwitchingProtocols { + break + } + if request.ProtoMajor == 1 && request.ProtoMinor == 0 { + continue + } + removeHopByHopHeaders(response.Header) + err = writeInterimResponse(c.conn, request, response) + if err != nil { + c.closeUpstream() + c.finishRequestWrite(writeDone) + return requestClose, E.Cause(err, "write interim response") + } + } + + response.ProtoMajor = request.ProtoMajor + response.ProtoMinor = request.ProtoMinor + if response.StatusCode == http.StatusSwitchingProtocols { + if !upgrade { + c.closeUpstream() + c.finishRequestWrite(writeDone) + return requestClose, E.New("unexpected 101 response") + } + return c.relayUpgrade(ctx, response, writeDone) + } + + upstreamClose := response.Close + removeHopByHopHeaders(response.Header) + if response.ContentLength == -1 && responseHasBody(request, response) { + if request.ProtoMajor == 1 && request.ProtoMinor == 0 { + keepAlive = false + } else { + response.TransferEncoding = []string{"chunked"} + } + } + response.Close = !keepAlive + if keepAlive && request.ProtoMajor == 1 && request.ProtoMinor == 0 { + response.Header.Set("Connection", "keep-alive") + response.Header.Set("Proxy-Connection", "keep-alive") + } + response.Body = io.NopCloser(response.Body) + err := response.Write(c.conn) + if err != nil { + c.closeUpstream() + c.finishRequestWrite(writeDone) + return requestClose, E.Cause(err, "write response") + } + writeErr := c.finishRequestWrite(writeDone) + if writeErr != nil { + c.closeUpstream() + if keepAlive && request.Body != http.NoBody && !c.discardBody(request.Body) { + keepAlive = false + } + } else if upstreamClose { + c.closeUpstream() + } + if !keepAlive { + return requestClose, nil + } + return requestContinue, nil +} + +func (c *serverConn) finishRequestWrite(writeDone chan error) error { + select { + case err := <-writeDone: + return err + default: + } + c.conn.SetReadDeadline(time.Now().Add(discardBodyTimeout)) + timer := time.NewTimer(discardBodyTimeout) + defer timer.Stop() + var err error + select { + case err = <-writeDone: + case <-timer.C: + c.closeUpstream() + err = <-writeDone + if err == nil { + err = E.New("request body timeout") + } + } + c.conn.SetReadDeadline(time.Time{}) + return err +} + +func (c *serverConn) relayUpgrade(ctx context.Context, response *http.Response, writeDone chan error) (requestResult, error) { + response.Close = false + err := response.Write(c.conn) + if err != nil { + c.closeUpstream() + c.finishRequestWrite(writeDone) + return requestClose, E.Cause(err, "write response") + } + err = c.finishRequestWrite(writeDone) + if err != nil { + c.closeUpstream() + return requestClose, E.Cause(err, "write upgrade request") + } + upstream := c.upstream + c.upstream = nil + var upstreamConn net.Conn = upstream + if upstream.reader.Buffered() > 0 { + buffer := buf.NewSize(upstream.reader.Buffered()) + _, err = buffer.ReadFullFrom(upstream.reader, buffer.FreeLen()) + if err != nil { + buffer.Release() + upstream.Close() + return requestClose, err + } + upstreamConn = bufio.NewCachedConn(upstream.Conn, buffer) + } + err = bufio.CopyConn(ctx, c.reader.cachedConn(c.conn), upstreamConn) + return requestClose, err +} + +func responseHasBody(request *http.Request, response *http.Response) bool { + if request.Method == http.MethodHead { + return false + } + if response.StatusCode < 200 || response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified { + return false + } + return true +} + +func writeInterimResponse(writer io.Writer, request *http.Request, response *http.Response) error { + var buffer bytes.Buffer + buffer.WriteString(F.ToString("HTTP/", request.ProtoMajor, ".", request.ProtoMinor, " ", response.StatusCode, " ", http.StatusText(response.StatusCode), "\r\n")) + err := response.Header.Write(&buffer) + if err != nil { + return err + } + buffer.WriteString("\r\n") + _, err = writer.Write(buffer.Bytes()) + return err +} diff --git a/transport/http/header.go b/transport/http/header.go new file mode 100644 index 000000000..779311e70 --- /dev/null +++ b/transport/http/header.go @@ -0,0 +1,82 @@ +package http + +import ( + "net/http" + "strings" + + M "github.com/sagernet/sing/common/metadata" + + "golang.org/x/net/http/httpguts" +) + +func headerHasToken(header http.Header, name string, token string) bool { + for _, value := range header.Values(name) { + if httpguts.HeaderValuesContainsToken([]string{value}, token) { + return true + } + } + return false +} + +func removeHopByHopHeaders(header http.Header) { + for _, value := range header.Values("Connection") { + for token := range strings.SplitSeq(value, ",") { + token = strings.TrimSpace(token) + if token != "" { + header.Del(token) + } + } + } + header.Del("Connection") + header.Del("Proxy-Connection") + header.Del("Proxy-Authenticate") + header.Del("Proxy-Authorization") + header.Del("Keep-Alive") + header.Del("TE") + header.Del("Transfer-Encoding") + header.Del("Upgrade") +} + +func requestKeepAlive(request *http.Request) bool { + if request.ProtoMajor == 1 && request.ProtoMinor == 0 { + return headerHasToken(request.Header, "Connection", "keep-alive") || + headerHasToken(request.Header, "Proxy-Connection", "keep-alive") + } + return !request.Close && !headerHasToken(request.Header, "Proxy-Connection", "close") +} + +func requestIsUpgrade(request *http.Request) bool { + return request.Header.Get("Upgrade") != "" && headerHasToken(request.Header, "Connection", "upgrade") +} + +func parseAuthority(authority string, defaultPort uint16) M.Socksaddr { + destination := M.ParseSocksaddr(authority) + if destination.Port == 0 { + destination.Port = defaultPort + } + return destination.Unwrap() +} + +func connectDestination(request *http.Request) M.Socksaddr { + authority := request.URL.Host + if authority == "" { + authority = request.Host + } + return parseAuthority(authority, 443) +} + +func forwardDestination(request *http.Request) (M.Socksaddr, bool) { + switch request.URL.Scheme { + case "http", "ws": + default: + return M.Socksaddr{}, false + } + if request.URL.Host == "" { + return M.Socksaddr{}, false + } + destination := parseAuthority(request.URL.Host, 80) + if !destination.IsValid() { + return M.Socksaddr{}, false + } + return destination, true +} diff --git a/transport/http/reader.go b/transport/http/reader.go new file mode 100644 index 000000000..abc85ce0a --- /dev/null +++ b/transport/http/reader.go @@ -0,0 +1,65 @@ +package http + +import ( + std_bufio "bufio" + "io" + "net" + + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" +) + +var errHeaderTooLarge = E.New("request header too large") + +type Reader struct { + *std_bufio.Reader + limiter *readLimiter +} + +func NewReader(conn net.Conn) *Reader { + limiter := &readLimiter{reader: conn, remaining: -1} + return &Reader{ + Reader: std_bufio.NewReader(limiter), + limiter: limiter, + } +} + +func (r *Reader) setLimit(limit int64) { + r.limiter.remaining = limit +} + +func (r *Reader) cachedConn(conn net.Conn) net.Conn { + if r.Buffered() == 0 { + return conn + } + buffer := buf.NewSize(r.Buffered()) + _, err := buffer.ReadFullFrom(r, buffer.FreeLen()) + if err != nil { + buffer.Release() + return conn + } + return bufio.NewCachedConn(conn, buffer) +} + +type readLimiter struct { + reader io.Reader + remaining int64 +} + +func (r *readLimiter) Read(p []byte) (int, error) { + if r.remaining < 0 { + return r.reader.Read(p) + } + if r.remaining == 0 { + return 0, errHeaderTooLarge + } + if int64(len(p)) > r.remaining { + p = p[:r.remaining] + } + n, err := r.reader.Read(p) + r.remaining -= int64(n) + return n, err +} + +var _ io.Reader = (*readLimiter)(nil) diff --git a/transport/http/server.go b/transport/http/server.go new file mode 100644 index 000000000..4798cc836 --- /dev/null +++ b/transport/http/server.go @@ -0,0 +1,124 @@ +package http + +import ( + "context" + "math" + "net" + "net/http" + "time" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/net/http2" +) + +const ( + maxHeaderBytes = 1 << 20 + idleTimeout = 60 * time.Second + maxDiscardBodyBytes = 256 << 10 + discardBodyTimeout = 5 * time.Second + realm = "sing-box" +) + +type Handler interface { + N.TCPConnectionHandlerEx + N.UDPConnectionHandlerEx +} + +type ServerOptions struct { + Authenticator *auth.Authenticator + Logger logger.ContextLogger + HTTP1 bool + HTTP2 bool + HTTP2Options option.HTTP2Options + UDP bool +} + +type Server struct { + authenticator *auth.Authenticator + logger logger.ContextLogger + http1 bool + http2Server *http2.Server + udp bool +} + +func NewServer(options ServerOptions) *Server { + server := &Server{ + authenticator: options.Authenticator, + logger: options.Logger, + http1: options.HTTP1, + udp: options.UDP, + } + if options.HTTP2 { + server.http2Server = &http2.Server{ + IdleTimeout: idleTimeout, + ReadIdleTimeout: time.Duration(options.HTTP2Options.KeepAlivePeriod), + PingTimeout: time.Duration(options.HTTP2Options.IdleTimeout), + MaxConcurrentStreams: uint32(max(options.HTTP2Options.MaxConcurrentStreams, 0)), + MaxUploadBufferPerConnection: int32(min(options.HTTP2Options.ConnectionReceiveWindow.Value(), math.MaxInt32)), + MaxUploadBufferPerStream: int32(min(options.HTTP2Options.StreamReceiveWindow.Value(), math.MaxInt32)), + } + if options.HTTP2Options.IdleTimeout > 0 { + server.http2Server.IdleTimeout = time.Duration(options.HTTP2Options.IdleTimeout) + } + } + return server +} + +func (s *Server) ServeConnection(ctx context.Context, conn net.Conn, reader *Reader, handler Handler, source M.Socksaddr, onClose N.CloseHandlerFunc) { + if s.http2Server != nil { + conn.SetReadDeadline(time.Now().Add(idleTimeout)) + isHTTP2, err := reader.isHTTP2Preface() + conn.SetReadDeadline(time.Time{}) + if err != nil { + s.finishConnection(ctx, conn, source, onClose, E.Cause(err, "peek request")) + return + } + if isHTTP2 { + s.serveHTTP2(ctx, conn, reader, handler, source, onClose) + return + } + } + if !s.http1 { + _, err := conn.Write([]byte("HTTP/1.1 505 HTTP Version Not Supported\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")) + s.finishConnection(ctx, conn, source, onClose, err) + return + } + connection := &serverConn{ + server: s, + ctx: ctx, + conn: conn, + reader: reader, + handler: handler, + source: source, + onClose: onClose, + } + connection.serve() +} + +func (s *Server) HTTP3Handler(handler Handler) http.Handler { + return &httpHandler{ + server: s, + handler: handler, + } +} + +func (s *Server) finishConnection(ctx context.Context, conn net.Conn, source M.Socksaddr, onClose N.CloseHandlerFunc, err error) { + conn.Close() + if err != nil { + if E.IsClosedOrCanceled(err) || E.IsTimeout(err) { + s.logger.DebugContext(ctx, "connection closed: ", err) + err = nil + } else { + s.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", source)) + } + } + if onClose != nil { + onClose(err) + } +} diff --git a/transport/http/server_conn.go b/transport/http/server_conn.go new file mode 100644 index 000000000..3ce835b91 --- /dev/null +++ b/transport/http/server_conn.go @@ -0,0 +1,173 @@ +package http + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "time" + + "github.com/sagernet/sing-box/common/badhttp" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type requestResult uint8 + +const ( + requestContinue requestResult = iota + requestClose + requestHandedOff +) + +type serverConn struct { + server *Server + ctx context.Context + conn net.Conn + reader *Reader + handler Handler + source M.Socksaddr + onClose N.CloseHandlerFunc + upstream *upstreamConn +} + +func (c *serverConn) serve() { + for { + result, err := c.serveRequest() + switch result { + case requestContinue: + continue + case requestHandedOff: + c.closeUpstream() + return + case requestClose: + c.closeUpstream() + c.server.finishConnection(c.ctx, c.conn, c.source, c.onClose, err) + return + } + } +} + +func (c *serverConn) serveRequest() (requestResult, error) { + c.conn.SetReadDeadline(time.Now().Add(idleTimeout)) + c.reader.setLimit(maxHeaderBytes) + request, err := badhttp.ReadRequest(c.reader.Reader) + c.reader.setLimit(-1) + c.conn.SetReadDeadline(time.Time{}) + if err != nil { + switch { + case errors.Is(err, io.EOF), E.IsTimeout(err), E.IsClosed(err): + return requestClose, err + case errors.Is(err, errHeaderTooLarge): + return c.rejectAndClose(nil, http.StatusRequestHeaderFieldsTooLarge, err) + default: + return c.rejectAndClose(nil, http.StatusBadRequest, E.Cause(err, "read request")) + } + } + ctx := c.ctx + if c.server.authenticator != nil { + username, password, valid := badhttp.ParseBasicAuth(request.Header.Get("Proxy-Authorization")) + if !valid || !c.server.authenticator.Verify(username, password) { + var authErr error + if !valid { + authErr = E.New("authentication failed: missing or malformed Proxy-Authorization") + } else { + authErr = E.New("authentication failed: username=", username) + } + c.server.logger.ErrorContext(ctx, E.Cause(authErr, "process connection from ", c.source)) + return c.reject(request, requestKeepAlive(request), http.StatusProxyAuthRequired, authErr) + } + ctx = auth.ContextWithUser(ctx, username) + } + source := badhttp.ForwardedSource(request, c.source) + switch { + case request.Method == http.MethodConnect: + return c.serveConnect(ctx, request, source) + case c.server.udp && requestIsConnectUDP(request): + return c.serveConnectUDP(ctx, request, source) + case requestIsUpgrade(request): + return c.serveForward(ctx, request, source, true) + default: + return c.serveForward(ctx, request, source, false) + } +} + +func (c *serverConn) serveConnect(ctx context.Context, request *http.Request, source M.Socksaddr) (requestResult, error) { + destination := connectDestination(request) + if !destination.IsValid() { + return c.reject(request, requestKeepAlive(request), http.StatusBadRequest, E.New("invalid CONNECT target: ", request.URL.Host)) + } + _, err := c.conn.Write([]byte(F.ToString("HTTP/", request.ProtoMajor, ".", request.ProtoMinor, " 200 Connection established\r\n\r\n"))) + if err != nil { + return requestClose, E.Cause(err, "write response") + } + c.handler.NewConnectionEx(ctx, c.reader.cachedConn(c.conn), source, destination, c.onClose) + return requestHandedOff, nil +} + +func (c *serverConn) reject(request *http.Request, keepAlive bool, statusCode int, cause error) (requestResult, error) { + if keepAlive && request.Body != nil && request.Body != http.NoBody { + keepAlive = c.discardBody(request.Body) + } + err := c.writeReject(request, statusCode, keepAlive) + if err != nil { + return requestClose, E.Errors(cause, err) + } + if keepAlive { + return requestContinue, nil + } + return requestClose, cause +} + +func (c *serverConn) rejectAndClose(request *http.Request, statusCode int, cause error) (requestResult, error) { + err := c.writeReject(request, statusCode, false) + if err != nil { + return requestClose, E.Errors(cause, err) + } + return requestClose, cause +} + +func (c *serverConn) writeReject(request *http.Request, statusCode int, keepAlive bool) error { + response := &http.Response{ + StatusCode: statusCode, + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Request: request, + Close: !keepAlive, + } + if request != nil { + response.ProtoMajor = request.ProtoMajor + response.ProtoMinor = request.ProtoMinor + } + if statusCode == http.StatusProxyAuthRequired { + response.Header.Set("Proxy-Authenticate", `Basic realm="`+realm+`", charset="UTF-8"`) + } + if keepAlive && request.ProtoMajor == 1 && request.ProtoMinor == 0 { + response.Header.Set("Connection", "keep-alive") + response.Header.Set("Proxy-Connection", "keep-alive") + } + err := response.Write(c.conn) + if err != nil { + return E.Cause(err, "write response") + } + return nil +} + +func (c *serverConn) discardBody(body io.Reader) bool { + c.conn.SetReadDeadline(time.Now().Add(discardBodyTimeout)) + _, err := io.CopyN(io.Discard, body, maxDiscardBodyBytes) + c.conn.SetReadDeadline(time.Time{}) + return errors.Is(err, io.EOF) +} + +func (c *serverConn) closeUpstream() { + if c.upstream != nil { + c.upstream.Close() + c.upstream = nil + } +} diff --git a/transport/http/server_h2.go b/transport/http/server_h2.go new file mode 100644 index 000000000..21048b92f --- /dev/null +++ b/transport/http/server_h2.go @@ -0,0 +1,277 @@ +package http + +import ( + std_bufio "bufio" + "context" + "crypto/tls" + "maps" + "net" + "net/http" + "strings" + + "github.com/sagernet/sing-box/common/badhttp" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/transport/v2rayhttp" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/pipe" + + "golang.org/x/net/http2" +) + +const http2Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + +func (r *Reader) isHTTP2Preface() (bool, error) { + head, err := r.Peek(3) + if err != nil { + return false, err + } + if string(head) != http2Preface[:3] { + return false, nil + } + preface, err := r.Peek(len(http2Preface)) + if err != nil { + return false, err + } + return string(preface) == http2Preface, nil +} + +type connectionStater interface { + ConnectionState() tls.ConnectionState +} + +type statedConn struct { + net.Conn + stater connectionStater +} + +func (c *statedConn) ConnectionState() tls.ConnectionState { + return c.stater.ConnectionState() +} + +func (s *Server) serveHTTP2(ctx context.Context, conn net.Conn, reader *Reader, handler Handler, source M.Socksaddr, onClose N.CloseHandlerFunc) { + serveConn := reader.cachedConn(conn) + stater, isStater := conn.(connectionStater) + if isStater && serveConn != conn { + serveConn = &statedConn{Conn: serveConn, stater: stater} + } + s.http2Server.ServeConn(serveConn, &http2.ServeConnOpts{ + Context: ctx, + BaseConfig: &http.Server{ + MaxHeaderBytes: maxHeaderBytes, + }, + Handler: &httpHandler{ + server: s, + handler: handler, + source: source, + plaintext: !isStater, + }, + }) + if onClose != nil { + onClose(nil) + } +} + +type httpHandler struct { + server *Server + handler Handler + source M.Socksaddr + plaintext bool +} + +func (h *httpHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + ctx := log.ContextWithNewID(request.Context()) + connectionSource := h.source + if !connectionSource.IsValid() { + connectionSource = M.ParseSocksaddr(request.RemoteAddr).Unwrap() + } + if h.server.authenticator != nil { + username, password, valid := badhttp.ParseBasicAuth(request.Header.Get("Proxy-Authorization")) + if !valid || !h.server.authenticator.Verify(username, password) { + var authErr error + if !valid { + authErr = E.New("authentication failed: missing or malformed Proxy-Authorization") + } else { + authErr = E.New("authentication failed: username=", username) + } + h.server.logger.ErrorContext(ctx, E.Cause(authErr, "process connection from ", connectionSource)) + writer.Header().Set("Proxy-Authenticate", `Basic realm="`+realm+`", charset="UTF-8"`) + writer.WriteHeader(http.StatusProxyAuthRequired) + return + } + ctx = auth.ContextWithUser(ctx, username) + } + source := badhttp.ForwardedSource(request, connectionSource) + if request.Method == http.MethodConnect { + protocol := request.Header.Get(":protocol") + if protocol == "" && request.ProtoMajor == 3 && !strings.HasPrefix(request.Proto, "HTTP/") { + protocol = request.Proto + } + switch { + case protocol == "": + h.serveConnect(ctx, writer, request, source) + case protocol == connectUDPProtocol && h.server.udp: + h.serveConnectUDP(ctx, writer, request, source) + default: + h.server.logger.ErrorContext(ctx, "process connection from ", source, ": unsupported CONNECT protocol: ", protocol) + writer.WriteHeader(http.StatusNotImplemented) + } + return + } + h.serveForward(ctx, writer, request, source) +} + +func (h *httpHandler) serveConnect(ctx context.Context, writer http.ResponseWriter, request *http.Request, source M.Socksaddr) { + destination := connectDestination(request) + if !destination.IsValid() { + h.server.logger.ErrorContext(ctx, "process connection from ", source, ": invalid CONNECT target: ", request.Host) + writer.WriteHeader(http.StatusBadRequest) + return + } + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + conn := v2rayhttp.NewHTTP2Wrapper(&v2rayhttp.ServerHTTPConn{ + HTTP2Conn: v2rayhttp.NewHTTPConn(request.Body, writer), + Flusher: writer.(http.Flusher), + }) + done := make(chan struct{}) + h.handler.NewConnectionEx(ctx, conn, source, destination, N.OnceClose(func(it error) { + close(done) + })) + <-done + conn.CloseWrapper() +} + +func (h *httpHandler) serveForward(ctx context.Context, writer http.ResponseWriter, request *http.Request, source M.Socksaddr) { + // golang.org/x/net/http2 builds the request URL from :path only (internal/httpcommon.NewServerRequest); + // :scheme survives only as request.TLS, which it sets when :scheme is https and the served conn + // exposes ConnectionState, so on a plaintext conn the scheme is unknowable. quic-go/http3 sets URL.Scheme. + if request.URL.Scheme == "" { + if h.plaintext { + h.server.logger.ErrorContext(ctx, "process connection from ", source, ": forward request over cleartext HTTP/2") + writer.WriteHeader(http.StatusBadRequest) + return + } + if request.TLS != nil { + request.URL.Scheme = "https" + } else { + request.URL.Scheme = "http" + } + } + request.URL.Host = request.Host + destination, valid := forwardDestination(request) + if !valid { + h.server.logger.ErrorContext(ctx, "process connection from ", source, ": invalid forward target: ", request.URL.Scheme, "://", request.Host) + writer.WriteHeader(http.StatusBadRequest) + return + } + removeHopByHopHeaders(request.Header) + if _, loaded := request.Header["User-Agent"]; !loaded { + request.Header["User-Agent"] = nil + } + if request.ContentLength == 0 { + request.Body = http.NoBody + } + request.Close = false + + upstream := newUpstreamConn(ctx, h.handler, source, destination) + stop := context.AfterFunc(request.Context(), func() { + upstream.Close() + }) + defer stop() + writeDone := make(chan error, 1) + go func() { + err := request.Write(upstream) + if err != nil { + upstream.Close() + } + writeDone <- err + }() + defer func() { + upstream.Close() + request.Body.Close() + <-writeDone + }() + + var response *http.Response + for { + var err error + response, err = upstream.readResponse(request) + if err != nil { + upstream.Close() + h.server.logger.ErrorContext(ctx, E.Cause(E.Errors(upstream.closeErr(), err), "process connection from ", source, ": read upstream response")) + writer.WriteHeader(http.StatusBadGateway) + return + } + if response.StatusCode >= 200 { + break + } + if response.StatusCode == http.StatusSwitchingProtocols { + upstream.Close() + h.server.logger.ErrorContext(ctx, "process connection from ", source, ": unexpected 101 response") + writer.WriteHeader(http.StatusBadGateway) + return + } + removeHopByHopHeaders(response.Header) + maps.Copy(writer.Header(), response.Header) + writer.WriteHeader(response.StatusCode) + clear(writer.Header()) + } + removeHopByHopHeaders(response.Header) + maps.Copy(writer.Header(), response.Header) + if !responseHasBody(request, response) && request.Method != http.MethodHead { + writer.Header().Del("Content-Length") + } + for name := range response.Trailer { + writer.Header().Add("Trailer", name) + } + writer.WriteHeader(response.StatusCode) + if !responseHasBody(request, response) { + response.Body.Close() + return + } + writer.(http.Flusher).Flush() + _, err := bufio.Copy(flushWriter{writer}, response.Body) + if err != nil { + upstream.Close() + response.Body.Close() + h.server.logger.DebugContext(ctx, "process connection from ", source, ": relay response: ", err) + panic(http.ErrAbortHandler) + } + response.Body.Close() + maps.Copy(writer.Header(), response.Trailer) +} + +func newUpstreamConn(ctx context.Context, handler Handler, source M.Socksaddr, destination M.Socksaddr) *upstreamConn { + serverSide, clientSide := pipe.Pipe() + limiter := &readLimiter{reader: clientSide, remaining: -1} + upstream := &upstreamConn{ + Conn: clientSide, + reader: std_bufio.NewReader(limiter), + limiter: limiter, + source: source, + destination: destination, + done: make(chan struct{}), + } + go handler.NewConnectionEx(ctx, serverSide, source, destination, N.OnceClose(func(it error) { + upstream.err = it + close(upstream.done) + })) + return upstream +} + +type flushWriter struct { + http.ResponseWriter +} + +func (w flushWriter) Write(p []byte) (int, error) { + n, err := w.ResponseWriter.Write(p) + if err != nil { + return n, err + } + w.ResponseWriter.(http.Flusher).Flush() + return n, nil +} diff --git a/transport/http/stream_conn.go b/transport/http/stream_conn.go new file mode 100644 index 000000000..e9fd231bb --- /dev/null +++ b/transport/http/stream_conn.go @@ -0,0 +1,108 @@ +package http + +import ( + "context" + "io" + "net" + "os" + "strings" + "sync/atomic" + "time" + + "github.com/sagernet/sing/common/baderror" + "github.com/sagernet/sing/common/bufio/deadline" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" +) + +type clientStreamConn struct { + reader io.ReadCloser + writer net.Conn + cancel context.CancelFunc + localAddr net.Addr + remoteAddr net.Addr + closed atomic.Bool +} + +func (c *clientStreamConn) Read(p []byte) (int, error) { + n, err := c.reader.Read(p) + return n, c.wrapError(err) +} + +func (c *clientStreamConn) Write(p []byte) (int, error) { + n, err := c.writer.Write(p) + return n, c.wrapError(err) +} + +func (c *clientStreamConn) wrapError(err error) error { + if err == nil { + return nil + } + if c.closed.Load() || strings.Contains(err.Error(), "client connection force closed") { + return net.ErrClosed + } + return baderror.WrapH2(err) +} + +func (c *clientStreamConn) CloseWrite() error { + return c.writer.Close() +} + +func (c *clientStreamConn) Close() error { + c.closed.Store(true) + c.writer.Close() + c.reader.Close() + c.cancel() + return nil +} + +func (c *clientStreamConn) LocalAddr() net.Addr { + return c.localAddr +} + +func (c *clientStreamConn) RemoteAddr() net.Addr { + return c.remoteAddr +} + +func (c *clientStreamConn) SetDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *clientStreamConn) SetReadDeadline(t time.Time) error { + return os.ErrInvalid +} + +func (c *clientStreamConn) SetWriteDeadline(t time.Time) error { + return c.writer.SetWriteDeadline(t) +} + +func (c *clientStreamConn) NeedAdditionalReadDeadline() bool { + return true +} + +type clientTunnelConn struct { + N.ExtendedConn + streamConn *clientStreamConn +} + +func newClientTunnelConn(streamConn *clientStreamConn) *clientTunnelConn { + return &clientTunnelConn{ExtendedConn: deadline.NewConn(streamConn), streamConn: streamConn} +} + +func (c *clientTunnelConn) CloseWrite() error { + return c.streamConn.CloseWrite() +} + +func (c *clientTunnelConn) SetDeadline(t time.Time) error { + return E.Errors(c.SetReadDeadline(t), c.SetWriteDeadline(t)) +} + +func (c *clientTunnelConn) Upstream() any { + return c.ExtendedConn +} + +var ( + _ net.Conn = (*clientStreamConn)(nil) + _ N.WriteCloser = (*clientStreamConn)(nil) + _ N.WriteCloser = (*clientTunnelConn)(nil) +) diff --git a/transport/v2raygrpclite/server.go b/transport/v2raygrpclite/server.go index 6dd04ce1c..5f20b47c0 100644 --- a/transport/v2raygrpclite/server.go +++ b/transport/v2raygrpclite/server.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/tls" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -19,7 +20,6 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" - sHttp "github.com/sagernet/sing/protocol/http" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" //nolint:staticcheck @@ -83,7 +83,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusOK) done := make(chan struct{}) conn := v2rayhttp.NewHTTP2Wrapper(newGunConn(request.Body, writer, writer.(http.Flusher))) - s.handler.NewConnectionEx(request.Context(), conn, sHttp.SourceAddress(request), M.Socksaddr{}, N.OnceClose(func(it error) { + s.handler.NewConnectionEx(request.Context(), conn, badhttp.SourceAddress(request), M.Socksaddr{}, N.OnceClose(func(it error) { close(done) })) <-done diff --git a/transport/v2rayhttp/client.go b/transport/v2rayhttp/client.go index b831086d3..2a1a50a59 100644 --- a/transport/v2rayhttp/client.go +++ b/transport/v2rayhttp/client.go @@ -13,6 +13,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" @@ -20,7 +21,6 @@ import ( E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - sHTTP "github.com/sagernet/sing/protocol/http" "golang.org/x/net/http2" ) @@ -72,7 +72,7 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt } requestURL.Host = serverAddr.String() requestURL.Path = options.Path - err := sHTTP.URLSetPath(&requestURL, options.Path) + err := badhttp.URLSetPath(&requestURL, options.Path) if err != nil { return nil, E.Cause(err, "parse path") } diff --git a/transport/v2rayhttp/server.go b/transport/v2rayhttp/server.go index 0f3abcc5d..6f6cbe69e 100644 --- a/transport/v2rayhttp/server.go +++ b/transport/v2rayhttp/server.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" @@ -21,7 +22,6 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" - sHttp "github.com/sagernet/sing/protocol/http" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" //nolint:staticcheck @@ -103,7 +103,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { } } - source := sHttp.SourceAddress(request) + source := badhttp.SourceAddress(request) if h, ok := writer.(http.Hijacker); ok { if request.ContentLength > buf.MaxPooledBufferSize { s.invalidRequest(writer, request, http.StatusRequestEntityTooLarge, E.New("request body too large: ", request.ContentLength)) diff --git a/transport/v2rayhttpupgrade/client.go b/transport/v2rayhttpupgrade/client.go index 207c4ebe6..dfc5ad2bf 100644 --- a/transport/v2rayhttpupgrade/client.go +++ b/transport/v2rayhttpupgrade/client.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/tls" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/buf" @@ -16,7 +17,6 @@ import ( E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - sHTTP "github.com/sagernet/sing/protocol/http" ) var _ adapter.V2RayClientTransport = (*Client)(nil) @@ -57,7 +57,7 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt } requestURL.Host = serverAddr.String() requestURL.Path = options.Path - err := sHTTP.URLSetPath(&requestURL, options.Path) + err := badhttp.URLSetPath(&requestURL, options.Path) if err != nil { return nil, E.Cause(err, "parse path") } diff --git a/transport/v2rayhttpupgrade/server.go b/transport/v2rayhttpupgrade/server.go index 2b58bc2a8..46c0c89f3 100644 --- a/transport/v2rayhttpupgrade/server.go +++ b/transport/v2rayhttpupgrade/server.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" @@ -21,7 +22,6 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" - sHttp "github.com/sagernet/sing/protocol/http" ) var _ adapter.V2RayServerTransport = (*Server)(nil) @@ -125,7 +125,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { } conn = bufio.NewCachedConn(conn, cache) } - s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, sHttp.SourceAddress(request), M.Socksaddr{}, nil) + s.handler.NewConnectionEx(v2rayhttp.DupContext(request.Context()), conn, badhttp.SourceAddress(request), M.Socksaddr{}, nil) } func (s *Server) invalidRequest(writer http.ResponseWriter, request *http.Request, statusCode int, err error) { diff --git a/transport/v2raywebsocket/client.go b/transport/v2raywebsocket/client.go index 54d748f1b..01e9cea81 100644 --- a/transport/v2raywebsocket/client.go +++ b/transport/v2raywebsocket/client.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" @@ -18,7 +19,6 @@ import ( E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - sHTTP "github.com/sagernet/sing/protocol/http" "github.com/sagernet/ws" ) @@ -48,7 +48,7 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt } requestURL.Host = serverAddr.String() requestURL.Path = options.Path - err := sHTTP.URLSetPath(&requestURL, options.Path) + err := badhttp.URLSetPath(&requestURL, options.Path) if err != nil { return nil, E.Cause(err, "parse path") } diff --git a/transport/v2raywebsocket/server.go b/transport/v2raywebsocket/server.go index b54d760a0..bbf40df63 100644 --- a/transport/v2raywebsocket/server.go +++ b/transport/v2raywebsocket/server.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" @@ -22,7 +23,6 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" - sHttp "github.com/sagernet/sing/protocol/http" "github.com/sagernet/ws" ) @@ -110,7 +110,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { s.invalidRequest(writer, request, 0, E.Cause(err, "upgrade websocket connection")) return } - source := sHttp.SourceAddress(request) + source := badhttp.SourceAddress(request) conn = NewConn(wsConn, source, ws.StateServerSide) if len(earlyData) > 0 { conn = bufio.NewCachedConn(conn, buf.As(earlyData))