From 7a949fa40f4095b8a62c094f4662ba641c2ddbc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 21 Sep 2026 22:46:18 +0800 Subject: [PATCH] Add MASQUE support --- constant/proxy.go | 6 + docs/configuration/endpoint/index.md | 2 + docs/configuration/endpoint/index.zh.md | 2 + docs/configuration/endpoint/masque-client.md | 145 ++++++ .../endpoint/masque-client.zh.md | 145 ++++++ docs/configuration/endpoint/masque-server.md | 126 +++++ .../endpoint/masque-server.zh.md | 126 +++++ docs/schema.json | 427 ++++++++++++++++ include/quic.go | 1 - include/registry.go | 2 + mkdocs.yml | 2 + option/masque.go | 121 +++++ protocol/http/inbound.go | 26 +- protocol/http/outbound.go | 50 +- protocol/masque/client.go | 374 ++++++++++++++ protocol/masque/endpoint.go | 122 +++++ protocol/masque/server.go | 325 ++++++++++++ protocol/openconnect/client.go | 25 +- protocol/openvpn/client.go | 29 +- protocol/openvpn/server.go | 26 +- protocol/tailscale/port.go | 2 +- test/endpoint_udp_nat_test.go | 104 +--- test/masque_test.go | 287 +++++++++++ transport/{openvpn => device}/device.go | 62 +-- .../device_stack.go => device/stack.go} | 21 +- .../device_system.go => device/system.go} | 20 +- .../system_stack.go} | 4 +- transport/http/capsule.go | 65 ++- transport/http/client.go | 164 +++--- transport/http/client_h2.go | 11 +- transport/http/client_h3.go | 32 +- transport/http/connect_udp.go | 81 +-- transport/http/forward.go | 4 +- transport/http/server.go | 36 +- transport/http/server_conn.go | 49 +- transport/http/server_h2.go | 49 +- .../http/server_h3.go | 12 +- transport/http/tunnel_client.go | 170 +++++++ transport/http/tunnel_server.go | 160 ++++++ transport/masque/capsule.go | 166 ++++++ transport/masque/client.go | 478 ++++++++++++++++++ transport/masque/icmp.go | 59 +++ transport/masque/pool.go | 63 +++ transport/masque/server.go | 432 ++++++++++++++++ transport/masque/session.go | 249 +++++++++ transport/masque/template.go | 237 +++++++++ transport/openconnect/configuration.go | 42 ++ transport/openconnect/device.go | 220 -------- transport/openconnect/device_system.go | 356 ------------- transport/openvpn/configuration.go | 37 ++ transport/openvpn/device_stack.go | 156 ------ transport/openvpn/device_system_stack.go | 117 ----- transport/wireguard/port.go | 2 +- 53 files changed, 4664 insertions(+), 1365 deletions(-) create mode 100644 docs/configuration/endpoint/masque-client.md create mode 100644 docs/configuration/endpoint/masque-client.zh.md create mode 100644 docs/configuration/endpoint/masque-server.md create mode 100644 docs/configuration/endpoint/masque-server.zh.md create mode 100644 option/masque.go create mode 100644 protocol/masque/client.go create mode 100644 protocol/masque/endpoint.go create mode 100644 protocol/masque/server.go create mode 100644 test/masque_test.go rename transport/{openvpn => device}/device.go (81%) rename transport/{openconnect/device_stack.go => device/stack.go} (91%) rename transport/{openvpn/device_system.go => device/system.go} (95%) rename transport/{openconnect/device_system_stack.go => device/system_stack.go} (96%) rename protocol/http/quic/inbound_init.go => transport/http/server_h3.go (82%) create mode 100644 transport/http/tunnel_client.go create mode 100644 transport/http/tunnel_server.go create mode 100644 transport/masque/capsule.go create mode 100644 transport/masque/client.go create mode 100644 transport/masque/icmp.go create mode 100644 transport/masque/pool.go create mode 100644 transport/masque/server.go create mode 100644 transport/masque/session.go create mode 100644 transport/masque/template.go create mode 100644 transport/openconnect/configuration.go delete mode 100644 transport/openconnect/device.go delete mode 100644 transport/openconnect/device_system.go create mode 100644 transport/openvpn/configuration.go delete mode 100644 transport/openvpn/device_stack.go delete mode 100644 transport/openvpn/device_system_stack.go diff --git a/constant/proxy.go b/constant/proxy.go index 4d4eaea7a..2993eb91b 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -29,6 +29,8 @@ const ( TypeOpenConnect = "openconnect" TypeOpenVPNClient = "openvpn-client" TypeOpenVPNServer = "openvpn-server" + TypeMASQUEClient = "masque-client" + TypeMASQUEServer = "masque-server" TypeTailscale = "tailscale" TypeTailcat = "tailcat" TypeCloudflared = "cloudflared" @@ -109,6 +111,10 @@ func ProxyDisplayName(proxyType string) string { return "OpenVPN Client" case TypeOpenVPNServer: return "OpenVPN Server" + case TypeMASQUEClient: + return "MASQUE Client" + case TypeMASQUEServer: + return "MASQUE Server" case TypeTailscale: return "Tailscale" case TypeTailcat: diff --git a/docs/configuration/endpoint/index.md b/docs/configuration/endpoint/index.md index 6e7614b05..995109b12 100644 --- a/docs/configuration/endpoint/index.md +++ b/docs/configuration/endpoint/index.md @@ -26,6 +26,8 @@ An endpoint is a protocol with inbound and outbound behavior. | `openconnect` | [OpenConnect Client](./openconnect/) | | `openvpn-client` | [OpenVPN Client](./openvpn-client/) | | `openvpn-server` | [OpenVPN Server](./openvpn-server/) | +| `masque-client` | [MASQUE Client](./masque-client/) | +| `masque-server` | [MASQUE Server](./masque-server/) | #### tag diff --git a/docs/configuration/endpoint/index.zh.md b/docs/configuration/endpoint/index.zh.md index a75bc6316..79fbcc835 100644 --- a/docs/configuration/endpoint/index.zh.md +++ b/docs/configuration/endpoint/index.zh.md @@ -26,6 +26,8 @@ | `openconnect` | [OpenConnect 客户端](./openconnect/) | | `openvpn-client` | [OpenVPN 客户端](./openvpn-client/) | | `openvpn-server` | [OpenVPN 服务器](./openvpn-server/) | +| `masque-client` | [MASQUE 客户端](./masque-client/) | +| `masque-server` | [MASQUE 服务器](./masque-server/) | #### tag diff --git a/docs/configuration/endpoint/masque-client.md b/docs/configuration/endpoint/masque-client.md new file mode 100644 index 000000000..7d3bd10b4 --- /dev/null +++ b/docs/configuration/endpoint/masque-client.md @@ -0,0 +1,145 @@ +# MASQUE Client + +!!! question "Since sing-box 1.15.0" + +`masque-client` endpoint is an IP proxying over HTTP ([RFC 9484](https://datatracker.ietf.org/doc/html/rfc9484), CONNECT-IP) client. + +## Structure + +```json +{ + "type": "masque-client", + "tag": "masque-client", + + "server": "127.0.0.1", + "server_port": 443, + "username": "", + "password": "", + "path": "", + "headers": {}, + "version": 0, + "disable_version_fallback": false, + "tls": {}, + "advertise_routes": [], + "system": false, + "name": "", + "mtu": 1280, + "on_demand": false, + + ... // HTTP2 Fields / QUIC Fields + ... // UDP NAT Fields + ... // Dial Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +## Fields + +### server + +==Required== + +The server address. + +### server_port + +==Required== + +The server port. + +### username + +Basic authorization username. + +### password + +Basic authorization password. + +### path + +URI template path of the IP proxying resource, may contain the `target` and `ipproto` variables. + +`/.well-known/masque/ip/{target}/{ipproto}/` is used by default. + +### headers + +Extra headers of HTTP request. + +### version + +HTTP version. + +Available values: `1`, `2`, `3`. + +`3` is used by default. + +When `2`, [QUIC Fields](#quic-fields) are replaced by [HTTP2 Fields](#http2-fields). + +### disable_version_fallback + +Disable automatic fallback to lower HTTP version. + +### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +Required for HTTP/3. + +### advertise_routes + +List of IP prefixes to advertise to the server. + +The server will route traffic for these prefixes into this endpoint, where it is handled as inbound traffic. + +### system + +Use system interface. + +Requires privilege and cannot conflict with existing system interfaces. + +If disabled, sing-box uses the internal network stack. + +### name + +Custom interface name for system interface. + +An automatically generated `masque` interface name is used by default. + +### mtu + +Tunnel MTU. + +`1280` will be used by default. + +### on_demand + +Allow the endpoint to be disconnected when necessary. + +## HTTP2 Fields + +When `version` is `2`. + +See [HTTP2 Fields](/configuration/shared/http2/) for details. + +`keep_alive_period` is `10s` by default. + +## QUIC Fields + +When `version` is `3` (default). + +See [QUIC Fields](/configuration/shared/quic/) for details. + +`keep_alive_period` is `10s` by default. + +`initial_packet_size` is `mtu + 51` by default, so that IP packets up to the tunnel MTU fit into a QUIC datagram. QUIC packets cannot exceed 1452 bytes; with a larger `mtu`, IP packets that do not fit are answered with ICMP Packet Too Big. If the path cannot carry packets of that size, the QUIC handshake fails and the client falls back to a lower HTTP version. + +## UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. + +## Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/endpoint/masque-client.zh.md b/docs/configuration/endpoint/masque-client.zh.md new file mode 100644 index 000000000..ecaa879ba --- /dev/null +++ b/docs/configuration/endpoint/masque-client.zh.md @@ -0,0 +1,145 @@ +# MASQUE 客户端 + +!!! question "自 sing-box 1.15.0 起" + +`masque-client` endpoint 是一个基于 HTTP 的 IP 代理([RFC 9484](https://datatracker.ietf.org/doc/html/rfc9484),CONNECT-IP)客户端。 + +## 结构 + +```json +{ + "type": "masque-client", + "tag": "masque-client", + + "server": "127.0.0.1", + "server_port": 443, + "username": "", + "password": "", + "path": "", + "headers": {}, + "version": 0, + "disable_version_fallback": false, + "tls": {}, + "advertise_routes": [], + "system": false, + "name": "", + "mtu": 1280, + "on_demand": false, + + ... // HTTP2 字段 / QUIC 字段 + ... // UDP NAT 字段 + ... // 拨号字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +## 字段 + +### server + +==必填== + +服务器地址。 + +### server_port + +==必填== + +服务器端口。 + +### username + +Basic 认证用户名。 + +### password + +Basic 认证密码。 + +### path + +IP 代理资源的 URI 模板路径,可以包含 `target` 和 `ipproto` 变量。 + +默认使用 `/.well-known/masque/ip/{target}/{ipproto}/`。 + +### headers + +HTTP 请求的额外标头。 + +### version + +HTTP 版本。 + +可用值:`1`、`2`、`3`。 + +默认使用 `3`。 + +当为 `2` 时,[QUIC 字段](#quic-字段) 替换为 [HTTP2 字段](#http2-字段)。 + +### disable_version_fallback + +禁用自动回退到更低的 HTTP 版本。 + +### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 + +HTTP/3 需要 TLS。 + +### advertise_routes + +向服务器声明的 IP 前缀列表。 + +服务器会把发往这些前缀的流量路由到此 endpoint,并在此作为入站流量处理。 + +### system + +使用系统接口。 + +需要特权且不能与已有系统接口冲突。 + +如果禁用,sing-box 将使用内部网络栈。 + +### name + +系统接口的自定义接口名称。 + +默认使用自动生成的 `masque` 接口名称。 + +### mtu + +隧道 MTU。 + +默认使用 `1280`。 + +### on_demand + +允许该 endpoint 在需要时断开连接。 + +## HTTP2 字段 + +当 `version` 为 `2` 时。 + +参阅 [HTTP2 字段](/zh/configuration/shared/http2/)。 + +`keep_alive_period` 默认为 `10s`。 + +## QUIC 字段 + +当 `version` 为 `3`(默认)时。 + +参阅 [QUIC 字段](/zh/configuration/shared/quic/)。 + +`keep_alive_period` 默认为 `10s`。 + +`initial_packet_size` 默认为 `mtu + 51`,使不超过隧道 MTU 的 IP 数据包能放入一个 QUIC 数据报。QUIC 数据包最大为 1452 字节,`mtu` 更大时,放不下的 IP 数据包会收到 ICMP Packet Too Big 回复。如果路径无法传输这个大小的数据包,QUIC 握手会失败,客户端将回退到更低的 HTTP 版本。 + +## UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + +## 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/endpoint/masque-server.md b/docs/configuration/endpoint/masque-server.md new file mode 100644 index 000000000..d00a36f52 --- /dev/null +++ b/docs/configuration/endpoint/masque-server.md @@ -0,0 +1,126 @@ +# MASQUE Server + +!!! question "Since sing-box 1.15.0" + +`masque-server` endpoint is an IP proxying over HTTP ([RFC 9484](https://datatracker.ietf.org/doc/html/rfc9484), CONNECT-IP) server. + +## Structure + +```json +{ + "type": "masque-server", + "tag": "masque-server", + + ... // Listen Fields + + "version": [], + "users": [ + { + "username": "", + "password": "" + } + ], + "tls": {}, + "path": "", + "address": [], + "advertise_routes": [], + "system": false, + "name": "", + "mtu": 1280, + + ... // HTTP2 Fields / QUIC Fields + ... // UDP NAT Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +## Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. `udp_timeout` is part of the [UDP NAT Fields](#udp-nat-fields) below. + +## Fields + +### version + +List of HTTP versions to serve. + +Available values: `1`, `2`, `3`. + +All versions are used by default. + +TLS is required for `3`. + +### users + +HTTP users, verified by the `Authorization` header. + +No authentication required if empty. + +### tls + +TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +IP proxying must be operated over TLS or QUIC. Leave it disabled only when the server is placed behind an HTTP intermediary that terminates TLS. + +### path + +URI template path of the IP proxying resource, may contain the `target` and `ipproto` variables. + +`/.well-known/masque/ip/{target}/{ipproto}/` is used by default. + +### address + +==Required== + +List of IP prefixes of the tunnel network, at most one for each IP version. + +The address of the prefix is used by the server itself, other addresses in the prefix are assigned to clients. + +### advertise_routes + +List of IP prefixes to advertise to clients, in addition to the tunnel network. + +Traffic from clients to other destinations is rejected. + +All addresses are advertised by default. + +### system + +Use system interface. + +Requires privilege and cannot conflict with existing system interfaces. + +If disabled, sing-box uses the internal network stack. + +### name + +Custom interface name for system interface. + +An automatically generated `masque` interface name is used by default. + +### mtu + +Tunnel MTU. + +`1280` will be used by default. + +## HTTP2 Fields + +When `version` contains `2`. + +See [HTTP2 Fields](/configuration/shared/http2/) for details. + +## QUIC Fields + +When `version` contains `3` (default), [HTTP2 Fields](#http2-fields) are replaced by QUIC Fields. + +See [QUIC Fields](/configuration/shared/quic/) for details. + +`initial_packet_size` is `mtu + 51` by default, so that IP packets up to the tunnel MTU fit into a QUIC datagram. QUIC packets cannot exceed 1452 bytes; with a larger `mtu`, IP packets that do not fit are answered with ICMP Packet Too Big. + +## UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/endpoint/masque-server.zh.md b/docs/configuration/endpoint/masque-server.zh.md new file mode 100644 index 000000000..16c19e851 --- /dev/null +++ b/docs/configuration/endpoint/masque-server.zh.md @@ -0,0 +1,126 @@ +# MASQUE 服务器 + +!!! question "自 sing-box 1.15.0 起" + +`masque-server` endpoint 是一个基于 HTTP 的 IP 代理([RFC 9484](https://datatracker.ietf.org/doc/html/rfc9484),CONNECT-IP)服务器。 + +## 结构 + +```json +{ + "type": "masque-server", + "tag": "masque-server", + + ... // 监听字段 + + "version": [], + "users": [ + { + "username": "", + "password": "" + } + ], + "tls": {}, + "path": "", + "address": [], + "advertise_routes": [], + "system": false, + "name": "", + "mtu": 1280, + + ... // HTTP2 字段 / QUIC 字段 + ... // UDP NAT 字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +## 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。`udp_timeout` 属于下方的 [UDP NAT 字段](#udp-nat-字段)。 + +## 字段 + +### version + +提供的 HTTP 版本列表。 + +可用值:`1`、`2`、`3`。 + +默认提供全部版本。 + +`3` 需要 TLS。 + +### users + +HTTP 用户,通过 `Authorization` 标头验证。 + +如果为空则不需要验证。 + +### tls + +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 + +IP 代理必须运行在 TLS 或 QUIC 之上。仅当服务器位于终止 TLS 的 HTTP 中间层之后时才可以不启用。 + +### path + +IP 代理资源的 URI 模板路径,可以包含 `target` 和 `ipproto` 变量。 + +默认使用 `/.well-known/masque/ip/{target}/{ipproto}/`。 + +### address + +==必填== + +隧道网络的 IP 前缀列表,每个 IP 版本最多一个。 + +前缀中的地址由服务器自己使用,前缀内的其他地址分配给客户端。 + +### advertise_routes + +除隧道网络外,向客户端声明的 IP 前缀列表。 + +客户端发往其他目的地址的流量会被拒绝。 + +默认声明全部地址。 + +### system + +使用系统接口。 + +需要特权且不能与已有系统接口冲突。 + +如果禁用,sing-box 将使用内部网络栈。 + +### name + +系统接口的自定义接口名称。 + +默认使用自动生成的 `masque` 接口名称。 + +### mtu + +隧道 MTU。 + +默认使用 `1280`。 + +## HTTP2 字段 + +当 `version` 包含 `2` 时。 + +参阅 [HTTP2 字段](/zh/configuration/shared/http2/)。 + +## QUIC 字段 + +当 `version` 包含 `3`(默认)时,[HTTP2 字段](#http2-字段) 替换为 QUIC 字段。 + +参阅 [QUIC 字段](/zh/configuration/shared/quic/)。 + +`initial_packet_size` 默认为 `mtu + 51`,使不超过隧道 MTU 的 IP 数据包能放入一个 QUIC 数据报。QUIC 数据包最大为 1452 字节,`mtu` 更大时,放不下的 IP 数据包会收到 ICMP Packet Too Big 回复。 + +## UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/docs/schema.json b/docs/schema.json index e90d9f45f..5b85f37fb 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -3405,6 +3405,433 @@ }, "Endpoint": { "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "masque-client" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "$ref": "#/$defs/IPAddress" + }, + "inet6_bind_address": { + "$ref": "#/$defs/IPAddress" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "path": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "advertise_routes": { + "type": "array", + "items": { + "$ref": "#/$defs/IPPrefix" + } + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "on_demand": { + "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": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "masque-server" + }, + "tag": { + "type": "string" + }, + "listen": { + "$ref": "#/$defs/IPAddress" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "version": { + "anyOf": [ + { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + { + "type": "array", + "items": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "path": { + "type": "string" + }, + "address": { + "anyOf": [ + { + "$ref": "#/$defs/IPPrefix" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/IPPrefix" + } + } + ] + }, + "advertise_routes": { + "type": "array", + "items": { + "$ref": "#/$defs/IPPrefix" + } + }, + "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": [ + "type" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/include/quic.go b/include/quic.go index ec4f3a6f5..42182c299 100644 --- a/include/quic.go +++ b/include/quic.go @@ -8,7 +8,6 @@ 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/include/registry.go b/include/registry.go index ac817fff2..fbb398d11 100644 --- a/include/registry.go +++ b/include/registry.go @@ -25,6 +25,7 @@ import ( "github.com/sagernet/sing-box/protocol/direct" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing-box/protocol/http" + "github.com/sagernet/sing-box/protocol/masque" "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" "github.com/sagernet/sing-box/protocol/redirect" @@ -115,6 +116,7 @@ func EndpointRegistry() *endpoint.Registry { registerWireGuardEndpoint(registry) registerOpenConnectEndpoint(registry) registerOpenVPNEndpoints(registry) + masque.RegisterEndpoint(registry) registerTailscaleEndpoint(registry) return registry diff --git a/mkdocs.yml b/mkdocs.yml index 994992ab1..ecad4edd0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -158,6 +158,8 @@ nav: - OpenConnect Client: configuration/endpoint/openconnect.md - OpenVPN Client: configuration/endpoint/openvpn-client.md - OpenVPN Server: configuration/endpoint/openvpn-server.md + - MASQUE Client: configuration/endpoint/masque-client.md + - MASQUE Server: configuration/endpoint/masque-server.md - Inbound: - configuration/inbound/index.md - Direct: configuration/inbound/direct.md diff --git a/option/masque.go b/option/masque.go new file mode 100644 index 000000000..f25ea3964 --- /dev/null +++ b/option/masque.go @@ -0,0 +1,121 @@ +package option + +import ( + "context" + "net/netip" + "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" +) + +type MASQUEEndpointOptions struct { + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` +} + +type _MASQUEClientEndpointOptions struct { + DialerOptions + ServerOptions + MASQUEEndpointOptions + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + OutboundTLSOptionsContainer + 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"` + AdvertiseRoutes []netip.Prefix `json:"advertise_routes,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + OnDemand bool `json:"on_demand,omitempty"` + HTTP2Options HTTP2Options `json:"-"` + HTTP3Options QUICOptions `json:"-"` +} + +type MASQUEClientEndpointOptions _MASQUEClientEndpointOptions + +func (o MASQUEClientEndpointOptions) ResolvedVersion() int { + if o.Version == 0 { + return 3 + } + return o.Version +} + +func (o MASQUEClientEndpointOptions) MarshalJSON() ([]byte, error) { + return badjson.MarshallObjects(_MASQUEClientEndpointOptions(o), httpVersionVariant(o.ResolvedVersion(), o.HTTP2Options, o.HTTP3Options)) +} + +func (o *MASQUEClientEndpointOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_MASQUEClientEndpointOptions)(o)) + if err != nil { + return err + } + return unmarshalHTTPVersionOptions(ctx, content, (*_MASQUEClientEndpointOptions)(o), o.ResolvedVersion(), &o.HTTP2Options, &o.HTTP3Options) +} + +func (o MASQUEClientEndpointOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[MASQUEClientEndpointOptions]()) + if err != nil { + return nil, err + } + err = builder.FlattenStruct(node, reflect.TypeFor[QUICOptions]()) + if err != nil { + return nil, err + } + return node, nil +} + +type _MASQUEServerEndpointOptions struct { + ListenOptions + MASQUEEndpointOptions + Users []auth.User `json:"users,omitempty"` + Version badoption.Listable[int] `json:"version,omitempty" enum:"1,2,3"` + InboundTLSOptionsContainer + Path string `json:"path,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address"` + AdvertiseRoutes []netip.Prefix `json:"advertise_routes,omitempty"` + HTTP2Options HTTP2Options `json:"-"` + HTTP3Options QUICOptions `json:"-"` +} + +type MASQUEServerEndpointOptions _MASQUEServerEndpointOptions + +func (o MASQUEServerEndpointOptions) Versions() []int { + if len(o.Version) > 0 { + return o.Version + } + return []int{1, 2, 3} +} + +func (o MASQUEServerEndpointOptions) MarshalJSON() ([]byte, error) { + return badjson.MarshallObjects(_MASQUEServerEndpointOptions(o), httpVersionsVariant(o.Versions(), o.HTTP2Options, o.HTTP3Options)) +} + +func (o *MASQUEServerEndpointOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_MASQUEServerEndpointOptions)(o)) + if err != nil { + return err + } + return unmarshalHTTPVersionsOptions(ctx, content, (*_MASQUEServerEndpointOptions)(o), o.Versions(), &o.HTTP2Options, &o.HTTP3Options) +} + +func (o MASQUEServerEndpointOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[MASQUEServerEndpointOptions]()) + 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 f5a0edd3b..8859ab043 100644 --- a/protocol/http/inbound.go +++ b/protocol/http/inbound.go @@ -2,9 +2,7 @@ package http import ( "context" - "io" "net" - std_http "net/http" "slices" "github.com/sagernet/sing-box/adapter" @@ -19,14 +17,9 @@ import ( "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" - - "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.HTTPInboundOptions](registry, C.TypeHTTP, NewInbound) } @@ -82,15 +75,8 @@ 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) + if tlsConfig != nil { + inbound.server.ConfigureTLS(tlsConfig) } inbound.tlsConfig = tlsConfig } @@ -133,16 +119,10 @@ func (h *Inbound) Start(stage adapter.StartStage, scope *adapter.Scope) error { } 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) + http3Server, err := h.server.ListenHTTP3(h.ctx, h.logger, h.listener, adapter.NewUpstreamHandler(metadata, h.newUserConnection, h.streamUserPacketConnection), h.tlsConfig, h.quicOptions) if err != nil { return err } diff --git a/protocol/http/outbound.go b/protocol/http/outbound.go index ef65b2e71..b5c504235 100644 --- a/protocol/http/outbound.go +++ b/protocol/http/outbound.go @@ -3,12 +3,10 @@ package http import ( "context" "net" - "slices" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/common/dialer" - "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" @@ -19,8 +17,6 @@ import ( "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" ) func RegisterOutbound(registry *outbound.Registry) { @@ -41,54 +37,12 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return nil, err } 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(), + client, err := http.NewClientWithTLS(ctx, logger, outboundDialer, options.ServerOptions, common.PtrValueOrDefault(options.TLS), http.ClientOptions{ Username: options.Username, Password: options.Password, Path: options.Path, Headers: headers, - Version: version, + Version: http.ResolveVersion(options.Version, options.Path, headers.Get("Host")), DisableVersionFallback: options.DisableVersionFallback, HTTP2Options: options.HTTP2Options, HTTP3Options: options.HTTP3Options, diff --git a/protocol/masque/client.go b/protocol/masque/client.go new file mode 100644 index 000000000..1256a4d73 --- /dev/null +++ b/protocol/masque/client.go @@ -0,0 +1,374 @@ +package masque + +import ( + "context" + "math" + "net" + "net/netip" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/iponly" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-box/transport/device" + "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing-box/transport/masque" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + 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/service" +) + +const defaultKeepAlivePeriod = 10 * time.Second + +var ( + _ adapter.OutboundWithPreferredRoutes = (*ClientEndpoint)(nil) + _ adapter.FlowOutbound = (*ClientEndpoint)(nil) + _ adapter.InterfaceUpdateListener = (*ClientEndpoint)(nil) + _ adapter.OnDemandEndpoint = (*ClientEndpoint)(nil) + _ dialer.PacketDialerWithDestination = (*ClientEndpoint)(nil) + _ masque.ClientHandler = (*ClientEndpoint)(nil) +) + +type ClientEndpoint struct { + endpointBase + ctx context.Context + dnsRouter adapter.DNSRouter + client *masque.Client + deviceOptions *device.Options + device device.Device + mtu uint32 + onDemand bool + stateAccess sync.Mutex + deviceStarted bool + state atomic.Pointer[clientState] +} + +type clientState struct { + configured bool + localAddresses []netip.Prefix + routes []masque.AddressRange +} + +func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MASQUEClientEndpointOptions) (adapter.Endpoint, error) { + if options.MTU == 0 { + options.MTU = masque.DefaultMTU + } + version := options.ResolvedVersion() + http2Options := options.HTTP2Options + if version == 3 { + http2Options = options.HTTP3Options.HTTP2Options + if options.Version == 0 && http.NewHTTP3Client == nil { + version = 2 + } + } + if http2Options.KeepAlivePeriod == 0 { + http2Options.KeepAlivePeriod = badoption.Duration(defaultKeepAlivePeriod) + } + options.HTTP3Options.HTTP2Options = http2Options + if options.HTTP3Options.InitialPacketSize == 0 { + options.HTTP3Options.InitialPacketSize = min(int(options.MTU)+masque.QUICPacketOverhead, math.MaxUint16) + } + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: options.ServerIsDomain(), + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + headers := options.Headers.Build() + authority := headers.Get("Host") + headers.Del("Host") + if authority == "" { + server := options.ServerOptions.Build() + authority = server.String() + if server.Port == 443 { + authority = server.AddrString() + if server.IsIPv6() { + authority = "[" + authority + "]" + } + } + } + httpClient, err := http.NewClientWithTLS(ctx, logger, outboundDialer, options.ServerOptions, common.PtrValueOrDefault(options.TLS), http.ClientOptions{ + Authority: authority, + Username: options.Username, + Password: options.Password, + Headers: headers, + Version: version, + DisableVersionFallback: options.DisableVersionFallback, + HTTP2Options: http2Options, + HTTP3Options: options.HTTP3Options, + }) + if err != nil { + return nil, err + } + clientEndpoint := &ClientEndpoint{ + endpointBase: endpointBase{ + Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeMASQUEClient, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + router: router, + logger: logger, + }, + ctx: ctx, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + mtu: options.MTU, + onDemand: options.OnDemand, + } + clientEndpoint.state.Store(&clientState{}) + clientEndpoint.deviceOptions = newDeviceOptions(ctx, logger, clientEndpoint, options.MASQUEEndpointOptions, time.Duration(options.UDPTimeout), nil) + clientEndpoint.client, err = masque.NewClient(masque.ClientOptions{ + Context: ctx, + Logger: logger, + HTTPClient: httpClient, + Path: options.Path, + AdvertiseRoutes: options.AdvertiseRoutes, + Handler: clientEndpoint, + }) + if err != nil { + httpClient.Close() + return nil, err + } + return clientEndpoint, nil +} + +func (c *ClientEndpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + c.deviceOptions.MemoryPressure = oomkiller.MemoryPressure(c.ctx) + tunnelDevice, err := device.New(*c.deviceOptions) + if err != nil { + return err + } + scope.Add(tunnelDevice.Close) + tunnelDevice.SetPacketWriter(c.writePacketBuffers) + c.device = tunnelDevice + c.deviceOptions = nil + scope.Add(c.client.Close) + case adapter.StartStatePostStart: + c.client.Start() + } + return nil +} + +func (c *ClientEndpoint) UpdateConfiguration(configuration masque.Configuration) error { + c.stateAccess.Lock() + defer c.stateAccess.Unlock() + err := c.device.UpdateConfiguration(device.Configuration{ + MTU: c.mtu, + Address: configuration.Address, + }) + if err != nil { + return E.Cause(err, "update device configuration") + } + if !c.deviceStarted { + err = c.device.Start() + if err != nil { + return E.Cause(err, "start device") + } + c.deviceStarted = true + } + if !slices.Equal(c.state.Load().localAddresses, configuration.Address) { + c.logger.Info("assigned ", strings.Join(common.Map(configuration.Address, netip.Prefix.String), " ")) + } + c.state.Store(&clientState{ + configured: true, + localAddresses: configuration.Address, + routes: configuration.Routes, + }) + return nil +} + +func (c *ClientEndpoint) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + if !c.state.Load().configured { + buf.ReleaseMulti(packetBuffers) + return nil + } + err := c.device.WriteInboundBuffers(packetBuffers) + buf.ReleaseMulti(packetBuffers) + return err +} + +func (c *ClientEndpoint) InterfaceUpdated(ctx context.Context) { + c.client.RestartSession() +} + +func (c *ClientEndpoint) OnDemand() bool { + return c.onDemand +} + +func (c *ClientEndpoint) SetKeepIdleConnections(keep bool) { + if !keep { + c.client.Suspend() + } +} + +func (c *ClientEndpoint) waitReady(ctx context.Context) error { + if !c.onDemand { + if !c.client.Ready() { + return E.New("endpoint is not ready yet") + } + return nil + } + c.client.Resume() + waitCtx, cancel := context.WithTimeout(ctx, C.TCPTimeout) + defer cancel() + return c.client.WaitReady(waitCtx) +} + +func (c *ClientEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { + return adapter.PreMatchFlow +} + +func (c *ClientEndpoint) PortAddresses() (netip.Addr, netip.Addr) { + return c.device.PortAddresses() +} + +func (c *ClientEndpoint) PortMTU() uint32 { + return c.device.PortMTU() +} + +func (c *ClientEndpoint) AttachReturn(returnPath tun.Return) error { + return c.device.AttachReturn(returnPath) +} + +func (c *ClientEndpoint) DetachReturn(returnPath tun.Return) error { + return c.device.DetachReturn(returnPath) +} + +func (c *ClientEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + return c.judgeFlow(c, c.state.Load().localAddresses, network, source, destination, firstPacket) +} + +func (c *ClientEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + c.newDNSPacket(log.ContextWithNewID(c.ctx), c, payload, source, destination, writer) +} + +func (c *ClientEndpoint) WritePackets(packets [][]byte) error { + if c.onDemand { + c.client.Resume() + } + if !c.client.Ready() { + return E.New("endpoint is not ready yet") + } + return c.client.WritePacketBuffers(common.Map(packets, func(packet []byte) *buf.Buffer { + packetBuffer := buf.NewSize(masque.PacketHeadroom + len(packet)) + packetBuffer.Resize(masque.PacketHeadroom, 0) + packetBuffer.Write(packet) + return packetBuffer + }), true) +} + +func (c *ClientEndpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error { + if c.onDemand { + c.client.Resume() + } + return c.client.WritePacketBuffers(packetBuffers, false) +} + +func (c *ClientEndpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + c.newConnection(ctx, c, c.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (c *ClientEndpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + c.newPacketConnection(ctx, c, c.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + c.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + c.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + err := c.waitReady(ctx) + if err != nil { + return nil, err + } + if destination.IsDomain() { + destinationAddresses, lookupErr := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if lookupErr != nil { + return nil, lookupErr + } + return N.DialSerial(ctx, c.device, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return c.device.DialContext(ctx, network, destination) +} + +func (c *ClientEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + c.logger.InfoContext(ctx, "outbound packet connection to ", destination) + err := c.waitReady(ctx) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsDomain() { + destinationAddresses, lookupErr := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if lookupErr != nil { + return nil, netip.Addr{}, lookupErr + } + packetConn, destinationAddress, listenErr := N.ListenSerial(ctx, c.device, destination, destinationAddresses) + if listenErr != nil { + return nil, netip.Addr{}, listenErr + } + return iponly.NewPacketConn(c.logger, packetConn), destinationAddress, nil + } + packetConn, err := c.device.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return iponly.NewPacketConn(c.logger, packetConn), destination.Addr, nil + } + return iponly.NewPacketConn(c.logger, packetConn), netip.Addr{}, nil +} + +func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := c.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (c *ClientEndpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { + return false +} + +func (c *ClientEndpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool { + state := c.state.Load() + if !state.configured || !c.client.Ready() { + return false + } + var protocol uint8 + switch metadata.Network { + case N.NetworkTCP: + protocol = uint8(header.TCPProtocolNumber) + case N.NetworkUDP: + protocol = uint8(header.UDPProtocolNumber) + case N.NetworkICMP: + protocol = uint8(header.ICMPv4ProtocolNumber) + } + return masque.RoutesContain(state.routes, address, protocol) +} diff --git a/protocol/masque/endpoint.go b/protocol/masque/endpoint.go new file mode 100644 index 000000000..0220e9503 --- /dev/null +++ b/protocol/masque/endpoint.go @@ -0,0 +1,122 @@ +package masque + +import ( + "context" + "net" + "net/netip" + "slices" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + 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/device" + "github.com/sagernet/sing-box/transport/masque" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.MASQUEClientEndpointOptions](registry, C.TypeMASQUEClient, NewClientEndpoint) + endpoint.Register[option.MASQUEServerEndpointOptions](registry, C.TypeMASQUEServer, NewServerEndpoint) +} + +type endpointBase struct { + endpoint.Adapter + router adapter.Router + logger log.ContextLogger +} + +func newDeviceOptions(ctx context.Context, logger log.ContextLogger, handler tun.Handler, options option.MASQUEEndpointOptions, udpTimeout time.Duration, address []netip.Prefix) *device.Options { + if udpTimeout == 0 { + udpTimeout = C.UDPTimeout + } + return &device.Options{ + Context: ctx, + Logger: logger, + System: options.System, + Handler: handler, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(), + Name: options.Name, + NamePrefix: "masque", + MTU: options.MTU, + PacketHeadroom: masque.PacketHeadroom, + Configuration: device.Configuration{ + MTU: options.MTU, + Address: address, + }, + } +} + +func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound connection from ", source) + e.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + e.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound packet connection from ", source) + e.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (e *endpointBase) newDNSPacket(ctx context.Context, endpoint adapter.Endpoint, payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + e.logger.InfoContext(ctx, "inbound DNS packet from ", source) + e.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + +func (e *endpointBase) judgeFlow(endpoint adapter.Endpoint, localAddresses []netip.Prefix, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + if isLocalAddress(localAddresses, destination.Addr()) { + return tun.FlowVerdict{Action: tun.ActionAccept} + } + return adapter.JudgeFlow(e.router, adapter.InboundContext{Inbound: endpoint.Tag(), InboundType: endpoint.Type()}, network, source, destination, firstPacket) +} + +func isLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool { + return slices.ContainsFunc(localAddresses, func(localPrefix netip.Prefix) bool { + return address == localPrefix.Addr() + }) +} + +func loopbackAddressFor(address netip.Addr) netip.Addr { + if address.Is4() { + return netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + } + return netip.IPv6Loopback() +} diff --git a/protocol/masque/server.go b/protocol/masque/server.go new file mode 100644 index 000000000..5e38f1966 --- /dev/null +++ b/protocol/masque/server.go @@ -0,0 +1,325 @@ +package masque + +import ( + "context" + "math" + "net" + "net/netip" + "slices" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/iponly" + "github.com/sagernet/sing-box/common/listener" + "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" + "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-box/transport/device" + "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing-box/transport/masque" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "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" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var ( + _ adapter.OutboundWithPreferredRoutes = (*ServerEndpoint)(nil) + _ adapter.FlowOutbound = (*ServerEndpoint)(nil) + _ adapter.ConnectionHandler = (*serverConnectionHandler)(nil) + _ dialer.PacketDialerWithDestination = (*ServerEndpoint)(nil) + _ masque.ServerHandler = (*ServerEndpoint)(nil) +) + +type ServerEndpoint struct { + endpointBase + ctx context.Context + dnsRouter adapter.DNSRouter + listener *listener.Listener + httpServer *http.Server + tlsConfig tls.ServerConfig + http3 bool + quicOptions option.QUICOptions + server *masque.Server + deviceOptions *device.Options + device device.Device + localAddresses []netip.Prefix + started atomic.Bool +} + +func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.MASQUEServerEndpointOptions) (adapter.Endpoint, error) { + if options.MTU == 0 { + options.MTU = masque.DefaultMTU + } + versions := options.Versions() + if len(options.Version) == 0 && http.ConfigureHTTP3ListenerFunc == nil { + logger.Warn("QUIC is not included in this build, HTTP/3 is disabled") + versions = []int{1, 2} + } + 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 options.HTTP3Options.InitialPacketSize == 0 { + options.HTTP3Options.InitialPacketSize = min(int(options.MTU)+masque.QUICPacketOverhead, math.MaxUint16) + } + serverEndpoint := &ServerEndpoint{ + endpointBase: endpointBase{ + Adapter: endpoint.NewAdapter(C.TypeMASQUEServer, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil), + router: router, + logger: logger, + }, + ctx: ctx, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + http3: serveHTTP3, + quicOptions: options.HTTP3Options, + localAddresses: options.Address, + } + server, err := masque.NewServer(masque.ServerOptions{ + Context: ctx, + Logger: logger, + Path: options.Path, + Address: options.Address, + AdvertiseRoutes: options.AdvertiseRoutes, + Resolve: serverEndpoint.resolve, + Handler: serverEndpoint, + }) + if err != nil { + return nil, err + } + serverEndpoint.server = server + serverEndpoint.httpServer = http.NewServer(http.ServerOptions{ + Authenticator: auth.NewAuthenticator(options.Users), + Logger: logger, + HTTP1: serveHTTP1, + HTTP2: serveHTTP2, + HTTP2Options: options.HTTP2Options, + Tunnels: map[string]http.TunnelHandler{"connect-ip": server}, + }) + if options.TLS != nil { + tlsConfig, tlsErr := tls.NewServerWithOptions(tls.ServerOptions{ + Context: ctx, + Logger: logger, + Options: common.PtrValueOrDefault(options.TLS), + KTLSCompatible: true, + }) + if tlsErr != nil { + return nil, tlsErr + } + if tlsConfig != nil { + serverEndpoint.httpServer.ConfigureTLS(tlsConfig) + } + serverEndpoint.tlsConfig = tlsConfig + } + var network []string + if serveHTTP1 || serveHTTP2 { + network = []string{N.NetworkTCP} + } + serverEndpoint.listener = listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Network: network, + Listen: options.ListenOptions, + ConnectionHandler: (*serverConnectionHandler)(serverEndpoint), + }) + serverEndpoint.deviceOptions = newDeviceOptions(ctx, logger, serverEndpoint, options.MASQUEEndpointOptions, time.Duration(options.UDPTimeout), options.Address) + return serverEndpoint, nil +} + +func (s *ServerEndpoint) resolve(ctx context.Context, domain string) ([]netip.Addr, error) { + return s.dnsRouter.Lookup(ctx, domain, adapter.DNSQueryOptions{}) +} + +func (s *ServerEndpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { + switch stage { + case adapter.StartStateInitialize: + s.deviceOptions.MemoryPressure = oomkiller.MemoryPressure(s.ctx) + tunnelDevice, err := device.New(*s.deviceOptions) + if err != nil { + return err + } + scope.Add(tunnelDevice.Close) + tunnelDevice.SetPacketWriter(s.writePacketBuffers) + s.device = tunnelDevice + s.deviceOptions = nil + scope.Add(s.server.Close) + case adapter.StartStateStart: + if s.tlsConfig != nil { + err := s.tlsConfig.Start() + if err != nil { + return E.Cause(err, "create TLS config") + } + scope.Add(s.tlsConfig.Close) + } + err := s.device.Start() + if err != nil { + return E.Cause(err, "start device") + } + err = s.listener.Start() + if err != nil { + return err + } + scope.Add(s.listener.Close) + if s.http3 { + http3Server, listenErr := s.httpServer.ListenHTTP3(s.ctx, s.logger, s.listener, nil, s.tlsConfig, s.quicOptions) + if listenErr != nil { + return listenErr + } + scope.Add(http3Server.Close) + } + s.started.Store(true) + scope.Add(func() error { + s.started.Store(false) + return nil + }) + } + return nil +} + +type serverConnectionHandler ServerEndpoint + +func (h *serverConnectionHandler) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if h.tlsConfig != nil { + tlsConn, err := tls.ServerHandshake(ctx, conn, h.tlsConfig) + if err != nil { + N.CloseOnHandshakeFailure(conn, onClose, err) + h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source, ": TLS handshake")) + return + } + conn = tlsConn + } + h.httpServer.ServeConnection(ctx, conn, http.NewReader(conn), nil, metadata.Source, onClose) +} + +func (s *ServerEndpoint) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + err := s.device.WriteInboundBuffers(packetBuffers) + buf.ReleaseMulti(packetBuffers) + return err +} + +func (s *ServerEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { + return adapter.PreMatchFlow +} + +func (s *ServerEndpoint) PortAddresses() (netip.Addr, netip.Addr) { + return s.device.PortAddresses() +} + +func (s *ServerEndpoint) PortMTU() uint32 { + return s.device.PortMTU() +} + +func (s *ServerEndpoint) AttachReturn(returnPath tun.Return) error { + return s.device.AttachReturn(returnPath) +} + +func (s *ServerEndpoint) DetachReturn(returnPath tun.Return) error { + return s.device.DetachReturn(returnPath) +} + +func (s *ServerEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + return s.judgeFlow(s, s.localAddresses, network, source, destination, firstPacket) +} + +func (s *ServerEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + s.newDNSPacket(log.ContextWithNewID(s.ctx), s, payload, source, destination, writer) +} + +func (s *ServerEndpoint) WritePackets(packets [][]byte) error { + if !s.started.Load() { + return E.New("endpoint is not ready yet") + } + return s.server.WritePacketBuffers(common.Map(packets, buf.As), true) +} + +func (s *ServerEndpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error { + return s.server.WritePacketBuffers(packetBuffers, false) +} + +func (s *ServerEndpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + s.newConnection(ctx, s, s.localAddresses, conn, source, destination, onClose) +} + +func (s *ServerEndpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + s.newPacketConnection(ctx, s, s.localAddresses, conn, source, destination, onClose) +} + +func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + s.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + s.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if !s.started.Load() { + return nil, E.New("endpoint is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, s.device, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return s.device.DialContext(ctx, network, destination) +} + +func (s *ServerEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + s.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if !s.started.Load() { + return nil, netip.Addr{}, E.New("endpoint is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + packetConn, destinationAddress, err := N.ListenSerial(ctx, s.device, destination, destinationAddresses) + if err != nil { + return nil, netip.Addr{}, err + } + return iponly.NewPacketConn(s.logger, packetConn), destinationAddress, nil + } + packetConn, err := s.device.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return iponly.NewPacketConn(s.logger, packetConn), destination.Addr, nil + } + return iponly.NewPacketConn(s.logger, packetConn), netip.Addr{}, nil +} + +func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := s.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (s *ServerEndpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { + return false +} + +func (s *ServerEndpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool { + return s.started.Load() && s.server.Contains(address) +} diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index 471bc0ad6..8864cf070 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -20,6 +20,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-box/transport/device" openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" "github.com/sagernet/sing-openconnect" "github.com/sagernet/sing-tun" @@ -49,8 +50,8 @@ type Endpoint struct { cancelLoop context.CancelFunc dnsRouter adapter.DNSRouter client *openconnect.Client - deviceOptions *openconnecttransport.DeviceOptions - device openconnecttransport.Device + deviceOptions *device.Options + device device.Device onDemand bool server string flavor string @@ -159,7 +160,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL udpTimeout = time.Duration(options.UDPTimeout) } networkManager := service.FromContext[adapter.NetworkManager](ctx) - openConnectEndpoint.deviceOptions = &openconnecttransport.DeviceOptions{ + openConnectEndpoint.deviceOptions = &device.Options{ Context: ctx, Logger: logger, System: options.System, @@ -171,8 +172,10 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL UDPNATMax: options.UDPNATMax, InterfaceFinder: networkManager.InterfaceFinder(), Name: options.Name, + NamePrefix: "oc", MTU: openconnecttransport.DefaultMTU, - Configuration: openconnecttransport.Configuration{ + PacketHeadroom: openconnecttransport.PacketHeadroom, + Configuration: device.Configuration{ MTU: openconnecttransport.DefaultMTU, }, } @@ -350,9 +353,9 @@ func (e *Endpoint) handleTunnelConfiguration(event openconnect.TunnelConfigurati if err != nil { return E.Cause(err, "build route set") } - err = e.device.UpdateConfiguration(openconnecttransport.Configuration{ - MTU: configuration.MTU, - Addresses: configuration.Addresses, + err = e.device.UpdateConfiguration(device.Configuration{ + MTU: configuration.MTU, + Address: configuration.Addresses, }) if err != nil { return E.Cause(err, "update device configuration") @@ -424,13 +427,13 @@ func (e *Endpoint) Start(stage adapter.StartStage, scope *adapter.Scope) error { return nil }) e.deviceOptions.MemoryPressure = oomkiller.MemoryPressure(e.loopContext) - device, err := openconnecttransport.NewDevice(*e.deviceOptions) + tunnelDevice, err := device.New(*e.deviceOptions) if err != nil { return err } - scope.Add(device.Close) - device.SetPacketWriter(e.writePacketBuffers) - e.device = device + scope.Add(tunnelDevice.Close) + tunnelDevice.SetPacketWriter(e.writePacketBuffers) + e.device = tunnelDevice e.deviceOptions = nil case adapter.StartStatePostStart: var loopGroup sync.WaitGroup diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index fff545173..24126cfb5 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -18,6 +18,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-box/transport/device" ovpntransport "github.com/sagernet/sing-box/transport/openvpn" ovpn "github.com/sagernet/sing-openvpn" "github.com/sagernet/sing-tun" @@ -51,8 +52,8 @@ type ClientEndpoint struct { outboundDialer N.Dialer queryOptions adapter.DNSQueryOptions client *ovpn.Client - deviceOptions *ovpntransport.DeviceOptions - device ovpntransport.Device + deviceOptions *device.Options + device device.Device onDemand bool stateAccess sync.Mutex state atomic.Pointer[clientState] @@ -122,7 +123,11 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co if options.UDPTimeout != 0 { udpTimeout = time.Duration(options.UDPTimeout) } - clientEndpoint.deviceOptions = &ovpntransport.DeviceOptions{ + deviceMTU := options.MTU + if deviceMTU == 0 { + deviceMTU = ovpntransport.DefaultMTU + } + clientEndpoint.deviceOptions = &device.Options{ Context: ctx, Logger: logger, System: options.System, @@ -134,9 +139,11 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co UDPNATMax: options.UDPNATMax, InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(), Name: options.Name, - MTU: options.MTU, - Configuration: ovpntransport.Configuration{ - MTU: options.MTU, + NamePrefix: "ovpn", + MTU: deviceMTU, + PacketHeadroom: ovpntransport.PacketHeadroom, + Configuration: device.Configuration{ + MTU: deviceMTU, Address: clientOptions.Tunnel.LocalAddress, }, } @@ -507,7 +514,7 @@ func (c *ClientEndpoint) handleTunnelConfiguration(event ovpn.TunnelConfiguratio c.updateState(func(state *clientState) { state.tunnelConfigured = false }) - deviceConfiguration := ovpntransport.Configuration{ + deviceConfiguration := device.Configuration{ MTU: configuration.MTU, Address: configuration.Address, BlockIPv6: configuration.BlockIPv6, @@ -603,13 +610,13 @@ func (c *ClientEndpoint) Start(stage adapter.StartStage, scope *adapter.Scope) e return nil }) c.deviceOptions.MemoryPressure = oomkiller.MemoryPressure(c.ctx) - device, err := ovpntransport.NewDevice(*c.deviceOptions) + tunnelDevice, err := device.New(*c.deviceOptions) if err != nil { return err } - scope.Add(device.Close) - device.SetPacketWriter(c.writePacketBuffers) - c.device = device + scope.Add(tunnelDevice.Close) + tunnelDevice.SetPacketWriter(c.writePacketBuffers) + c.device = tunnelDevice c.deviceOptions = nil case adapter.StartStatePostStart: var loopGroup sync.WaitGroup diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index 8ca813cf1..339c775d6 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -19,6 +19,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-box/transport/device" ovpntransport "github.com/sagernet/sing-box/transport/openvpn" ovpn "github.com/sagernet/sing-openvpn" "github.com/sagernet/sing-tun" @@ -43,8 +44,8 @@ type ServerEndpoint struct { dnsRouter adapter.DNSRouter listener *listener.Listener server *ovpn.Server - deviceOptions *ovpntransport.DeviceOptions - device ovpntransport.Device + deviceOptions *device.Options + device device.Device localAddresses []netip.Prefix started atomic.Bool } @@ -102,7 +103,7 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co if options.UDPTimeout != 0 { udpTimeout = time.Duration(options.UDPTimeout) } - serverEndpoint.deviceOptions = &ovpntransport.DeviceOptions{ + serverEndpoint.deviceOptions = &device.Options{ Context: ctx, Logger: logger, System: options.System, @@ -114,11 +115,12 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co UDPNATMax: options.UDPNATMax, InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(), Name: options.Name, + NamePrefix: "ovpn", MTU: options.MTU, - Configuration: ovpntransport.Configuration{ - MTU: options.MTU, - Address: options.Address, - Topology: options.Topology, + PacketHeadroom: ovpntransport.PacketHeadroom, + Configuration: device.Configuration{ + MTU: options.MTU, + Address: options.Address, }, } return serverEndpoint, nil @@ -162,13 +164,13 @@ func (s *ServerEndpoint) Start(stage adapter.StartStage, scope *adapter.Scope) e return nil }) s.deviceOptions.MemoryPressure = oomkiller.MemoryPressure(s.ctx) - device, err := ovpntransport.NewDevice(*s.deviceOptions) + tunnelDevice, err := device.New(*s.deviceOptions) if err != nil { return err } - scope.Add(device.Close) - device.SetPacketWriter(s.writePacketBuffersByDestination) - s.device = device + scope.Add(tunnelDevice.Close) + tunnelDevice.SetPacketWriter(s.writePacketBuffersByDestination) + s.device = tunnelDevice s.deviceOptions = nil return nil } @@ -696,7 +698,7 @@ func (s *ServerEndpoint) writeRouteMisses(routeMisses []*ovpn.RouteMissError) { replies := make([][]byte, 0, len(routeMisses)) for _, routeMiss := range routeMisses { sourceAddress := packetSourceAddress(routeMiss.Packet, inet4Address, inet6Address) - reply, built := tun.BuildUnreachable(routeMiss.Packet, sourceAddress, headroom) + reply, built := tun.BuildICMPError(routeMiss.Packet, tun.ICMPErrorNoRoute, sourceAddress, 0, headroom) if built { replies = append(replies, reply) } diff --git a/protocol/tailscale/port.go b/protocol/tailscale/port.go index d3bfc9577..00587c3a4 100644 --- a/protocol/tailscale/port.go +++ b/protocol/tailscale/port.go @@ -133,7 +133,7 @@ func (t *Endpoint) WritePackets(packets [][]byte) error { if header.IPVersion(packet) == header.IPv6Version { source = inet6Address } - reply, replyOk := tun.BuildUnreachable(packet, source, headroom) + reply, replyOk := tun.BuildICMPError(packet, tun.ICMPErrorNoRoute, source, 0, headroom) if replyOk { replies = append(replies, reply) } diff --git a/test/endpoint_udp_nat_test.go b/test/endpoint_udp_nat_test.go index df9a54cf9..bce65969c 100644 --- a/test/endpoint_udp_nat_test.go +++ b/test/endpoint_udp_nat_test.go @@ -8,8 +8,7 @@ import ( "testing" "time" - openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" - openvpntransport "github.com/sagernet/sing-box/transport/openvpn" + "github.com/sagernet/sing-box/transport/device" "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/gtcpip/header" "github.com/sagernet/sing/common/buf" @@ -20,13 +19,6 @@ import ( "github.com/stretchr/testify/require" ) -type endpointUDPNATDevice struct { - start func() error - writeInboundBuffers func([]*buf.Buffer) error - setPacketWriter func(func([]*buf.Buffer) error) - close func() error -} - type endpointUDPNATPacket struct { session *endpointUDPNATSession destination M.Socksaddr @@ -87,78 +79,30 @@ func (h *endpointUDPNATHandler) NewPacketConnectionEx(_ context.Context, conn N. }() } -func TestOpenVPNEndpointUDPNATDataPlane(t *testing.T) { - testEndpointUDPNATDataPlane(t, func(ctx context.Context, handler tun.Handler) (endpointUDPNATDevice, error) { - device, err := openvpntransport.NewDevice(openvpntransport.DeviceOptions{ - Context: ctx, - Logger: logger.NOP(), - Handler: handler, - UDPTimeout: time.Minute, - UDPMapping: tun.NATMappingAddressAndPortDependent, - UDPFiltering: tun.NATFilteringAddressAndPortDependent, - UDPNATMax: 1, - MTU: 1500, - Configuration: openvpntransport.Configuration{ - MTU: 1500, - Address: []netip.Prefix{netip.MustParsePrefix("10.8.0.1/24")}, - }, - }) - if err != nil { - return endpointUDPNATDevice{}, err - } - return endpointUDPNATDevice{ - start: device.Start, - writeInboundBuffers: device.WriteInboundBuffers, - setPacketWriter: func(writer func([]*buf.Buffer) error) { - device.SetPacketWriter(openvpntransport.PacketWriter(writer)) - }, - close: device.Close, - }, nil - }) -} - -func TestOpenConnectEndpointUDPNATDataPlane(t *testing.T) { - testEndpointUDPNATDataPlane(t, func(ctx context.Context, handler tun.Handler) (endpointUDPNATDevice, error) { - device, err := openconnecttransport.NewDevice(openconnecttransport.DeviceOptions{ - Context: ctx, - Logger: logger.NOP(), - Handler: handler, - UDPTimeout: time.Minute, - UDPMapping: tun.NATMappingAddressAndPortDependent, - UDPFiltering: tun.NATFilteringAddressAndPortDependent, - UDPNATMax: 1, - MTU: 1500, - Configuration: openconnecttransport.Configuration{ - MTU: 1500, - Addresses: []netip.Prefix{netip.MustParsePrefix("10.8.0.1/24")}, - }, - }) - if err != nil { - return endpointUDPNATDevice{}, err - } - return endpointUDPNATDevice{ - start: device.Start, - writeInboundBuffers: device.WriteInboundBuffers, - setPacketWriter: func(writer func([]*buf.Buffer) error) { - device.SetPacketWriter(openconnecttransport.PacketWriter(writer)) - }, - close: device.Close, - }, nil - }) -} - -func testEndpointUDPNATDataPlane(t *testing.T, newDevice func(context.Context, tun.Handler) (endpointUDPNATDevice, error)) { - t.Helper() +func TestEndpointDeviceUDPNATDataPlane(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() handler := &endpointUDPNATHandler{packets: make(chan endpointUDPNATPacket, 4)} - device, err := newDevice(ctx, handler) + tunnelDevice, err := device.New(device.Options{ + Context: ctx, + Logger: logger.NOP(), + Handler: handler, + UDPTimeout: time.Minute, + UDPMapping: tun.NATMappingAddressAndPortDependent, + UDPFiltering: tun.NATFilteringAddressAndPortDependent, + UDPNATMax: 1, + MTU: 1500, + Configuration: device.Configuration{ + MTU: 1500, + Address: []netip.Prefix{netip.MustParsePrefix("10.8.0.1/24")}, + }, + }) require.NoError(t, err) t.Cleanup(func() { - require.NoError(t, device.close()) + require.NoError(t, tunnelDevice.Close()) }) outboundPackets := make(chan []byte, 4) - device.setPacketWriter(func(packetBuffers []*buf.Buffer) error { + tunnelDevice.SetPacketWriter(func(packetBuffers []*buf.Buffer) error { for _, packetBuffer := range packetBuffers { payload, isUDP := endpointUDPPayload(packetBuffer.Bytes()) packetBuffer.Release() @@ -172,9 +116,9 @@ func testEndpointUDPNATDataPlane(t *testing.T, newDevice func(context.Context, t source := netip.MustParseAddrPort("10.8.0.2:40000") firstDestination := netip.MustParseAddrPort("192.0.2.1:5001") secondDestination := netip.MustParseAddrPort("192.0.2.1:5002") - writeEndpointUDPPacket(t, device, source, firstDestination, []byte("before-start")) - require.NoError(t, device.start()) - writeEndpointUDPPacket(t, device, source, firstDestination, []byte("request-one")) + writeEndpointUDPPacket(t, tunnelDevice, source, firstDestination, []byte("before-start")) + require.NoError(t, tunnelDevice.Start()) + writeEndpointUDPPacket(t, tunnelDevice, source, firstDestination, []byte("request-one")) firstPacket := waitEndpointUDPNATPacket(t, handler.packets) require.Equal(t, M.SocksaddrFromNetIP(firstDestination), firstPacket.destination) require.Equal(t, []byte("request-one"), firstPacket.payload) @@ -183,7 +127,7 @@ func testEndpointUDPNATDataPlane(t *testing.T, newDevice func(context.Context, t require.NoError(t, firstPacket.session.conn.WritePacket(endpointUDPPacketBuffer(firstPacket.session.conn, "allowed-one"), M.SocksaddrFromNetIP(firstDestination))) require.Equal(t, []byte("allowed-one"), waitEndpointUDPResponse(t, outboundPackets)) - writeEndpointUDPPacket(t, device, source, secondDestination, []byte("request-two")) + writeEndpointUDPPacket(t, tunnelDevice, source, secondDestination, []byte("request-two")) secondPacket := waitEndpointUDPNATPacket(t, handler.packets) require.Equal(t, M.SocksaddrFromNetIP(secondDestination), secondPacket.destination) require.Equal(t, []byte("request-two"), secondPacket.payload) @@ -198,7 +142,7 @@ func testEndpointUDPNATDataPlane(t *testing.T, newDevice func(context.Context, t require.Equal(t, []byte("allowed-two"), waitEndpointUDPResponse(t, outboundPackets)) } -func writeEndpointUDPPacket(t *testing.T, device endpointUDPNATDevice, source netip.AddrPort, destination netip.AddrPort, payload []byte) { +func writeEndpointUDPPacket(t *testing.T, tunnelDevice device.Device, source netip.AddrPort, destination netip.AddrPort, payload []byte) { t.Helper() packet := make([]byte, header.IPv4MinimumSize+header.UDPMinimumSize+len(payload)) ipHeader := header.IPv4(packet) @@ -218,7 +162,7 @@ func writeEndpointUDPPacket(t *testing.T, device endpointUDPNATDevice, source ne }) copy(udpHeader.Payload(), payload) packetBuffer := buf.As(packet) - require.NoError(t, device.writeInboundBuffers([]*buf.Buffer{packetBuffer})) + require.NoError(t, tunnelDevice.WriteInboundBuffers([]*buf.Buffer{packetBuffer})) packetBuffer.Release() } diff --git a/test/masque_test.go b/test/masque_test.go new file mode 100644 index 000000000..8e448fa67 --- /dev/null +++ b/test/masque_test.go @@ -0,0 +1,287 @@ +package main + +import ( + std_bufio "bufio" + "context" + "crypto/tls" + "encoding/base64" + "io" + "net" + "net/http" + "net/netip" + "strconv" + "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" + 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" +) + +const ( + masqueServerAddress = "10.8.0.1" + masqueSiteAddress = "10.9.0.5" +) + +type masqueEnvironment struct { + serverPort uint16 + serverProxyPort uint16 + clientProxyPort uint16 +} + +func startMASQUE(t *testing.T, serverVersions []int, clientVersion int, disableVersionFallback bool, mtu uint32, serverRoutes []netip.Prefix, clientRoutes []netip.Prefix) masqueEnvironment { + t.Helper() + environment := masqueEnvironment{ + serverProxyPort: reserveOpenVPNTCPPort(t), + clientProxyPort: reserveOpenVPNTCPPort(t), + } + masquePort := reserveOpenVPNEchoPort(t) + environment.serverPort = masquePort + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + users := []auth.User{{Username: "sekai", Password: "password"}} + startInstance(t, masqueInstanceOptions(C.TypeMASQUEServer, &option.MASQUEServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: masquePort, + }, + MASQUEEndpointOptions: option.MASQUEEndpointOptions{MTU: mtu}, + Users: users, + Version: serverVersions, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + Address: []netip.Prefix{netip.MustParsePrefix(masqueServerAddress + "/24")}, + AdvertiseRoutes: serverRoutes, + }, environment.serverProxyPort, "")) + startInstance(t, masqueInstanceOptions(C.TypeMASQUEClient, &option.MASQUEClientEndpointOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: masquePort, + }, + MASQUEEndpointOptions: option.MASQUEEndpointOptions{MTU: mtu}, + Username: users[0].Username, + Password: users[0].Password, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + CertificatePath: certPem, + }, + }, + Version: clientVersion, + DisableVersionFallback: disableVersionFallback, + AdvertiseRoutes: clientRoutes, + }, environment.clientProxyPort, "127.0.0.1")) + waitForOpenVPNClientReady(t, environment.clientProxyPort, reserveOpenVPNEchoPort(t), masqueServerAddress) + return environment +} + +func masqueInstanceOptions(endpointType string, endpointOptions any, proxyPort uint16, overrideAddress string) option.Options { + return option.Options{ + Endpoints: []option.Endpoint{ + { + Type: endpointType, + Tag: "masque", + Options: endpointOptions, + }, + }, + Inbounds: []option.Inbound{ + { + Type: C.TypeSOCKS, + Tag: "socks-in", + Options: &option.SocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: proxyPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + Tag: "direct", + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"socks-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "masque", + }, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"masque"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "direct", + RawRouteOptionsActionOptions: option.RawRouteOptionsActionOptions{ + OverrideAddress: overrideAddress, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestMASQUESelfToSelf(t *testing.T) { + for _, testCase := range []struct { + name string + version int + }{ + {"HTTP3", 3}, + {"HTTP2", 2}, + {"HTTP1", 1}, + } { + t.Run(testCase.name, func(t *testing.T) { + environment := startMASQUE(t, nil, testCase.version, true, 0, nil, nil) + testSuitOpenVPN(t, environment.clientProxyPort, reserveOpenVPNEchoPort(t), masqueServerAddress) + }) + } +} + +func TestMASQUEVersionFallback(t *testing.T) { + environment := startMASQUE(t, []int{1, 2}, 0, false, 0, nil, nil) + testSuitOpenVPN(t, environment.clientProxyPort, reserveOpenVPNEchoPort(t), masqueServerAddress) +} + +func TestMASQUEAdvertiseRoutes(t *testing.T) { + environment := startMASQUE(t, nil, 0, false, 0, nil, []netip.Prefix{netip.MustParsePrefix("10.9.0.0/24")}) + readinessPort := reserveOpenVPNEchoPort(t) + closeEcho := startOpenVPNReadinessEcho(t, readinessPort) + waitForOpenVPNRemoteReady(t, environment.serverProxyPort, masqueSiteAddress, readinessPort, 30*time.Second) + closeEcho() + testSuitOpenVPN(t, environment.serverProxyPort, reserveOpenVPNEchoPort(t), masqueSiteAddress) +} + +func TestMASQUENoRoute(t *testing.T) { + environment := startMASQUE(t, nil, 0, false, 0, []netip.Prefix{netip.MustParsePrefix("10.9.0.0/24")}, nil) + for _, proxyPort := range []uint16{environment.clientProxyPort, environment.serverProxyPort} { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", proxyPort), socks.Version5, "", "") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + conn, err := dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddrHostPort("10.10.0.1", 80)) + if err == nil { + conn.Close() + } + require.Error(t, err) + require.NoError(t, ctx.Err()) + cancel() + } +} + +func TestMASQUEPacketTooBig(t *testing.T) { + environment := startMASQUE(t, nil, 3, true, 1500, nil, nil) + testSuitOpenVPN(t, environment.clientProxyPort, reserveOpenVPNEchoPort(t), masqueServerAddress) +} + +func TestMASQUEReconnectBackoff(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + var accepted atomic.Int32 + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted.Add(1) + conn.Close() + } + }() + t.Cleanup(func() { + listener.Close() + }) + startInstance(t, masqueInstanceOptions(C.TypeMASQUEClient, &option.MASQUEClientEndpointOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: uint16(listener.Addr().(*net.TCPAddr).Port), + }, + Version: 1, + }, reserveOpenVPNTCPPort(t), "")) + time.Sleep(2500 * time.Millisecond) + require.Positive(t, accepted.Load()) + require.LessOrEqual(t, accepted.Load(), int32(3)) +} + +func TestMASQUEStuckClient(t *testing.T) { + environment := startMASQUE(t, nil, 0, false, 0, nil, nil) + stuckConn, err := tls.Dial("tcp", "127.0.0.1:"+strconv.Itoa(int(environment.serverPort)), &tls.Config{ + ServerName: "example.org", + InsecureSkipVerify: true, + NextProtos: []string{"http/1.1"}, + }) + require.NoError(t, err) + t.Cleanup(func() { + stuckConn.Close() + }) + _, err = stuckConn.Write([]byte("GET /.well-known/masque/ip/*/*/ HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\nUpgrade: connect-ip\r\nCapsule-Protocol: ?1\r\nAuthorization: Basic " + base64.StdEncoding.EncodeToString([]byte("sekai:password")) + "\r\n\r\n")) + require.NoError(t, err) + reader := std_bufio.NewReader(stuckConn) + response, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, response.StatusCode) + require.Equal(t, uint64(1), readVarint(t, reader)) + capsuleLength := readVarint(t, reader) + capsule := make([]byte, capsuleLength) + _, err = io.ReadFull(reader, capsule) + require.NoError(t, err) + require.Equal(t, byte(4), capsule[1]) + stuckAddress := netip.AddrFrom4([4]byte(capsule[2:6])) + + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", environment.serverProxyPort), socks.Version5, "", "") + floodConn, err := dialer.DialContext(context.Background(), N.NetworkUDP, M.SocksaddrFrom(stuckAddress, 9)) + require.NoError(t, err) + t.Cleanup(func() { + floodConn.Close() + }) + floodDone := make(chan struct{}) + go func() { + defer close(floodDone) + payload := make([]byte, 1200) + for range 4096 { + _, writeErr := floodConn.Write(payload) + if writeErr != nil { + return + } + } + }() + select { + case <-floodDone: + case <-time.After(10 * time.Second): + } + + echoPort := reserveOpenVPNEchoPort(t) + closeEcho := startOpenVPNReadinessEcho(t, echoPort) + defer closeEcho() + require.NoError(t, probeOpenVPNTCPWithTimeout(environment.clientProxyPort, masqueServerAddress, echoPort, 5*time.Second)) +} diff --git a/transport/openvpn/device.go b/transport/device/device.go similarity index 81% rename from transport/openvpn/device.go rename to transport/device/device.go index 7d251a516..fd6b7ac1d 100644 --- a/transport/openvpn/device.go +++ b/transport/device/device.go @@ -1,4 +1,4 @@ -package openvpn +package device import ( "context" @@ -14,11 +14,6 @@ import ( N "github.com/sagernet/sing/common/network" ) -const ( - DefaultMTU = 1500 - PacketHeadroom = 4096 -) - type PacketWriter func(packetBuffers []*buf.Buffer) error type Device interface { @@ -35,7 +30,7 @@ type Device interface { Close() error } -type DeviceOptions struct { +type Options struct { Context context.Context Logger logger.ContextLogger System bool @@ -48,47 +43,26 @@ type DeviceOptions struct { InterfaceFinder control.InterfaceFinder MemoryPressure func() tun.MemoryPressure Name string + NamePrefix string MTU uint32 + PacketHeadroom int Configuration Configuration } type Configuration struct { - MTU uint32 - Address []netip.Prefix - Routes []Route - ExcludedRoutes []Route - DNS []netip.Addr - DNSServers []DNSServer - SearchDomains []string - DNSRoutes []string - Topology string - Interface string - BlockIPv6 bool + MTU uint32 + Address []netip.Prefix + BlockIPv6 bool } -type Route struct { - Prefix netip.Prefix - Gateway netip.Addr - Metric int -} - -type DNSServer struct { - Priority int - Addresses []netip.AddrPort - ResolveDomains []string - DNSSEC string - Transport string - SNI string -} - -func NewDevice(options DeviceOptions) (Device, error) { +func New(options Options) (Device, error) { if !options.System { return newStackDevice(options) } return newSystemStackDevice(options) } -func newStack(options DeviceOptions, memoryTun *tun.MemoryTun) (*tun.Go, error) { +func newStack(options Options, memoryTun *tun.MemoryTun) (*tun.Go, error) { return tun.NewGo(tun.StackOptions{ Context: options.Context, Tun: memoryTun, @@ -106,8 +80,9 @@ func newStack(options DeviceOptions, memoryTun *tun.MemoryTun) (*tun.Go, error) } type baseDevice struct { - packetWriter PacketWriter - returnState atomic.Pointer[returnPathState] + packetHeadroom int + packetWriter PacketWriter + returnState atomic.Pointer[returnPathState] } func (d *baseDevice) SetPacketWriter(writer PacketWriter) { @@ -150,8 +125,8 @@ func (d *baseDevice) processInboundBuffers(packetBuffers []*buf.Buffer, writeBuf func (d *baseDevice) AttachReturn(returnPath tun.Return) error { headroom := returnPath.ReturnHeadroom() - if headroom > PacketHeadroom { - return E.New("return path headroom ", headroom, " exceeds available ", PacketHeadroom) + if headroom > d.packetHeadroom { + return E.New("return path headroom ", headroom, " exceeds available ", d.packetHeadroom) } newState := &returnPathState{ returnPath: returnPath, @@ -217,12 +192,3 @@ func splitPrefixes(prefixes []netip.Prefix) ([]netip.Prefix, []netip.Prefix) { } return inet4Prefixes, inet6Prefixes } - -func hasRouteOptions(routes []Route) bool { - for _, route := range routes { - if route.Gateway.IsValid() || route.Metric != 0 { - return true - } - } - return false -} diff --git a/transport/openconnect/device_stack.go b/transport/device/stack.go similarity index 91% rename from transport/openconnect/device_stack.go rename to transport/device/stack.go index 05d8de820..1f86d771d 100644 --- a/transport/openconnect/device_stack.go +++ b/transport/device/stack.go @@ -1,4 +1,4 @@ -package openconnect +package device import ( "context" @@ -19,7 +19,7 @@ var _ Device = (*stackDevice)(nil) type stackDevice struct { baseDevice stateAccess sync.RWMutex - options DeviceOptions + options Options stack *tun.Go memoryTun *tun.MemoryTun inet4Address netip.Addr @@ -27,17 +27,15 @@ type stackDevice struct { closeOnce sync.Once } -func newStackDevice(options DeviceOptions) (*stackDevice, error) { - if options.MTU == 0 { - options.MTU = DefaultMTU - } +func newStackDevice(options Options) (*stackDevice, error) { device := &stackDevice{ - options: options, + baseDevice: baseDevice{packetHeadroom: options.PacketHeadroom}, + options: options, } - device.inet4Address, device.inet6Address = firstAddresses(options.Configuration.Addresses) + device.inet4Address, device.inet6Address = firstAddresses(options.Configuration.Address) device.memoryTun = tun.NewMemoryTun(tun.MemoryTunOptions{ MTU: int(options.MTU), - Headroom: PacketHeadroom, + Headroom: options.PacketHeadroom, RearSpace: systemDevicePacketRearSpace, Outbound: device.writeOutbound, }) @@ -61,7 +59,7 @@ func (d *stackDevice) UpdateConfiguration(configuration Configuration) error { d.memoryTun.UpdateMTU(int(configuration.MTU)) } d.options.Configuration = configuration - d.inet4Address, d.inet6Address = firstAddresses(configuration.Addresses) + d.inet4Address, d.inet6Address = firstAddresses(configuration.Address) return nil } @@ -120,6 +118,9 @@ func (d *stackDevice) bindAddress(destination M.Socksaddr) (netip.Addr, error) { } return d.inet4Address, nil } + if d.options.Configuration.BlockIPv6 { + return netip.Addr{}, E.New("IPv6 is blocked") + } if !d.inet6Address.IsValid() { return netip.Addr{}, E.New("missing IPv6 local address") } diff --git a/transport/openvpn/device_system.go b/transport/device/system.go similarity index 95% rename from transport/openvpn/device_system.go rename to transport/device/system.go index 93421ee38..30e03ecc2 100644 --- a/transport/openvpn/device_system.go +++ b/transport/device/system.go @@ -1,4 +1,4 @@ -package openvpn +package device import ( "context" @@ -30,7 +30,7 @@ const ( type systemDevice struct { baseDevice stateAccess sync.RWMutex - options DeviceOptions + options Options dialer N.Dialer device tun.Tun inet4Address netip.Addr @@ -38,12 +38,9 @@ type systemDevice struct { closed bool } -func newSystemDevice(options DeviceOptions) (*systemDevice, error) { +func newSystemDevice(options Options) (*systemDevice, error) { if options.Name == "" { - options.Name = tun.CalculateInterfaceName("ovpn") - } - if options.MTU == 0 { - options.MTU = DefaultMTU + options.Name = tun.CalculateInterfaceName(options.NamePrefix) } interfaceDialer, err := dialer.NewDefault(options.Context, option.DialerOptions{ AbstractDialerOptions: option.AbstractDialerOptions{ @@ -55,6 +52,7 @@ func newSystemDevice(options DeviceOptions) (*systemDevice, error) { } inet4Address, inet6Address := firstAddresses(options.Configuration.Address) return &systemDevice{ + baseDevice: baseDevice{packetHeadroom: options.PacketHeadroom}, options: options, dialer: interfaceDialer, inet4Address: inet4Address, @@ -128,11 +126,11 @@ func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { d.readLoopDarwin(darwinTUN) return } - packetBuffer := buf.NewSize(PacketHeadroom + systemDeviceReadBufferSize + systemDevicePacketRearSpace) + packetBuffer := buf.NewSize(d.packetHeadroom + systemDeviceReadBufferSize + systemDevicePacketRearSpace) defer packetBuffer.Release() for { packetBuffer.Reset() - packetBuffer.Resize(PacketHeadroom, 0) + packetBuffer.Resize(d.packetHeadroom, 0) readN, err := tunInterface.Read(packetBuffer.FreeBytes()[:systemDeviceReadBufferSize]) if err != nil { if E.IsClosed(err) { @@ -164,13 +162,13 @@ func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, m packetSizes := make([]int, batchSize) outboundBuffers := make([]*buf.Buffer, 0, batchSize) for i := range packetBuffers { - packetBuffers[i] = buf.NewSize(PacketHeadroom + mtu + systemDevicePacketRearSpace) + packetBuffers[i] = buf.NewSize(d.packetHeadroom + mtu + systemDevicePacketRearSpace) } defer buf.ReleaseMulti(packetBuffers) for { for i, packetBuffer := range packetBuffers { packetBuffer.Reset() - packetBuffer.Resize(PacketHeadroom, 0) + packetBuffer.Resize(d.packetHeadroom, 0) readBuffers[i] = packetBuffer.FreeBytes()[:mtu] } packetCount, readErr := tunInterface.BatchRead(readBuffers, 0, packetSizes) diff --git a/transport/openconnect/device_system_stack.go b/transport/device/system_stack.go similarity index 96% rename from transport/openconnect/device_system_stack.go rename to transport/device/system_stack.go index 3deafb399..6d148e868 100644 --- a/transport/openconnect/device_system_stack.go +++ b/transport/device/system_stack.go @@ -1,4 +1,4 @@ -package openconnect +package device import ( "net/netip" @@ -15,7 +15,7 @@ type systemStackDevice struct { stackDevice *stackDevice } -func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { +func newSystemStackDevice(options Options) (*systemStackDevice, error) { system, err := newSystemDevice(options) if err != nil { return nil, err diff --git a/transport/http/capsule.go b/transport/http/capsule.go index 60b247862..a2121e9d6 100644 --- a/transport/http/capsule.go +++ b/transport/http/capsule.go @@ -14,17 +14,18 @@ import ( "github.com/sagernet/sing/common/buf" 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" ) const ( - capsuleTypeDatagram = 0 - capsuleHeadroom = 1 + 4 + 1 - maxCapsuleLength = 1 << 20 + CapsuleTypeDatagram = 0 + CapsuleHeadroom = 1 + 4 + 1 + MaxCapsuleLength = 1 << 20 ) -func readVarint(reader *std_bufio.Reader) (uint64, int, error) { +func ReadVarint(reader *std_bufio.Reader) (uint64, int, error) { first, err := reader.ReadByte() if err != nil { return 0, 0, err @@ -42,7 +43,7 @@ func readVarint(reader *std_bufio.Reader) (uint64, int, error) { return value, length, nil } -func decodeVarint(b []byte) (uint64, int, bool) { +func DecodeVarint(b []byte) (uint64, int, bool) { if len(b) == 0 { return 0, 0, false } @@ -57,7 +58,7 @@ func decodeVarint(b []byte) (uint64, int, bool) { return value, length, true } -func varintLen(value uint64) int { +func VarintLen(value uint64) int { switch { case value < 1<<6: return 1 @@ -70,8 +71,8 @@ func varintLen(value uint64) int { } } -func putVarint(b []byte, value uint64) int { - switch varintLen(value) { +func PutVarint(b []byte, value uint64) int { + switch VarintLen(value) { case 1: b[0] = byte(value) return 1 @@ -89,25 +90,25 @@ func putVarint(b []byte, value uint64) int { func readDatagramCapsule(reader *std_bufio.Reader, buffer *buf.Buffer) error { for { - capsuleType, _, err := readVarint(reader) + capsuleType, _, err := ReadVarint(reader) if err != nil { return err } - length, _, err := readVarint(reader) + length, _, err := ReadVarint(reader) if err != nil { return err } - if length > maxCapsuleLength { + if length > MaxCapsuleLength { return E.New("capsule too large: ", length) } - if capsuleType != capsuleTypeDatagram { + if capsuleType != CapsuleTypeDatagram { _, err = reader.Discard(int(length)) if err != nil { return err } continue } - contextID, contextLength, err := readVarint(reader) + contextID, contextLength, err := ReadVarint(reader) if err != nil { return err } @@ -127,7 +128,7 @@ func readDatagramCapsule(reader *std_bufio.Reader, buffer *buf.Buffer) error { } } -func prependContextID(buffer *buf.Buffer) *buf.Buffer { +func PrependContextID(buffer *buf.Buffer) *buf.Buffer { if buffer.Start() >= 1 { buffer.ExtendHeader(1)[0] = 0 return buffer @@ -139,20 +140,20 @@ func prependContextID(buffer *buf.Buffer) *buf.Buffer { return datagram } -func writeDatagramCapsule(writer io.Writer, datagram *buf.Buffer) error { +func WriteDatagramCapsule(writer io.Writer, datagram *buf.Buffer) error { length := uint64(datagram.Len()) - headerLength := 1 + varintLen(length) + headerLength := 1 + VarintLen(length) var capsule *buf.Buffer if datagram.Start() >= headerLength { header := datagram.ExtendHeader(headerLength) - header[0] = capsuleTypeDatagram - putVarint(header[1:], length) + 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) + header[0] = CapsuleTypeDatagram + PutVarint(header[1:], length) capsule.Write(datagram.Bytes()) datagram.Release() } @@ -189,7 +190,7 @@ func (c *capsuleConn) ReadPacket(buffer *buf.Buffer) (M.Socksaddr, error) { func (c *capsuleConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { c.writeAccess.Lock() defer c.writeAccess.Unlock() - return writeDatagramCapsule(c.writer, prependContextID(buffer)) + return WriteDatagramCapsule(c.writer, PrependContextID(buffer)) } func (c *capsuleConn) Close() error { @@ -217,7 +218,7 @@ func (c *capsuleConn) NeedAdditionalReadDeadline() bool { } func (c *capsuleConn) FrontHeadroom() int { - return capsuleHeadroom + return CapsuleHeadroom } func (c *capsuleConn) Upstream() any { @@ -237,6 +238,18 @@ var ( ErrDatagramUnsupported = E.New("datagram unsupported") ) +type DatagramTooLargeError struct { + MaxPayloadSize int +} + +func (e *DatagramTooLargeError) Error() string { + return F.ToString("datagram too large, maximum payload size is ", e.MaxPayloadSize) +} + +func (e *DatagramTooLargeError) Unwrap() error { + return ErrDatagramUnsupported +} + type http3PacketConn struct { stream DatagramStream reader *std_bufio.Reader @@ -276,7 +289,7 @@ func (c *http3PacketConn) loopDatagram() { c.closeWithError(err) return } - contextID, contextLength, valid := decodeVarint(datagram) + contextID, contextLength, valid := DecodeVarint(datagram) if !valid || contextID != 0 { continue } @@ -328,7 +341,7 @@ func (c *http3PacketConn) ReadPacket(buffer *buf.Buffer) (M.Socksaddr, error) { } func (c *http3PacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error { - datagram := prependContextID(buffer) + datagram := PrependContextID(buffer) err := c.stream.SendDatagram(datagram.Bytes()) if err == nil { datagram.Release() @@ -340,7 +353,7 @@ func (c *http3PacketConn) WritePacket(buffer *buf.Buffer, destination M.Socksadd } c.writeAccess.Lock() defer c.writeAccess.Unlock() - return writeDatagramCapsule(c.stream, datagram) + return WriteDatagramCapsule(c.stream, datagram) } func (c *http3PacketConn) closeWithError(err error) { @@ -396,7 +409,7 @@ func (c *http3PacketConn) NeedAdditionalReadDeadline() bool { } func (c *http3PacketConn) FrontHeadroom() int { - return capsuleHeadroom + return CapsuleHeadroom } var _ N.PacketConn = (*http3PacketConn)(nil) diff --git a/transport/http/client.go b/transport/http/client.go index 24720a312..06526c49b 100644 --- a/transport/http/client.go +++ b/transport/http/client.go @@ -10,18 +10,21 @@ import ( "net/http" "net/url" "os" + "slices" "sync" "sync/atomic" "time" "github.com/sagernet/sing-box/common/badhttp" "github.com/sagernet/sing-box/common/httpclient" + "github.com/sagernet/sing-box/common/tls" 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" + "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" @@ -39,6 +42,7 @@ type ClientOptions struct { RawDialer N.Dialer TLSConfig aTLS.Config Server M.Socksaddr + Authority string Username string Password string Path string @@ -51,7 +55,7 @@ type ClientOptions struct { type http3Client interface { DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error) - ListenPacket(ctx context.Context, destination M.Socksaddr) (N.PacketConn, error) + OpenTunnel(ctx context.Context, request tunnelRequest) (DatagramStream, error) ResetConnection() Close() error } @@ -59,24 +63,64 @@ type http3Client interface { 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 + dialer N.Dialer + http1Dialer N.Dialer + tlsDialer tlsDialer + server M.Socksaddr + authorityOverride string + authorization string + host string + path string + headers http.Header + version int + disableVersionFallback bool + http2Transport *http2.Transport + http2Access sync.Mutex + http2Conns []*http2ClientConn + http2Unsupported atomic.Bool + http2ExtendedConnectUnsupported atomic.Bool + http3 http3Client + http3Broken atomic.Int64 + http3Backoff atomic.Int64 +} + +func NewClientWithTLS(ctx context.Context, logger logger.ContextLogger, outboundDialer N.Dialer, serverOptions option.ServerOptions, tlsOptions option.OutboundTLSOptions, options ClientOptions) (*Client, error) { + if options.Version == 3 && !tlsOptions.Enabled { + return nil, C.ErrTLSRequired + } + alpnIsDefault := tlsOptions.Enabled && len(tlsOptions.ALPN) == 0 + if alpnIsDefault { + if options.Version == 1 { + tlsOptions.ALPN = []string{"http/1.1"} + } else { + tlsOptions.ALPN = []string{http2.NextProtoTLS, "http/1.1"} + } + } + var err error + options.Dialer, err = tls.NewDialerFromOptions(ctx, logger, outboundDialer, serverOptions.Server, tlsOptions) + if err != nil { + return nil, err + } + if options.Version >= 2 && (alpnIsDefault || slices.Contains(tlsOptions.ALPN, "http/1.1")) { + http1TLSOptions := tlsOptions + http1TLSOptions.ALPN = []string{"http/1.1"} + options.HTTP1Dialer, err = tls.NewDialerFromOptions(ctx, logger, outboundDialer, serverOptions.Server, http1TLSOptions) + if err != nil { + return nil, err + } + } + if options.Version == 3 { + if alpnIsDefault { + tlsOptions.ALPN = []string{"h3"} + } + options.TLSConfig, err = tls.NewClient(ctx, logger, serverOptions.Server, tlsOptions) + if err != nil { + return nil, err + } + } + options.RawDialer = outboundDialer + options.Server = serverOptions.Build() + return NewClient(options) } func NewClient(options ClientOptions) (*Client, error) { @@ -84,6 +128,7 @@ func NewClient(options ClientOptions) (*Client, error) { dialer: options.Dialer, http1Dialer: options.HTTP1Dialer, server: options.Server, + authorityOverride: options.Authority, path: options.Path, headers: options.Headers.Clone(), version: options.Version, @@ -223,7 +268,7 @@ func (c *Client) ResetConnections() { c.closeHTTP2Locked() c.http2Access.Unlock() c.http2Unsupported.Store(false) - c.http2ConnectUDPUnsupported.Store(false) + c.http2ExtendedConnectUnsupported.Store(false) if c.http3 != nil { c.http3.ResetConnection() c.clearHTTP3Broken() @@ -253,13 +298,7 @@ func (c *Client) connect(ctx context.Context, conn net.Conn, destination M.Socks 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) - } + maps.Copy(request.Header, buildRequestHeader(c.headers, c.authorization, false)) err := request.Write(conn) if err != nil { return nil, E.Cause(err, "write request") @@ -287,6 +326,24 @@ func (c *Client) connect(ctx context.Context, conn net.Conn, destination M.Socks return conn, nil } +func buildRequestHeader(headers http.Header, authorization string, originAuthorization bool) http.Header { + header := headers.Clone() + if header == nil { + header = make(http.Header) + } + if _, loaded := header["User-Agent"]; !loaded { + header["User-Agent"] = nil + } + if authorization != "" { + if originAuthorization { + header.Set("Authorization", authorization) + } else { + header.Set("Proxy-Authorization", authorization) + } + } + return header +} + func (c *Client) Close() error { c.http2Access.Lock() defer c.http2Access.Unlock() @@ -328,55 +385,18 @@ func (c *Client) ListenPacket(ctx context.Context, destination M.Socksaddr) (net } 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) + conn, stream, err := c.openTunnel(ctx, tunnelRequest{ + protocol: connectUDPProtocol, + url: connectUDPURL(destination), + destination: destination, + }) 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 + if stream != nil { + return newHTTP3PacketConn(stream, destination, M.Socksaddr{}), nil } - return packetConn, nil + return newCapsuleConn(std_bufio.NewReader(conn), conn, destination), nil } var _ N.Dialer = (*Client)(nil) diff --git a/transport/http/client_h2.go b/transport/http/client_h2.go index 1f191bde2..7357c2077 100644 --- a/transport/http/client_h2.go +++ b/transport/http/client_h2.go @@ -55,7 +55,7 @@ func (c *Client) connectHTTP2(ctx context.Context, clientConn *http2ClientConn, Method: http.MethodConnect, URL: &url.URL{Host: destination.String()}, Host: destination.String(), - Header: c.headers.Clone(), + Header: buildRequestHeader(c.headers, c.authorization, false), } streamConn, err := c.roundTripHTTP2(ctx, clientConn, request, destination) if err != nil { @@ -69,15 +69,6 @@ func (c *Client) roundTripHTTP2(ctx context.Context, clientConn *http2ClientConn 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() diff --git a/transport/http/client_h3.go b/transport/http/client_h3.go index 5eabf84bb..5dc616c54 100644 --- a/transport/http/client_h3.go +++ b/transport/http/client_h3.go @@ -53,6 +53,9 @@ func newHTTP3Client(options ClientOptions, authorization string) (http3Client, e quicConfig.EnableDatagrams = true headers := options.Headers.Clone() authority := options.Server.String() + if options.Authority != "" { + authority = options.Authority + } if headers != nil { if host := headers.Get("Host"); host != "" { authority = host @@ -113,15 +116,6 @@ func (c *http3ClientImpl) openStream(ctx context.Context, request *http.Request) } 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) @@ -164,7 +158,7 @@ func (c *http3ClientImpl) DialContext(ctx context.Context, destination M.Socksad Method: http.MethodConnect, URL: &url.URL{Host: destination.String()}, Host: destination.String(), - Header: c.headers.Clone(), + Header: buildRequestHeader(c.headers, c.authorization, false), }) if err != nil { return nil, err @@ -172,23 +166,23 @@ func (c *http3ClientImpl) DialContext(ctx context.Context, destination M.Socksad 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) - } +func (c *http3ClientImpl) OpenTunnel(ctx context.Context, request tunnelRequest) (DatagramStream, error) { + requestURL := *request.url + requestURL.Scheme = "https" + requestURL.Host = c.authority + header := buildRequestHeader(c.headers, c.authorization, request.originAuthorization) header.Set("Capsule-Protocol", "?1") stream, clientConn, err := c.openStream(ctx, &http.Request{ Method: http.MethodConnect, - Proto: connectUDPProtocol, - URL: connectUDPURL(c.authority, destination), + Proto: request.protocol, + URL: &requestURL, Host: c.authority, Header: header, }) if err != nil { return nil, err } - return newHTTP3PacketConn(&http3RequestDatagramStream{stream: stream, datagramsEnabled: clientConn.Settings().EnableDatagrams}, destination, M.Socksaddr{}), nil + return &http3RequestDatagramStream{stream: stream, datagramsEnabled: clientConn.Settings().EnableDatagrams}, nil } func (c *http3ClientImpl) ResetConnection() { @@ -315,7 +309,7 @@ func (s *http3RequestDatagramStream) SendDatagram(payload []byte) error { } var tooLarge *quic.DatagramTooLargeError if errors.As(err, &tooLarge) { - return ErrDatagramUnsupported + return &DatagramTooLargeError{MaxPayloadSize: int(tooLarge.MaxDatagramPayloadSize) - VarintLen(uint64(s.stream.StreamID()/4))} } return err } diff --git a/transport/http/connect_udp.go b/transport/http/connect_udp.go index f927fe401..3b00a9fba 100644 --- a/transport/http/connect_udp.go +++ b/transport/http/connect_udp.go @@ -44,18 +44,13 @@ func parseConnectUDPTarget(path string) (M.Socksaddr, bool) { return destination, true } -func connectUDPURL(authority string, destination M.Socksaddr) *url.URL { +func connectUDPURL(destination M.Socksaddr) *url.URL { host := destination.AddrString() port := "/" + strconv.Itoa(int(destination.Port)) + "/" - requestURL := &url.URL{ + return &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 { @@ -65,7 +60,7 @@ func requestIsConnectUDP(request *http.Request) bool { 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)) + return c.reject(request, requestKeepAlive(request), http.StatusBadRequest, nil, 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 { @@ -106,73 +101,3 @@ func (h *httpHandler) serveConnectUDP(ctx context.Context, writer http.ResponseW <-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/forward.go b/transport/http/forward.go index c5d4b04d2..6619d8f51 100644 --- a/transport/http/forward.go +++ b/transport/http/forward.go @@ -69,7 +69,7 @@ func (c *serverConn) openUpstream(ctx context.Context, source M.Socksaddr, desti 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())) + return c.reject(request, requestKeepAlive(request), http.StatusBadRequest, nil, E.New("invalid forward target: ", request.URL.String())) } keepAlive := requestKeepAlive(request) upgradeProtocol := request.Header.Get("Upgrade") @@ -110,7 +110,7 @@ func (c *serverConn) serveForward(ctx context.Context, request *http.Request, so return c.rejectAndClose(request, http.StatusBadGateway, err) } request.Body = http.NoBody - return c.reject(request, true, http.StatusBadGateway, err) + return c.reject(request, true, http.StatusBadGateway, nil, err) } if response.StatusCode >= 200 || response.StatusCode == http.StatusSwitchingProtocols { break diff --git a/transport/http/server.go b/transport/http/server.go index 4798cc836..1e7800c33 100644 --- a/transport/http/server.go +++ b/transport/http/server.go @@ -2,11 +2,16 @@ package http import ( "context" + "io" "math" "net" "net/http" + "slices" "time" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/auth" E "github.com/sagernet/sing/common/exceptions" @@ -25,6 +30,8 @@ const ( realm = "sing-box" ) +var ConfigureHTTP3ListenerFunc func(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler http.Handler, tlsConfig tls.ServerConfig, options option.QUICOptions) (io.Closer, error) + type Handler interface { N.TCPConnectionHandlerEx N.UDPConnectionHandlerEx @@ -37,6 +44,7 @@ type ServerOptions struct { HTTP2 bool HTTP2Options option.HTTP2Options UDP bool + Tunnels map[string]TunnelHandler } type Server struct { @@ -45,6 +53,7 @@ type Server struct { http1 bool http2Server *http2.Server udp bool + tunnels map[string]TunnelHandler } func NewServer(options ServerOptions) *Server { @@ -53,6 +62,7 @@ func NewServer(options ServerOptions) *Server { logger: options.Logger, http1: options.HTTP1, udp: options.UDP, + tunnels: options.Tunnels, } if options.HTTP2 { server.http2Server = &http2.Server{ @@ -101,11 +111,31 @@ func (s *Server) ServeConnection(ctx context.Context, conn net.Conn, reader *Rea connection.serve() } -func (s *Server) HTTP3Handler(handler Handler) http.Handler { - return &httpHandler{ +func (s *Server) ConfigureTLS(tlsConfig tls.ServerConfig) { + if len(tlsConfig.NextProtos()) > 0 { + return + } + var nextProtos []string + if s.http2Server != nil { + nextProtos = append(nextProtos, http2.NextProtoTLS) + } + if s.http1 { + nextProtos = append(nextProtos, "http/1.1") + } + tlsConfig.SetNextProtos(nextProtos) +} + +func (s *Server) ListenHTTP3(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler Handler, tlsConfig tls.ServerConfig, options option.QUICOptions) (io.Closer, error) { + if ConfigureHTTP3ListenerFunc == nil { + return nil, C.ErrQUICNotIncluded + } + if !slices.Contains(tlsConfig.NextProtos(), "h3") { + tlsConfig.SetNextProtos(append([]string{"h3"}, tlsConfig.NextProtos()...)) + } + return ConfigureHTTP3ListenerFunc(ctx, logger, listener, &httpHandler{ server: s, handler: handler, - } + }, tlsConfig, options) } func (s *Server) finishConnection(ctx context.Context, conn net.Conn, source M.Socksaddr, onClose N.CloseHandlerFunc, err error) { diff --git a/transport/http/server_conn.go b/transport/http/server_conn.go index 3ce835b91..fb25515ed 100644 --- a/transport/http/server_conn.go +++ b/transport/http/server_conn.go @@ -9,7 +9,6 @@ import ( "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" @@ -68,20 +67,22 @@ func (c *serverConn) serveRequest() (requestResult, error) { 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) + tunnelProtocol, tunnelHandler := c.server.upgradeTunnelHandler(request) + if tunnelHandler != nil { + tunnelCtx, tunnelAuthErr := c.server.authenticate(c.ctx, request, "Authorization") + if tunnelAuthErr != nil { + c.server.logger.ErrorContext(c.ctx, E.Cause(tunnelAuthErr, "process connection from ", c.source)) + return c.reject(request, requestKeepAlive(request), http.StatusUnauthorized, nil, tunnelAuthErr) } - ctx = auth.ContextWithUser(ctx, username) + return c.serveTunnel(tunnelCtx, request, badhttp.ForwardedSource(request, c.source), tunnelProtocol, tunnelHandler) + } + if c.handler == nil { + return c.reject(request, requestKeepAlive(request), http.StatusNotFound, nil, E.New("unexpected request: ", request.Method, " ", request.URL)) + } + ctx, authErr := c.server.authenticate(c.ctx, request, "Proxy-Authorization") + if authErr != nil { + c.server.logger.ErrorContext(c.ctx, E.Cause(authErr, "process connection from ", c.source)) + return c.reject(request, requestKeepAlive(request), http.StatusProxyAuthRequired, nil, authErr) } source := badhttp.ForwardedSource(request, c.source) switch { @@ -99,7 +100,7 @@ func (c *serverConn) serveRequest() (requestResult, error) { 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)) + return c.reject(request, requestKeepAlive(request), http.StatusBadRequest, nil, 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 { @@ -109,11 +110,11 @@ func (c *serverConn) serveConnect(ctx context.Context, request *http.Request, so return requestHandedOff, nil } -func (c *serverConn) reject(request *http.Request, keepAlive bool, statusCode int, cause error) (requestResult, error) { +func (c *serverConn) reject(request *http.Request, keepAlive bool, statusCode int, header http.Header, cause error) (requestResult, error) { if keepAlive && request.Body != nil && request.Body != http.NoBody { keepAlive = c.discardBody(request.Body) } - err := c.writeReject(request, statusCode, keepAlive) + err := c.writeReject(request, statusCode, header, keepAlive) if err != nil { return requestClose, E.Errors(cause, err) } @@ -124,28 +125,34 @@ func (c *serverConn) reject(request *http.Request, keepAlive bool, statusCode in } func (c *serverConn) rejectAndClose(request *http.Request, statusCode int, cause error) (requestResult, error) { - err := c.writeReject(request, statusCode, false) + err := c.writeReject(request, statusCode, nil, 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 { +func (c *serverConn) writeReject(request *http.Request, statusCode int, header http.Header, keepAlive bool) error { response := &http.Response{ StatusCode: statusCode, ProtoMajor: 1, ProtoMinor: 1, - Header: make(http.Header), + Header: header.Clone(), Request: request, Close: !keepAlive, } + if response.Header == nil { + response.Header = make(http.Header) + } if request != nil { response.ProtoMajor = request.ProtoMajor response.ProtoMinor = request.ProtoMinor } - if statusCode == http.StatusProxyAuthRequired { + switch statusCode { + case http.StatusProxyAuthRequired: response.Header.Set("Proxy-Authenticate", `Basic realm="`+realm+`", charset="UTF-8"`) + case http.StatusUnauthorized: + response.Header.Set("WWW-Authenticate", `Basic realm="`+realm+`", charset="UTF-8"`) } if keepAlive && request.ProtoMajor == 1 && request.ProtoMinor == 0 { response.Header.Set("Connection", "keep-alive") diff --git a/transport/http/server_h2.go b/transport/http/server_h2.go index 21048b92f..4d23118ac 100644 --- a/transport/http/server_h2.go +++ b/transport/http/server_h2.go @@ -12,7 +12,6 @@ import ( "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" @@ -88,28 +87,40 @@ func (h *httpHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reques 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) + var protocol string if request.Method == http.MethodConnect { - protocol := request.Header.Get(":protocol") + protocol = request.Header.Get(":protocol") if protocol == "" && request.ProtoMajor == 3 && !strings.HasPrefix(request.Proto, "HTTP/") { protocol = request.Proto } + } + tunnelHandler := h.server.tunnels[protocol] + if tunnelHandler != nil { + tunnelCtx, authErr := h.server.authenticate(ctx, request, "Authorization") + if authErr != nil { + h.server.logger.ErrorContext(ctx, E.Cause(authErr, "process connection from ", connectionSource)) + writer.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`", charset="UTF-8"`) + writer.WriteHeader(http.StatusUnauthorized) + return + } + h.serveTunnel(tunnelCtx, writer, request, badhttp.ForwardedSource(request, connectionSource), tunnelHandler) + return + } + if h.handler == nil { + h.server.logger.ErrorContext(ctx, "process connection from ", connectionSource, ": unexpected request: ", request.Method, " ", request.URL) + writer.WriteHeader(http.StatusNotFound) + return + } + proxyCtx, authErr := h.server.authenticate(ctx, request, "Proxy-Authorization") + if authErr != nil { + 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 = proxyCtx + source := badhttp.ForwardedSource(request, connectionSource) + if request.Method == http.MethodConnect { switch { case protocol == "": h.serveConnect(ctx, writer, request, source) diff --git a/protocol/http/quic/inbound_init.go b/transport/http/server_h3.go similarity index 82% rename from protocol/http/quic/inbound_init.go rename to transport/http/server_h3.go index fe49cc68e..938c30c8a 100644 --- a/protocol/http/quic/inbound_init.go +++ b/transport/http/server_h3.go @@ -1,6 +1,6 @@ //go:build with_quic -package quic +package http import ( "context" @@ -16,8 +16,6 @@ import ( "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" @@ -25,7 +23,7 @@ import ( ) 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) { + 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 @@ -63,7 +61,7 @@ func init() { }() return quicListener, nil } - transportHTTP.HTTP3StreamFunc = func(ctx context.Context, writer http.ResponseWriter) (transportHTTP.DatagramStream, bool) { + HTTP3StreamFunc = func(ctx context.Context, writer http.ResponseWriter) (DatagramStream, bool) { streamer, isStreamer := writer.(http3.HTTPStreamer) if !isStreamer { return nil, false @@ -91,7 +89,7 @@ type datagramStream struct { func (s *datagramStream) SendDatagram(payload []byte) error { if !s.datagramsEnabled { - return transportHTTP.ErrDatagramUnsupported + return ErrDatagramUnsupported } err := s.Stream.SendDatagram(payload) if err == nil { @@ -99,7 +97,7 @@ func (s *datagramStream) SendDatagram(payload []byte) error { } var tooLarge *quic.DatagramTooLargeError if errors.As(err, &tooLarge) { - return transportHTTP.ErrDatagramUnsupported + return &DatagramTooLargeError{MaxPayloadSize: int(tooLarge.MaxDatagramPayloadSize) - VarintLen(uint64(s.Stream.StreamID()/4))} } return err } diff --git a/transport/http/tunnel_client.go b/transport/http/tunnel_client.go new file mode 100644 index 000000000..1cde18db0 --- /dev/null +++ b/transport/http/tunnel_client.go @@ -0,0 +1,170 @@ +package http + +import ( + std_bufio "bufio" + "context" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + + "github.com/sagernet/sing/common/buf" + "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" +) + +type tunnelRequest struct { + protocol string + url *url.URL + destination M.Socksaddr + originAuthorization bool +} + +func (c *Client) OpenTunnel(ctx context.Context, protocol string, path string) (io.ReadWriteCloser, error) { + requestURL, err := url.ParseRequestURI(path) + if err != nil { + return nil, E.Cause(err, "parse tunnel path") + } + conn, stream, err := c.openTunnel(ctx, tunnelRequest{ + protocol: protocol, + url: &url.URL{Path: requestURL.Path, RawPath: requestURL.RawPath, RawQuery: requestURL.RawQuery}, + destination: c.server, + originAuthorization: true, + }) + if err != nil { + return nil, err + } + if stream != nil { + return stream, nil + } + return conn, nil +} + +func (c *Client) openTunnel(ctx context.Context, request tunnelRequest) (net.Conn, DatagramStream, error) { + if c.http3Available() { + stream, err := c.http3.OpenTunnel(ctx, request) + if err == nil { + c.clearHTTP3Broken() + return nil, stream, nil + } + if c.disableVersionFallback || !errors.Is(err, ErrHTTP3Unavailable) { + return nil, nil, err + } + c.markHTTP3Broken() + } + if c.tlsDialer != nil && !c.http2Unsupported.Load() && !c.http2ExtendedConnectUnsupported.Load() { + clientConn, conn, err := c.acquireHTTP2(ctx) + if err != nil { + return nil, nil, err + } + if clientConn != nil { + streamConn, connectErr := c.openTunnelHTTP2(ctx, clientConn, request) + if connectErr == nil { + return streamConn, nil, nil + } + if !errors.Is(connectErr, errExtendedConnectUnsupported) || c.disableVersionFallback { + return nil, nil, connectErr + } + c.http2ExtendedConnectUnsupported.Store(true) + } else if c.disableVersionFallback { + conn.Close() + return nil, nil, ErrHTTP2Unsupported + } else { + return c.openTunnelHTTP1AndClose(ctx, conn, request) + } + } + conn, err := c.http1Dialer.DialContext(ctx, N.NetworkTCP, c.server) + if err != nil { + return nil, nil, err + } + return c.openTunnelHTTP1AndClose(ctx, conn, request) +} + +func (c *Client) openTunnelHTTP1AndClose(ctx context.Context, conn net.Conn, request tunnelRequest) (net.Conn, DatagramStream, error) { + tunnelConn, err := c.openTunnelHTTP1(ctx, conn, request) + if err != nil { + conn.Close() + if ctx.Err() != nil { + return nil, nil, ctx.Err() + } + return nil, nil, err + } + return tunnelConn, nil, nil +} + +func (c *Client) openTunnelHTTP1(ctx context.Context, conn net.Conn, request tunnelRequest) (net.Conn, error) { + stop := context.AfterFunc(ctx, func() { + conn.Close() + }) + defer stop() + httpRequest := &http.Request{ + Method: http.MethodGet, + URL: request.url, + Host: c.authority(), + Header: buildRequestHeader(c.headers, c.authorization, request.originAuthorization), + } + httpRequest.Header.Set("Connection", "Upgrade") + httpRequest.Header.Set("Upgrade", request.protocol) + httpRequest.Header.Set("Capsule-Protocol", "?1") + err := httpRequest.Write(conn) + if err != nil { + return nil, E.Cause(err, "write request") + } + reader := std_bufio.NewReader(conn) + response, err := http.ReadResponse(reader, httpRequest) + 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"), request.protocol) { + return nil, E.New("unexpected upgrade protocol: ", response.Header.Get("Upgrade")) + } + 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) openTunnelHTTP2(ctx context.Context, clientConn *http2ClientConn, request tunnelRequest) (net.Conn, error) { + requestURL := *request.url + requestURL.Scheme = "https" + requestURL.Host = c.authority() + httpRequest := &http.Request{ + Method: http.MethodConnect, + URL: &requestURL, + Host: c.authority(), + Header: buildRequestHeader(c.headers, c.authorization, request.originAuthorization), + } + httpRequest.Header.Set(":protocol", request.protocol) + httpRequest.Header.Set("Capsule-Protocol", "?1") + streamConn, err := c.roundTripHTTP2(ctx, clientConn, httpRequest, request.destination) + if err != nil { + return nil, err + } + return streamConn, nil +} + +func (c *Client) authority() string { + if c.host != "" { + return c.host + } + if c.authorityOverride != "" { + return c.authorityOverride + } + return c.server.String() +} diff --git a/transport/http/tunnel_server.go b/transport/http/tunnel_server.go new file mode 100644 index 000000000..540d55c1d --- /dev/null +++ b/transport/http/tunnel_server.go @@ -0,0 +1,160 @@ +package http + +import ( + "context" + "io" + "maps" + "net/http" + "strings" + + "github.com/sagernet/sing-box/common/badhttp" + "github.com/sagernet/sing-box/transport/v2rayhttp" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" +) + +type TunnelHandler interface { + NewTunnelRequest(ctx context.Context, request TunnelRequest) +} + +type TunnelRequest interface { + Request() *http.Request + Source() M.Socksaddr + Accept() (io.ReadWriteCloser, error) + Reject(statusCode int, header http.Header) +} + +func (s *Server) authenticate(ctx context.Context, request *http.Request, headerName string) (context.Context, error) { + if s.authenticator == nil { + return ctx, nil + } + username, password, valid := badhttp.ParseBasicAuth(request.Header.Get(headerName)) + if !valid { + return nil, E.New("authentication failed: missing or malformed ", headerName) + } + if !s.authenticator.Verify(username, password) { + return nil, E.New("authentication failed: username=", username) + } + return auth.ContextWithUser(ctx, username), nil +} + +func (s *Server) upgradeTunnelHandler(request *http.Request) (string, TunnelHandler) { + if len(s.tunnels) == 0 || request.Method != http.MethodGet || !requestIsUpgrade(request) { + return "", nil + } + protocol := strings.ToLower(request.Header.Get("Upgrade")) + return protocol, s.tunnels[protocol] +} + +type http1TunnelRequest struct { + conn *serverConn + request *http.Request + source M.Socksaddr + protocol string + responded bool + accepted bool + result requestResult + err error +} + +func (r *http1TunnelRequest) Request() *http.Request { + return r.request +} + +func (r *http1TunnelRequest) Source() M.Socksaddr { + return r.source +} + +func (r *http1TunnelRequest) Accept() (io.ReadWriteCloser, error) { + r.responded = true + _, err := r.conn.conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: " + r.protocol + "\r\nCapsule-Protocol: ?1\r\n\r\n")) + if err != nil { + r.result = requestClose + r.err = E.Cause(err, "write response") + return nil, r.err + } + r.accepted = true + return r.conn.reader.cachedConn(r.conn.conn), nil +} + +func (r *http1TunnelRequest) Reject(statusCode int, header http.Header) { + r.responded = true + r.result, r.err = r.conn.reject(r.request, requestKeepAlive(r.request), statusCode, header, nil) +} + +func (c *serverConn) serveTunnel(ctx context.Context, request *http.Request, source M.Socksaddr, protocol string, handler TunnelHandler) (requestResult, error) { + if !request.ProtoAtLeast(1, 1) { + return c.reject(request, requestKeepAlive(request), http.StatusBadRequest, nil, E.New("invalid ", protocol, " request")) + } + current := &http1TunnelRequest{ + conn: c, + request: request, + source: source, + protocol: protocol, + } + handler.NewTunnelRequest(ctx, current) + if !current.responded { + return c.reject(request, requestKeepAlive(request), http.StatusInternalServerError, nil, E.New(protocol, " request left unanswered")) + } + if current.accepted { + return requestClose, nil + } + return current.result, current.err +} + +type httpTunnelRequest struct { + writer http.ResponseWriter + request *http.Request + source M.Socksaddr + responded bool + wrapper *v2rayhttp.HTTP2ConnWrapper +} + +func (r *httpTunnelRequest) Request() *http.Request { + return r.request +} + +func (r *httpTunnelRequest) Source() M.Socksaddr { + return r.source +} + +func (r *httpTunnelRequest) Accept() (io.ReadWriteCloser, error) { + r.responded = true + r.writer.Header().Set("Capsule-Protocol", "?1") + r.writer.WriteHeader(http.StatusOK) + r.writer.(http.Flusher).Flush() + if r.request.ProtoMajor == 3 && HTTP3StreamFunc != nil { + stream, isDatagramStream := HTTP3StreamFunc(r.request.Context(), r.writer) + if isDatagramStream { + return stream, nil + } + } + r.wrapper = v2rayhttp.NewHTTP2Wrapper(&v2rayhttp.ServerHTTPConn{ + HTTP2Conn: v2rayhttp.NewHTTPConn(r.request.Body, r.writer), + Flusher: r.writer.(http.Flusher), + }) + return r.wrapper, nil +} + +func (r *httpTunnelRequest) Reject(statusCode int, header http.Header) { + r.responded = true + maps.Copy(r.writer.Header(), header) + r.writer.WriteHeader(statusCode) +} + +func (h *httpHandler) serveTunnel(ctx context.Context, writer http.ResponseWriter, request *http.Request, source M.Socksaddr, handler TunnelHandler) { + current := &httpTunnelRequest{ + writer: writer, + request: request, + source: source, + } + handler.NewTunnelRequest(ctx, current) + if !current.responded { + writer.WriteHeader(http.StatusInternalServerError) + return + } + if current.wrapper != nil { + current.wrapper.CloseWrapper() + } +} diff --git a/transport/masque/capsule.go b/transport/masque/capsule.go new file mode 100644 index 000000000..955621753 --- /dev/null +++ b/transport/masque/capsule.go @@ -0,0 +1,166 @@ +package masque + +import ( + "net/netip" + + transportHTTP "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + + "go4.org/netipx" +) + +const ( + capsuleTypeAddressAssign = 0x01 + capsuleTypeAddressRequest = 0x02 + capsuleTypeRouteAdvertisement = 0x03 +) + +type AssignedAddress struct { + RequestID uint64 + Prefix netip.Prefix +} + +type AddressRange struct { + Start netip.Addr + End netip.Addr + Protocol uint8 +} + +func (r AddressRange) Contains(address netip.Addr) bool { + return address.BitLen() == r.Start.BitLen() && r.Start.Compare(address) <= 0 && address.Compare(r.End) <= 0 +} + +func parseAddresses(payload []byte) ([]AssignedAddress, error) { + var addresses []AssignedAddress + for len(payload) > 0 { + requestID, requestIDLength, valid := transportHTTP.DecodeVarint(payload) + if !valid { + return nil, E.New("truncated request ID") + } + payload = payload[requestIDLength:] + addressLength, err := parseVersion(payload) + if err != nil { + return nil, err + } + if len(payload) < 1+addressLength+1 { + return nil, E.New("truncated address") + } + address, _ := netip.AddrFromSlice(payload[1 : 1+addressLength]) + prefixLength := int(payload[1+addressLength]) + payload = payload[1+addressLength+1:] + if prefixLength > address.BitLen() { + return nil, E.New("invalid prefix length: ", prefixLength) + } + prefix := netip.PrefixFrom(address, prefixLength) + if prefix.Masked() != prefix { + return nil, E.New("prefix has host bits set: ", prefix) + } + addresses = append(addresses, AssignedAddress{RequestID: requestID, Prefix: prefix}) + } + return addresses, nil +} + +func parseVersion(payload []byte) (int, error) { + if len(payload) < 1 { + return 0, E.New("truncated IP version") + } + switch payload[0] { + case 4: + return 4, nil + case 6: + return 16, nil + default: + return 0, E.New("invalid IP version: ", payload[0]) + } +} + +func parseRoutes(payload []byte) ([]AddressRange, error) { + var routes []AddressRange + for len(payload) > 0 { + addressLength, err := parseVersion(payload) + if err != nil { + return nil, err + } + if len(payload) < 1+addressLength*2+1 { + return nil, E.New("truncated address range") + } + start, _ := netip.AddrFromSlice(payload[1 : 1+addressLength]) + end, _ := netip.AddrFromSlice(payload[1+addressLength : 1+addressLength*2]) + route := AddressRange{Start: start, End: end, Protocol: payload[1+addressLength*2]} + payload = payload[1+addressLength*2+1:] + if start.Compare(end) > 0 { + return nil, E.New("invalid address range: ", start, "-", end) + } + if len(routes) > 0 { + previous := routes[len(routes)-1] + switch { + case previous.Start.BitLen() > start.BitLen(): + return nil, E.New("address ranges are not ordered by IP version") + case previous.Start.BitLen() < start.BitLen(): + case previous.Protocol > route.Protocol: + return nil, E.New("address ranges are not ordered by IP protocol") + case previous.Protocol < route.Protocol: + case previous.End.Compare(start) >= 0: + return nil, E.New("address ranges overlap: ", previous.End, " and ", start) + } + } + routes = append(routes, route) + } + return routes, nil +} + +func appendAddress(payload []byte, address netip.Addr) []byte { + if address.Is4() { + payload = append(payload, 4) + } else { + payload = append(payload, 6) + } + return append(payload, address.AsSlice()...) +} + +func newCapsule(capsuleType uint64, payload []byte) *buf.Buffer { + capsule := buf.NewSize(transportHTTP.VarintLen(capsuleType) + transportHTTP.VarintLen(uint64(len(payload))) + len(payload)) + transportHTTP.PutVarint(capsule.Extend(transportHTTP.VarintLen(capsuleType)), capsuleType) + transportHTTP.PutVarint(capsule.Extend(transportHTTP.VarintLen(uint64(len(payload)))), uint64(len(payload))) + capsule.Write(payload) + return capsule +} + +func newAddressCapsule(capsuleType uint64, addresses []AssignedAddress) *buf.Buffer { + var payload []byte + for _, address := range addresses { + var requestID [8]byte + payload = append(payload, requestID[:transportHTTP.PutVarint(requestID[:], address.RequestID)]...) + payload = appendAddress(payload, address.Prefix.Addr()) + payload = append(payload, byte(address.Prefix.Bits())) + } + return newCapsule(capsuleType, payload) +} + +func newRouteCapsule(routes []AddressRange) *buf.Buffer { + var payload []byte + for _, route := range routes { + payload = appendAddress(payload, route.Start) + payload = append(payload, route.End.AsSlice()...) + payload = append(payload, route.Protocol) + } + return newCapsule(capsuleTypeRouteAdvertisement, payload) +} + +func RangesFromPrefixes(prefixes []netip.Prefix, protocol uint8) ([]AddressRange, error) { + var builder netipx.IPSetBuilder + for _, prefix := range prefixes { + builder.AddPrefix(prefix) + } + ipSet, err := builder.IPSet() + if err != nil { + return nil, err + } + ipRanges := ipSet.Ranges() + routes := make([]AddressRange, 0, len(ipRanges)) + for _, ipRange := range ipRanges { + routes = append(routes, AddressRange{Start: ipRange.From(), End: ipRange.To(), Protocol: protocol}) + } + return routes, nil +} diff --git a/transport/masque/client.go b/transport/masque/client.go new file mode 100644 index 000000000..cae712ec6 --- /dev/null +++ b/transport/masque/client.go @@ -0,0 +1,478 @@ +package masque + +import ( + "context" + "net/netip" + "slices" + "sync" + "time" + + C "github.com/sagernet/sing-box/constant" + transportHTTP "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +const ( + reconnectBackoffInitial = time.Second + reconnectBackoffMax = time.Minute +) + +type Configuration struct { + Address []netip.Prefix + Routes []AddressRange + RoutesAdvertised bool +} + +type ClientHandler interface { + UpdateConfiguration(configuration Configuration) error + WriteInboundBuffers(packetBuffers []*buf.Buffer) error +} + +type ClientOptions struct { + Context context.Context + Logger logger.ContextLogger + HTTPClient *transportHTTP.Client + Path string + AdvertiseRoutes []netip.Prefix + Handler ClientHandler +} + +type Client struct { + ctx context.Context + cancel context.CancelFunc + logger logger.ContextLogger + httpClient *transportHTTP.Client + template *Template + advertiseRoutes []AddressRange + handler ClientHandler + access sync.Mutex + current *clientSession + suspended bool + restarting bool + lastError error + stateUpdated chan struct{} + loopDone chan struct{} +} + +type clientSession struct { + *session + client *Client + access sync.Mutex + configuration Configuration + ready bool +} + +func NewClient(options ClientOptions) (*Client, error) { + template, err := ParseTemplate(options.Path) + if err != nil { + return nil, err + } + advertiseRoutes, err := RangesFromPrefixes(options.AdvertiseRoutes, 0) + if err != nil { + return nil, E.Cause(err, "build advertised routes") + } + ctx, cancel := context.WithCancel(options.Context) + return &Client{ + ctx: ctx, + cancel: cancel, + logger: options.Logger, + httpClient: options.HTTPClient, + template: template, + advertiseRoutes: advertiseRoutes, + handler: options.Handler, + stateUpdated: make(chan struct{}), + }, nil +} + +func (c *Client) Start() { + c.loopDone = make(chan struct{}) + go c.loop() +} + +func (c *Client) Close() error { + c.cancel() + if c.loopDone != nil { + <-c.loopDone + } + return c.httpClient.Close() +} + +func (c *Client) notifyStateLocked() { + close(c.stateUpdated) + c.stateUpdated = make(chan struct{}) +} + +func (c *Client) loop() { + defer close(c.loopDone) + backoff := reconnectBackoffInitial + for { + c.access.Lock() + suspended := c.suspended + stateUpdated := c.stateUpdated + c.restarting = false + if !suspended { + c.lastError = nil + } + c.access.Unlock() + if suspended { + select { + case <-stateUpdated: + continue + case <-c.ctx.Done(): + return + } + } + established, err := c.connect() + if c.ctx.Err() != nil { + return + } + c.access.Lock() + interrupted := c.suspended || c.restarting + if !interrupted { + c.lastError = err + } + c.notifyStateLocked() + stateUpdated = c.stateUpdated + c.access.Unlock() + if interrupted { + backoff = reconnectBackoffInitial + continue + } + if err != nil { + c.logger.Error(E.Cause(err, "connection closed")) + } + if established { + backoff = reconnectBackoffInitial + } + timer := time.NewTimer(backoff) + select { + case <-timer.C: + case <-stateUpdated: + timer.Stop() + case <-c.ctx.Done(): + timer.Stop() + return + } + backoff = min(backoff*2, reconnectBackoffMax) + } +} + +func (c *Client) connect() (bool, error) { + dialCtx, cancelDial := context.WithTimeout(c.ctx, C.TCPTimeout) + stream, err := c.httpClient.OpenTunnel(dialCtx, upgradeToken, c.template.Expand()) + cancelDial() + if err != nil { + return false, err + } + current := &clientSession{client: c} + c.access.Lock() + if c.suspended || c.restarting { + suspended := c.suspended + c.access.Unlock() + stream.Close() + if suspended { + c.httpClient.ResetConnections() + } + return false, nil + } + current.session = newSession(c.ctx, stream, current, false) + c.current = current + c.access.Unlock() + err = current.writeCapsule(newAddressCapsule(capsuleTypeAddressRequest, []AssignedAddress{ + {RequestID: 1, Prefix: netip.PrefixFrom(netip.IPv4Unspecified(), 32)}, + {RequestID: 2, Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 128)}, + })) + if err == nil && len(c.advertiseRoutes) > 0 { + err = current.writeCapsule(newRouteCapsule(c.advertiseRoutes)) + } + if err != nil { + current.cancel(err) + } + err = current.run() + c.access.Lock() + c.current = nil + c.access.Unlock() + current.access.Lock() + established := current.ready + current.ready = false + current.access.Unlock() + return established, err +} + +func (c *Client) activeSession() *clientSession { + c.access.Lock() + defer c.access.Unlock() + return c.current +} + +func (c *Client) Ready() bool { + current := c.activeSession() + if current == nil { + return false + } + current.access.Lock() + defer current.access.Unlock() + return current.ready +} + +func (c *Client) WaitReady(ctx context.Context) error { + for { + c.access.Lock() + current := c.current + lastError := c.lastError + stateUpdated := c.stateUpdated + c.access.Unlock() + if current != nil { + current.access.Lock() + ready := current.ready + current.access.Unlock() + if ready { + return nil + } + } else if lastError != nil { + return lastError + } + select { + case <-stateUpdated: + case <-ctx.Done(): + return ctx.Err() + case <-c.ctx.Done(): + return c.ctx.Err() + } + } +} + +func (c *Client) Suspend() { + c.access.Lock() + if c.suspended { + c.access.Unlock() + return + } + c.suspended = true + if c.current != nil { + c.current.cancel(E.New("suspended")) + } + c.notifyStateLocked() + c.access.Unlock() + c.httpClient.ResetConnections() +} + +func (c *Client) Resume() { + c.access.Lock() + defer c.access.Unlock() + if !c.suspended { + return + } + c.suspended = false + c.lastError = nil + c.notifyStateLocked() +} + +func (c *Client) RestartSession() { + c.access.Lock() + c.restarting = true + if c.current != nil { + c.current.cancel(E.New("network changed")) + } + c.lastError = nil + c.notifyStateLocked() + c.access.Unlock() + c.httpClient.ResetConnections() +} + +func (c *Client) WritePacketBuffers(packetBuffers []*buf.Buffer, forwarded bool) error { + current := c.activeSession() + if current == nil { + buf.ReleaseMulti(packetBuffers) + return nil + } + current.access.Lock() + configuration := current.configuration + ready := current.ready + current.access.Unlock() + if !ready { + buf.ReleaseMulti(packetBuffers) + return nil + } + inet4Address, inet6Address := firstAddresses(configuration.Address) + var replies []*buf.Buffer + for i, packetBuffer := range packetBuffers { + _, destination, protocol, valid := packetAddresses(packetBuffer.Bytes()) + if !valid { + packetBuffer.Release() + continue + } + errorType := tun.ICMPErrorNoRoute + routed := !configuration.RoutesAdvertised || RoutesContain(configuration.Routes, destination, protocol) + if routed && forwarded && !decrementHopLimit(packetBuffer.Bytes()) { + routed = false + errorType = tun.ICMPErrorHopLimitExceeded + } + if !routed { + reply, built := buildICMPError(packetBuffer.Bytes(), errorType, inet4Address, inet6Address, 0, PacketHeadroom) + if built { + replies = append(replies, reply) + } + packetBuffer.Release() + continue + } + err := current.writePacket(packetBuffer) + if err != nil { + current.cancel(err) + buf.ReleaseMulti(packetBuffers[i+1:]) + break + } + } + if len(replies) > 0 { + return c.handler.WriteInboundBuffers(replies) + } + return nil +} + +func (s *clientSession) handleAddressAssign(addresses []AssignedAddress) error { + var assigned []netip.Prefix + for _, address := range addresses { + if address.RequestID != 0 && address.Prefix.Addr().IsUnspecified() && address.Prefix.IsSingleIP() { + continue + } + localAddress := address.Prefix.Addr() + if !address.Prefix.IsSingleIP() { + localAddress = localAddress.Next() + } + assigned = append(assigned, netip.PrefixFrom(localAddress, address.Prefix.Bits())) + } + s.access.Lock() + if slices.Equal(s.configuration.Address, assigned) { + s.access.Unlock() + return nil + } + s.configuration.Address = assigned + configuration := s.configuration + s.access.Unlock() + return s.updateConfiguration(configuration) +} + +func (s *clientSession) handleRouteAdvertisement(routes []AddressRange) error { + s.access.Lock() + if s.configuration.RoutesAdvertised && slices.Equal(s.configuration.Routes, routes) { + s.access.Unlock() + return nil + } + s.configuration.Routes = routes + s.configuration.RoutesAdvertised = true + configuration := s.configuration + s.access.Unlock() + if len(configuration.Address) == 0 { + return nil + } + return s.updateConfiguration(configuration) +} + +func (s *clientSession) updateConfiguration(configuration Configuration) error { + if len(configuration.Address) == 0 { + s.access.Lock() + s.ready = false + s.access.Unlock() + s.client.access.Lock() + s.client.notifyStateLocked() + s.client.access.Unlock() + return nil + } + err := s.client.handler.UpdateConfiguration(configuration) + if err != nil { + return E.Cause(err, "update configuration") + } + s.access.Lock() + s.ready = true + s.access.Unlock() + s.client.access.Lock() + s.client.lastError = nil + s.client.notifyStateLocked() + s.client.access.Unlock() + return nil +} + +func (s *clientSession) handleAddressRequest(addresses []AssignedAddress) error { + rejections := make([]AssignedAddress, 0, len(addresses)) + for _, address := range addresses { + unspecified := netip.IPv4Unspecified() + if address.Prefix.Addr().Is6() { + unspecified = netip.IPv6Unspecified() + } + rejections = append(rejections, AssignedAddress{RequestID: address.RequestID, Prefix: netip.PrefixFrom(unspecified, unspecified.BitLen())}) + } + return s.writeCapsule(newAddressCapsule(capsuleTypeAddressAssign, rejections)) +} + +func (s *clientSession) handlePacket(buffer *buf.Buffer) { + _, destination, _, valid := packetAddresses(buffer.Bytes()) + if !valid { + buffer.Release() + return + } + s.access.Lock() + configuration := s.configuration + s.access.Unlock() + if !prefixesContain(configuration.Address, destination) && !rangesContain(s.client.advertiseRoutes, destination) { + inet4Address, inet6Address := firstAddresses(configuration.Address) + reply, built := buildICMPError(buffer.Bytes(), tun.ICMPErrorNoRoute, inet4Address, inet6Address, 0, transportHTTP.CapsuleHeadroom) + buffer.Release() + if built { + _ = s.writePacket(reply) + } + return + } + err := s.client.handler.WriteInboundBuffers([]*buf.Buffer{buffer}) + if err != nil { + s.client.logger.Debug(E.Cause(err, "write packet to device")) + } +} + +func (s *clientSession) handlePacketTooBig(buffer *buf.Buffer, mtu int) { + s.access.Lock() + inet4Address, inet6Address := firstAddresses(s.configuration.Address) + s.access.Unlock() + reply, built := buildICMPError(buffer.Bytes(), tun.ICMPErrorPacketTooBig, inet4Address, inet6Address, mtu, PacketHeadroom) + buffer.Release() + if !built { + return + } + err := s.client.handler.WriteInboundBuffers([]*buf.Buffer{reply}) + if err != nil { + s.client.logger.Debug(E.Cause(err, "write packet to device")) + } +} + +func firstAddresses(addresses []netip.Prefix) (netip.Addr, netip.Addr) { + var inet4Address netip.Addr + var inet6Address netip.Addr + for _, prefix := range addresses { + if prefix.Addr().Is4() && !inet4Address.IsValid() { + inet4Address = prefix.Addr() + } else if prefix.Addr().Is6() && !inet6Address.IsValid() { + inet6Address = prefix.Addr() + } + } + return inet4Address, inet6Address +} + +func prefixesContain(prefixes []netip.Prefix, address netip.Addr) bool { + return slices.ContainsFunc(prefixes, func(prefix netip.Prefix) bool { + return prefix.Contains(address) + }) +} + +func RoutesContain(routes []AddressRange, address netip.Addr, protocol uint8) bool { + return slices.ContainsFunc(routes, func(route AddressRange) bool { + return route.Contains(address) && (route.Protocol == 0 || route.Protocol == protocol || isControlProtocol(protocol)) + }) +} + +func rangesContain(routes []AddressRange, address netip.Addr) bool { + return slices.ContainsFunc(routes, func(route AddressRange) bool { + return route.Contains(address) + }) +} diff --git a/transport/masque/icmp.go b/transport/masque/icmp.go new file mode 100644 index 000000000..d976da20f --- /dev/null +++ b/transport/masque/icmp.go @@ -0,0 +1,59 @@ +package masque + +import ( + "net/netip" + + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" +) + +func packetAddresses(packet []byte) (netip.Addr, netip.Addr, uint8, bool) { + protocol, valid := tun.IPTransportProtocol(packet) + if !valid { + return netip.Addr{}, netip.Addr{}, 0, false + } + if header.IPVersion(packet) == header.IPv4Version { + ipHeader := header.IPv4(packet) + return ipHeader.SourceAddr(), ipHeader.DestinationAddr(), protocol, true + } + ipHeader := header.IPv6(packet) + return ipHeader.SourceAddr(), ipHeader.DestinationAddr(), protocol, true +} + +func isControlProtocol(protocol uint8) bool { + return protocol == uint8(header.ICMPv4ProtocolNumber) || protocol == uint8(header.ICMPv6ProtocolNumber) +} + +func decrementHopLimit(packet []byte) bool { + if header.IPVersion(packet) == header.IPv4Version { + ipHeader := header.IPv4(packet) + if ipHeader.TTL() <= 1 { + return false + } + ipHeader.SetTTL(ipHeader.TTL() - 1) + ipHeader.SetChecksum(0) + ipHeader.SetChecksum(^ipHeader.CalculateChecksum()) + return true + } + ipHeader := header.IPv6(packet) + if ipHeader.HopLimit() <= 1 { + return false + } + ipHeader.SetHopLimit(ipHeader.HopLimit() - 1) + return true +} + +func buildICMPError(packet []byte, errorType tun.ICMPError, inet4Source netip.Addr, inet6Source netip.Addr, mtu int, headroom int) (*buf.Buffer, bool) { + source := inet4Source + if header.IPVersion(packet) == header.IPv6Version { + source = inet6Source + } + reply, built := tun.BuildICMPError(packet, errorType, source, uint32(mtu), headroom) + if !built { + return nil, false + } + buffer := buf.As(reply) + buffer.Advance(headroom) + return buffer, true +} diff --git a/transport/masque/pool.go b/transport/masque/pool.go new file mode 100644 index 000000000..cf368ec05 --- /dev/null +++ b/transport/masque/pool.go @@ -0,0 +1,63 @@ +package masque + +import ( + "net/netip" + "sync" + + "go4.org/netipx" +) + +type addressPool struct { + access sync.Mutex + prefix netip.Prefix + reserved netip.Addr + last netip.Addr + next netip.Addr + allocated map[netip.Addr]struct{} +} + +func newAddressPool(address netip.Prefix) *addressPool { + prefix := address.Masked() + return &addressPool{ + prefix: prefix, + reserved: address.Addr(), + last: netipx.PrefixLastIP(prefix), + next: prefix.Addr(), + allocated: make(map[netip.Addr]struct{}), + } +} + +func (p *addressPool) usable(address netip.Addr) bool { + if address == p.reserved || address == p.prefix.Addr() { + return false + } + return !address.Is4() || address != p.last +} + +func (p *addressPool) allocate() (netip.Addr, bool) { + p.access.Lock() + defer p.access.Unlock() + start := p.next + for { + candidate := p.next + if candidate == p.last { + p.next = p.prefix.Addr() + } else { + p.next = candidate.Next() + } + _, inUse := p.allocated[candidate] + if !inUse && p.usable(candidate) { + p.allocated[candidate] = struct{}{} + return candidate, true + } + if p.next == start { + return netip.Addr{}, false + } + } +} + +func (p *addressPool) release(address netip.Addr) { + p.access.Lock() + defer p.access.Unlock() + delete(p.allocated, address) +} diff --git a/transport/masque/server.go b/transport/masque/server.go new file mode 100644 index 000000000..334cd8721 --- /dev/null +++ b/transport/masque/server.go @@ -0,0 +1,432 @@ +package masque + +import ( + "context" + "net" + "net/http" + "net/netip" + "slices" + "strings" + "sync" + + transportHTTP "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "go4.org/netipx" +) + +type ServerHandler interface { + WriteInboundBuffers(packetBuffers []*buf.Buffer) error +} + +type ServerOptions struct { + Context context.Context + Logger logger.ContextLogger + Path string + Address []netip.Prefix + AdvertiseRoutes []netip.Prefix + Resolve func(ctx context.Context, domain string) ([]netip.Addr, error) + Handler ServerHandler +} + +type Server struct { + ctx context.Context + logger logger.ContextLogger + template *Template + pools []*addressPool + inet4Address netip.Addr + inet6Address netip.Addr + advertiseRoutes *netipx.IPSet + resolve func(ctx context.Context, domain string) ([]netip.Addr, error) + handler ServerHandler + access sync.RWMutex + addresses map[netip.Addr]*serverSession + advertisements []*serverSession +} + +type serverSession struct { + *session + server *Server + ctx context.Context + user string + addresses []netip.Addr + advertisedRoutes []AddressRange + peerRoutes []AddressRange +} + +func NewServer(options ServerOptions) (*Server, error) { + template, err := ParseTemplate(options.Path) + if err != nil { + return nil, err + } + if len(options.Address) == 0 { + return nil, E.New("missing address") + } + server := &Server{ + ctx: options.Context, + logger: options.Logger, + template: template, + resolve: options.Resolve, + handler: options.Handler, + addresses: make(map[netip.Addr]*serverSession), + } + var builder netipx.IPSetBuilder + for _, address := range options.Address { + builder.AddPrefix(address.Masked()) + if address.Addr().Is4() { + if server.inet4Address.IsValid() { + return nil, E.New("multiple IPv4 addresses are not supported") + } + server.inet4Address = address.Addr() + } else { + if server.inet6Address.IsValid() { + return nil, E.New("multiple IPv6 addresses are not supported") + } + server.inet6Address = address.Addr() + } + pool := newAddressPool(address) + probe, allocated := pool.allocate() + if !allocated { + return nil, E.New("address ", address, " leaves no addresses to assign") + } + pool.release(probe) + pool.next = pool.prefix.Addr() + server.pools = append(server.pools, pool) + } + if len(options.AdvertiseRoutes) == 0 { + builder.AddPrefix(netip.PrefixFrom(netip.IPv4Unspecified(), 0)) + builder.AddPrefix(netip.PrefixFrom(netip.IPv6Unspecified(), 0)) + } + for _, route := range options.AdvertiseRoutes { + builder.AddPrefix(route) + } + server.advertiseRoutes, err = builder.IPSet() + if err != nil { + return nil, E.Cause(err, "build advertised routes") + } + return server, nil +} + +func (s *Server) NewTunnelRequest(ctx context.Context, request transportHTTP.TunnelRequest) { + scope, matched, err := s.template.Match(request.Request().URL) + if !matched { + s.logger.ErrorContext(ctx, "process connection from ", request.Source(), ": unexpected path: ", request.Request().URL.Path) + request.Reject(http.StatusNotFound, nil) + return + } + if err != nil { + s.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", request.Source())) + request.Reject(http.StatusBadRequest, nil) + return + } + var builder netipx.IPSetBuilder + switch { + case scope.Domain != "": + addresses, resolveErr := s.resolve(ctx, scope.Domain) + if resolveErr != nil { + s.logger.ErrorContext(ctx, E.Cause(resolveErr, "process connection from ", request.Source(), ": resolve ", scope.Domain)) + request.Reject(http.StatusBadGateway, http.Header{"Proxy-Status": []string{"sing-box; error=dns_error"}}) + return + } + for _, address := range addresses { + builder.Add(address) + } + builder.Intersect(s.advertiseRoutes) + case scope.Prefix.IsValid(): + builder.AddPrefix(scope.Prefix) + builder.Intersect(s.advertiseRoutes) + default: + builder.AddSet(s.advertiseRoutes) + } + current := &serverSession{ + server: s, + ctx: ctx, + } + current.user, _ = auth.UserFromContext[string](ctx) + for _, pool := range s.pools { + address, allocated := pool.allocate() + if !allocated { + continue + } + current.addresses = append(current.addresses, address) + } + defer s.releaseSession(current) + if len(current.addresses) == 0 { + s.logger.ErrorContext(ctx, "process connection from ", request.Source(), ": address pool exhausted") + request.Reject(http.StatusServiceUnavailable, nil) + return + } + var familyBuilder netipx.IPSetBuilder + for _, address := range current.addresses { + if address.Is4() { + familyBuilder.AddPrefix(netip.PrefixFrom(netip.IPv4Unspecified(), 0)) + } else { + familyBuilder.AddPrefix(netip.PrefixFrom(netip.IPv6Unspecified(), 0)) + } + } + families, _ := familyBuilder.IPSet() + builder.Intersect(families) + routeSet, err := builder.IPSet() + if err != nil { + s.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", request.Source(), ": build routes")) + request.Reject(http.StatusInternalServerError, nil) + return + } + current.advertisedRoutes = common.Map(routeSet.Ranges(), func(it netipx.IPRange) AddressRange { + return AddressRange{Start: it.From(), End: it.To(), Protocol: scope.Protocol} + }) + if len(current.advertisedRoutes) == 0 { + s.logger.ErrorContext(ctx, "process connection from ", request.Source(), ": requested target is not routable") + request.Reject(http.StatusForbidden, nil) + return + } + stream, err := request.Accept() + if err != nil { + s.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", request.Source())) + return + } + current.session = newSession(s.ctx, stream, current, true) + s.access.Lock() + for _, address := range current.addresses { + s.addresses[address] = current + } + s.access.Unlock() + assignedAddresses := strings.Join(common.Map(current.addresses, netip.Addr.String), " ") + if current.user != "" { + s.logger.InfoContext(ctx, "[", current.user, "] inbound tunnel from ", request.Source(), " assigned ", assignedAddresses) + } else { + s.logger.InfoContext(ctx, "inbound tunnel from ", request.Source(), " assigned ", assignedAddresses) + } + err = current.writeCapsule(newAddressCapsule(capsuleTypeAddressAssign, current.assignedAddresses(nil))) + if err == nil { + err = current.writeCapsule(newRouteCapsule(current.advertisedRoutes)) + } + if err != nil { + current.cancel(err) + } + err = current.run() + if err != nil && !E.IsClosedOrCanceled(err) { + s.logger.ErrorContext(ctx, E.Cause(err, "tunnel from ", request.Source(), " closed")) + } else { + s.logger.DebugContext(ctx, "tunnel from ", request.Source(), " closed") + } +} + +func (s *Server) releaseSession(current *serverSession) { + s.access.Lock() + for _, address := range current.addresses { + if s.addresses[address] == current { + delete(s.addresses, address) + } + } + s.advertisements = slices.DeleteFunc(s.advertisements, func(it *serverSession) bool { + return it == current + }) + s.access.Unlock() + for _, address := range current.addresses { + for _, pool := range s.pools { + if pool.prefix.Contains(address) { + pool.release(address) + } + } + } +} + +func (s *Server) lookup(destination netip.Addr, protocol uint8) *serverSession { + s.access.RLock() + defer s.access.RUnlock() + current, loaded := s.addresses[destination] + if loaded { + return current + } + if destination == s.inet4Address || destination == s.inet6Address { + return nil + } + for _, advertisement := range slices.Backward(s.advertisements) { + if RoutesContain(advertisement.peerRoutes, destination, protocol) { + return advertisement + } + } + return nil +} + +func (s *Server) Contains(address netip.Addr) bool { + s.access.RLock() + defer s.access.RUnlock() + _, loaded := s.addresses[address] + return loaded || slices.ContainsFunc(s.advertisements, func(it *serverSession) bool { + return rangesContain(it.peerRoutes, address) + }) +} + +func (s *Server) WritePacketBuffers(packetBuffers []*buf.Buffer, forwarded bool) error { + var replies []*buf.Buffer + for _, packetBuffer := range packetBuffers { + source, destination, protocol, valid := packetAddresses(packetBuffer.Bytes()) + if !valid { + packetBuffer.Release() + continue + } + errorType := tun.ICMPErrorNoRoute + target := s.lookup(destination, protocol) + routed := target != nil && target.accepts(source, protocol) + if routed && forwarded && !decrementHopLimit(packetBuffer.Bytes()) { + routed = false + errorType = tun.ICMPErrorHopLimitExceeded + } + if routed { + buffer := buf.NewSize(transportHTTP.CapsuleHeadroom + packetBuffer.Len()) + buffer.Resize(transportHTTP.CapsuleHeadroom, 0) + buffer.Write(packetBuffer.Bytes()) + target.queuePacket(buffer) + } else { + reply, built := buildICMPError(packetBuffer.Bytes(), errorType, s.inet4Address, s.inet6Address, 0, PacketHeadroom) + if built { + replies = append(replies, reply) + } + } + packetBuffer.Release() + } + if len(replies) > 0 { + return s.handler.WriteInboundBuffers(replies) + } + return nil +} + +func (s *Server) Close() error { + s.access.RLock() + sessions := make([]*serverSession, 0, len(s.addresses)) + for _, current := range s.addresses { + sessions = append(sessions, current) + } + s.access.RUnlock() + for _, current := range sessions { + current.cancel(net.ErrClosed) + } + return nil +} + +func (s *serverSession) accepts(source netip.Addr, protocol uint8) bool { + return isControlProtocol(protocol) || RoutesContain(s.advertisedRoutes, source, protocol) +} + +func (s *serverSession) assignedAddresses(requests []AssignedAddress) []AssignedAddress { + assigned := make([]AssignedAddress, 0, len(s.addresses)+len(requests)) + answered := make(map[netip.Addr]bool) + for _, request := range requests { + index := slices.IndexFunc(s.addresses, func(address netip.Addr) bool { + return address.Is4() == request.Prefix.Addr().Is4() && !answered[address] + }) + if index == -1 { + unspecified := netip.IPv4Unspecified() + if request.Prefix.Addr().Is6() { + unspecified = netip.IPv6Unspecified() + } + assigned = append(assigned, AssignedAddress{RequestID: request.RequestID, Prefix: netip.PrefixFrom(unspecified, unspecified.BitLen())}) + continue + } + address := s.addresses[index] + answered[address] = true + assigned = append(assigned, AssignedAddress{RequestID: request.RequestID, Prefix: netip.PrefixFrom(address, address.BitLen())}) + } + for _, address := range s.addresses { + if !answered[address] { + assigned = append(assigned, AssignedAddress{Prefix: netip.PrefixFrom(address, address.BitLen())}) + } + } + return assigned +} + +func (s *serverSession) handleAddressAssign(addresses []AssignedAddress) error { + return nil +} + +func (s *serverSession) handleAddressRequest(addresses []AssignedAddress) error { + return s.writeCapsule(newAddressCapsule(capsuleTypeAddressAssign, s.assignedAddresses(addresses))) +} + +func (s *serverSession) handleRouteAdvertisement(routes []AddressRange) error { + s.server.access.Lock() + s.peerRoutes = routes + s.server.advertisements = slices.DeleteFunc(s.server.advertisements, func(it *serverSession) bool { + return it == s + }) + if len(routes) > 0 { + s.server.advertisements = append(s.server.advertisements, s) + } + s.server.access.Unlock() + s.server.logger.InfoContext(s.ctx, "peer advertised ", len(routes), " routes") + return nil +} + +func (s *serverSession) handlePacket(buffer *buf.Buffer) { + source, destination, protocol, valid := packetAddresses(buffer.Bytes()) + if !valid { + buffer.Release() + return + } + s.server.access.RLock() + peerRoutes := s.peerRoutes + s.server.access.RUnlock() + errorType := tun.ICMPErrorNoRoute + switch { + case !slices.Contains(s.addresses, source) && !rangesContain(peerRoutes, source): + errorType = tun.ICMPErrorSourcePolicy + case destination.IsLinkLocalUnicast() || destination.IsLinkLocalMulticast() || destination.IsInterfaceLocalMulticast(): + buffer.Release() + return + case !RoutesContain(s.advertisedRoutes, destination, protocol): + default: + target := s.server.lookup(destination, protocol) + if target == nil && destination != s.server.inet4Address && destination != s.server.inet6Address && slices.ContainsFunc(s.server.pools, func(pool *addressPool) bool { + return pool.prefix.Contains(destination) + }) { + errorType = tun.ICMPErrorAddressUnreachable + break + } + if target == nil { + err := s.server.handler.WriteInboundBuffers([]*buf.Buffer{buffer}) + if err != nil { + s.server.logger.DebugContext(s.ctx, E.Cause(err, "write packet to device")) + } + return + } + if !target.accepts(source, protocol) { + break + } + if !decrementHopLimit(buffer.Bytes()) { + errorType = tun.ICMPErrorHopLimitExceeded + break + } + target.queuePacket(buffer) + return + } + reply, built := buildICMPError(buffer.Bytes(), errorType, s.server.inet4Address, s.server.inet6Address, 0, transportHTTP.CapsuleHeadroom) + buffer.Release() + if built { + s.queuePacket(reply) + } +} + +func (s *serverSession) handlePacketTooBig(buffer *buf.Buffer, mtu int) { + reply, built := buildICMPError(buffer.Bytes(), tun.ICMPErrorPacketTooBig, s.server.inet4Address, s.server.inet6Address, mtu, PacketHeadroom) + buffer.Release() + if !built { + return + } + _, destination, protocol, _ := packetAddresses(reply.Bytes()) + target := s.server.lookup(destination, protocol) + if target != nil { + target.queuePacket(reply) + return + } + err := s.server.handler.WriteInboundBuffers([]*buf.Buffer{reply}) + if err != nil { + s.server.logger.DebugContext(s.ctx, E.Cause(err, "write packet to device")) + } +} diff --git a/transport/masque/session.go b/transport/masque/session.go new file mode 100644 index 000000000..49d39074f --- /dev/null +++ b/transport/masque/session.go @@ -0,0 +1,249 @@ +package masque + +import ( + std_bufio "bufio" + "context" + "errors" + "io" + "sync" + + transportHTTP "github.com/sagernet/sing-box/transport/http" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + upgradeToken = "connect-ip" + DefaultMTU = 1280 + PacketHeadroom = 64 + QUICPacketOverhead = 51 + minimumLinkMTU = 1280 + maxPacketSize = 65535 + sendQueueSize = 256 +) + +type sessionHandler interface { + handleAddressAssign(addresses []AssignedAddress) error + handleAddressRequest(addresses []AssignedAddress) error + handleRouteAdvertisement(routes []AddressRange) error + handlePacket(buffer *buf.Buffer) + handlePacketTooBig(buffer *buf.Buffer, mtu int) +} + +type session struct { + ctx context.Context + cancel context.CancelCauseFunc + stream io.ReadWriteCloser + datagrams transportHTTP.DatagramStream + reader *std_bufio.Reader + handler sessionHandler + sendQueue chan *buf.Buffer + writeAccess sync.Mutex +} + +func newSession(ctx context.Context, stream io.ReadWriteCloser, handler sessionHandler, queued bool) *session { + sessionCtx, cancel := context.WithCancelCause(ctx) + datagrams, _ := stream.(transportHTTP.DatagramStream) + current := &session{ + ctx: sessionCtx, + cancel: cancel, + stream: stream, + datagrams: datagrams, + reader: std_bufio.NewReader(stream), + handler: handler, + } + if queued { + current.sendQueue = make(chan *buf.Buffer, sendQueueSize) + } + return current +} + +func (s *session) run() error { + stop := context.AfterFunc(s.ctx, func() { + s.stream.Close() + }) + defer stop() + var loops sync.WaitGroup + if s.datagrams != nil { + loops.Go(s.loopDatagram) + } + if s.sendQueue != nil { + loops.Go(s.loopSend) + } + err := s.loopCapsule() + s.cancel(err) + s.stream.Close() + loops.Wait() + return context.Cause(s.ctx) +} + +func (s *session) loopDatagram() { + for { + datagram, err := s.datagrams.ReceiveDatagram(s.ctx) + if err != nil { + s.cancel(err) + return + } + contextID, contextLength, valid := transportHTTP.DecodeVarint(datagram) + if !valid || contextID != 0 || len(datagram) == contextLength { + continue + } + buffer := buf.NewSize(PacketHeadroom + len(datagram) - contextLength) + buffer.Resize(PacketHeadroom, 0) + buffer.Write(datagram[contextLength:]) + s.handler.handlePacket(buffer) + } +} + +func (s *session) loopSend() { + for { + select { + case buffer := <-s.sendQueue: + err := s.writePacket(buffer) + if err != nil { + s.cancel(err) + } + case <-s.ctx.Done(): + for { + select { + case buffer := <-s.sendQueue: + buffer.Release() + default: + return + } + } + } + } +} + +func (s *session) loopCapsule() error { + for { + capsuleType, _, err := transportHTTP.ReadVarint(s.reader) + if err != nil { + return err + } + length, _, err := transportHTTP.ReadVarint(s.reader) + if err != nil { + return err + } + if length > transportHTTP.MaxCapsuleLength { + return E.New("capsule too large: ", length) + } + switch capsuleType { + case transportHTTP.CapsuleTypeDatagram: + err = s.readDatagramCapsule(int(length)) + case capsuleTypeAddressAssign, capsuleTypeAddressRequest, capsuleTypeRouteAdvertisement: + err = s.readControlCapsule(capsuleType, int(length)) + default: + _, err = s.reader.Discard(int(length)) + } + if err != nil { + return err + } + } +} + +func (s *session) readDatagramCapsule(length int) error { + contextID, contextLength, err := transportHTTP.ReadVarint(s.reader) + if err != nil { + return err + } + if contextLength > length { + return E.New("malformed datagram capsule") + } + payloadLength := length - contextLength + if contextID != 0 || payloadLength == 0 || payloadLength > maxPacketSize { + _, err = s.reader.Discard(payloadLength) + return err + } + buffer := buf.NewSize(PacketHeadroom + payloadLength) + buffer.Resize(PacketHeadroom, 0) + _, err = buffer.ReadFullFrom(s.reader, payloadLength) + if err != nil { + buffer.Release() + return err + } + s.handler.handlePacket(buffer) + return nil +} + +func (s *session) readControlCapsule(capsuleType uint64, length int) error { + payload := make([]byte, length) + _, err := io.ReadFull(s.reader, payload) + if err != nil { + return err + } + switch capsuleType { + case capsuleTypeAddressAssign: + addresses, parseErr := parseAddresses(payload) + if parseErr != nil { + return E.Cause(parseErr, "parse ADDRESS_ASSIGN capsule") + } + return s.handler.handleAddressAssign(addresses) + case capsuleTypeAddressRequest: + addresses, parseErr := parseAddresses(payload) + if parseErr != nil { + return E.Cause(parseErr, "parse ADDRESS_REQUEST capsule") + } + if len(addresses) == 0 { + return E.New("empty ADDRESS_REQUEST capsule") + } + for _, address := range addresses { + if address.RequestID == 0 { + return E.New("ADDRESS_REQUEST capsule with zero request ID") + } + } + return s.handler.handleAddressRequest(addresses) + default: + routes, parseErr := parseRoutes(payload) + if parseErr != nil { + return E.Cause(parseErr, "parse ROUTE_ADVERTISEMENT capsule") + } + return s.handler.handleRouteAdvertisement(routes) + } +} + +func (s *session) writeCapsule(capsule *buf.Buffer) error { + defer capsule.Release() + s.writeAccess.Lock() + defer s.writeAccess.Unlock() + _, err := s.stream.Write(capsule.Bytes()) + return err +} + +func (s *session) queuePacket(buffer *buf.Buffer) { + select { + case s.sendQueue <- buffer: + default: + buffer.Release() + } +} + +func (s *session) writePacket(buffer *buf.Buffer) error { + datagram := transportHTTP.PrependContextID(buffer) + if s.datagrams != nil { + err := s.datagrams.SendDatagram(datagram.Bytes()) + var tooLarge *transportHTTP.DatagramTooLargeError + switch { + case err == nil: + datagram.Release() + return nil + case errors.As(err, &tooLarge): + mtu := tooLarge.MaxPayloadSize - 1 + if mtu < minimumLinkMTU { + datagram.Release() + return E.New("QUIC connection is unable to carry ", minimumLinkMTU, " bytes packets") + } + datagram.Advance(1) + s.handler.handlePacketTooBig(datagram, mtu) + return nil + case errors.Is(err, transportHTTP.ErrDatagramUnsupported): + default: + datagram.Release() + return err + } + } + s.writeAccess.Lock() + defer s.writeAccess.Unlock() + return transportHTTP.WriteDatagramCapsule(s.stream, datagram) +} diff --git a/transport/masque/template.go b/transport/masque/template.go new file mode 100644 index 000000000..2f65be3af --- /dev/null +++ b/transport/masque/template.go @@ -0,0 +1,237 @@ +package masque + +import ( + "net/netip" + "net/url" + "regexp" + "strconv" + "strings" + + E "github.com/sagernet/sing/common/exceptions" +) + +const DefaultPath = "/.well-known/masque/ip/{target}/{ipproto}/" + +type expression struct { + operator byte + variables []string +} + +type Template struct { + literals []string + expressions []expression + pathExpression *regexp.Regexp + pathVariables []string + queryVariables map[string]string +} + +type Scope struct { + Domain string + Prefix netip.Prefix + Protocol uint8 +} + +func ParseTemplate(path string) (*Template, error) { + if path == "" { + path = DefaultPath + } + if !strings.HasPrefix(path, "/") { + return nil, E.New("path must start with a slash: ", path) + } + for _, character := range []byte(path) { + if character < 0x21 || character > 0x7E { + return nil, E.New("path contains invalid characters: ", path) + } + } + template := &Template{queryVariables: make(map[string]string)} + var pathPattern strings.Builder + pathPattern.WriteString("^") + inQuery := false + remaining := path + for { + open := strings.IndexByte(remaining, '{') + if open == -1 { + break + } + closing := strings.IndexByte(remaining[open:], '}') + if closing == -1 { + return nil, E.New("unterminated expression in path: ", path) + } + literal := remaining[:open] + body := remaining[open+1 : open+closing] + remaining = remaining[open+closing+1:] + if body == "" { + return nil, E.New("empty expression in path: ", path) + } + var item expression + switch body[0] { + case '?', '&': + item.operator = body[0] + body = body[1:] + case '+', '#', '.', '/', ';': + return nil, E.New("unsupported expression operator in path: ", path) + } + item.variables = strings.Split(body, ",") + for _, variable := range item.variables { + if variable == "" || strings.ContainsAny(variable, ":*") { + return nil, E.New("unsupported expression in path: ", path) + } + } + if !inQuery { + literalPath, _, hasQuery := strings.Cut(literal, "?") + if hasQuery { + inQuery = true + pathPattern.WriteString(regexp.QuoteMeta(literalPath)) + } else if item.operator != 0 { + inQuery = true + pathPattern.WriteString(regexp.QuoteMeta(literal)) + } else { + if len(item.variables) != 1 { + return nil, E.New("unsupported expression in path: ", path) + } + pathPattern.WriteString(regexp.QuoteMeta(literal)) + pathPattern.WriteString("([^/]*)") + template.pathVariables = append(template.pathVariables, item.variables[0]) + } + } + if inQuery && item.operator == 0 { + queryKey := literal[strings.LastIndexAny(literal, "?&")+1:] + if len(item.variables) != 1 || len(queryKey) < 2 || !strings.HasSuffix(queryKey, "=") { + return nil, E.New("unsupported expression in path: ", path) + } + template.queryVariables[strings.TrimSuffix(queryKey, "=")] = item.variables[0] + } + template.literals = append(template.literals, literal) + template.expressions = append(template.expressions, item) + } + if !inQuery { + remainingPath, _, _ := strings.Cut(remaining, "?") + pathPattern.WriteString(regexp.QuoteMeta(remainingPath)) + } + pathPattern.WriteString("$") + template.literals = append(template.literals, remaining) + pathExpression, err := regexp.Compile(pathPattern.String()) + if err != nil { + return nil, E.Cause(err, "compile path: ", path) + } + template.pathExpression = pathExpression + return template, nil +} + +func (t *Template) Expand() string { + var result strings.Builder + for i, item := range t.expressions { + result.WriteString(t.literals[i]) + separator := item.operator + for _, variable := range item.variables { + if variable != "target" && variable != "ipproto" { + continue + } + if separator != 0 { + result.WriteByte(separator) + result.WriteString(variable) + result.WriteByte('=') + separator = '&' + } + result.WriteByte('*') + } + } + result.WriteString(t.literals[len(t.literals)-1]) + return result.String() +} + +func (t *Template) Match(requestURL *url.URL) (Scope, bool, error) { + match := t.pathExpression.FindStringSubmatch(requestURL.EscapedPath()) + if match == nil { + return Scope{}, false, nil + } + target := "*" + protocol := "*" + query := requestURL.Query() + for _, item := range t.expressions { + if item.operator == 0 { + continue + } + for _, variable := range item.variables { + if !query.Has(variable) { + continue + } + switch variable { + case "target": + target = query.Get(variable) + case "ipproto": + protocol = query.Get(variable) + } + } + } + for queryKey, variable := range t.queryVariables { + if !query.Has(queryKey) { + continue + } + switch variable { + case "target": + target = query.Get(queryKey) + case "ipproto": + protocol = query.Get(queryKey) + } + } + for i, variable := range t.pathVariables { + value, err := url.PathUnescape(match[i+1]) + if err != nil { + return Scope{}, true, E.Cause(err, "decode ", variable) + } + switch variable { + case "target": + target = value + case "ipproto": + protocol = value + } + } + var scope Scope + switch target { + case "": + return Scope{}, true, E.New("empty target") + case "*": + default: + prefix, err := parseTarget(target) + if err == nil { + scope.Prefix = prefix + } else if strings.ContainsAny(target, ":/") { + return Scope{}, true, E.Cause(err, "parse target") + } else { + scope.Domain = target + } + } + if protocol != "*" { + protocolNumber, err := strconv.ParseUint(protocol, 10, 8) + if err != nil { + return Scope{}, true, E.New("invalid ipproto: ", protocol) + } + scope.Protocol = uint8(protocolNumber) + } + return scope, true, nil +} + +func parseTarget(target string) (netip.Prefix, error) { + var prefix netip.Prefix + if strings.Contains(target, "/") { + var err error + prefix, err = netip.ParsePrefix(target) + if err != nil { + return netip.Prefix{}, err + } + } else { + address, err := netip.ParseAddr(target) + if err != nil { + return netip.Prefix{}, err + } + prefix = netip.PrefixFrom(address, address.BitLen()) + } + if prefix.Addr().Zone() != "" { + return netip.Prefix{}, E.New("zone identifiers are not supported: ", target) + } + if prefix.Masked() != prefix { + return netip.Prefix{}, E.New("prefix has host bits set: ", target) + } + return prefix, nil +} diff --git a/transport/openconnect/configuration.go b/transport/openconnect/configuration.go new file mode 100644 index 000000000..d27d2ec7d --- /dev/null +++ b/transport/openconnect/configuration.go @@ -0,0 +1,42 @@ +package openconnect + +import ( + "net/netip" + "time" + + "github.com/sagernet/sing-openconnect" +) + +const ( + DefaultMTU = 1500 + PacketHeadroom = openconnect.PacketHeadroom +) + +type Configuration struct { + MTU uint32 + Addresses []netip.Prefix + Routes []Route + ExcludedRoutes []Route + DNS []netip.Addr + NBNS []netip.Addr + SearchDomains []string + SplitDNS []string + SplitDNSRules []SplitDNSRule + ProxyAutoConfigURL string + Banner string + TunnelAllDNS bool + ClientBypassProtocol bool + IdleTimeout time.Duration + AuthenticationExpiration time.Time +} + +type Route struct { + Prefix netip.Prefix + Gateway netip.Addr + Metric int +} + +type SplitDNSRule struct { + Domains []string + Servers []netip.Addr +} diff --git a/transport/openconnect/device.go b/transport/openconnect/device.go deleted file mode 100644 index 01cbb352b..000000000 --- a/transport/openconnect/device.go +++ /dev/null @@ -1,220 +0,0 @@ -package openconnect - -import ( - "context" - "net/netip" - "sync/atomic" - "time" - - "github.com/sagernet/sing-openconnect" - "github.com/sagernet/sing-tun" - "github.com/sagernet/sing/common/buf" - "github.com/sagernet/sing/common/control" - E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/common/logger" - N "github.com/sagernet/sing/common/network" -) - -const ( - DefaultMTU = 1500 - PacketHeadroom = openconnect.PacketHeadroom -) - -type PacketWriter func(packetBuffers []*buf.Buffer) error - -type Device interface { - N.Dialer - Start() error - UpdateConfiguration(configuration Configuration) error - WriteInboundBuffers(packetBuffers []*buf.Buffer) error - SetPacketWriter(writer PacketWriter) - PortAddresses() (netip.Addr, netip.Addr) - PortMTU() uint32 - AttachReturn(returnPath tun.Return) error - DetachReturn(returnPath tun.Return) error - ReturnPath() (tun.Return, int) - Close() error -} - -type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - System bool - Handler tun.Handler - UDPTimeout time.Duration - ICMPTimeout time.Duration - UDPMapping tun.NATMapping - UDPFiltering tun.NATFiltering - UDPNATMax uint32 - InterfaceFinder control.InterfaceFinder - MemoryPressure func() tun.MemoryPressure - Name string - MTU uint32 - Configuration Configuration -} - -type Configuration struct { - MTU uint32 - Addresses []netip.Prefix - Routes []Route - ExcludedRoutes []Route - DNS []netip.Addr - NBNS []netip.Addr - SearchDomains []string - SplitDNS []string - SplitDNSRules []SplitDNSRule - ProxyAutoConfigURL string - Banner string - TunnelAllDNS bool - ClientBypassProtocol bool - IdleTimeout time.Duration - AuthenticationExpiration time.Time -} - -type Route struct { - Prefix netip.Prefix - Gateway netip.Addr - Metric int -} - -type SplitDNSRule struct { - Domains []string - Servers []netip.Addr -} - -func NewDevice(options DeviceOptions) (Device, error) { - if !options.System { - return newStackDevice(options) - } - return newSystemStackDevice(options) -} - -func newStack(options DeviceOptions, memoryTun *tun.MemoryTun) (*tun.Go, error) { - return tun.NewGo(tun.StackOptions{ - Context: options.Context, - Tun: memoryTun, - TunOptions: tun.Options{MTU: options.MTU}, - UDPTimeout: options.UDPTimeout, - ICMPTimeout: options.ICMPTimeout, - UDPMapping: options.UDPMapping, - UDPFiltering: options.UDPFiltering, - UDPNATMax: options.UDPNATMax, - Handler: options.Handler, - Logger: options.Logger, - InterfaceFinder: options.InterfaceFinder, - MemoryPressure: options.MemoryPressure, - }) -} - -type baseDevice struct { - packetWriter PacketWriter - returnState atomic.Pointer[returnPathState] -} - -func (d *baseDevice) SetPacketWriter(writer PacketWriter) { - d.packetWriter = writer -} - -func (d *baseDevice) writeOutbound(packetBuffers []*buf.Buffer) error { - if d.packetWriter == nil { - buf.ReleaseMulti(packetBuffers) - return E.New("missing packet writer") - } - return d.packetWriter(packetBuffers) -} - -func (d *baseDevice) processInboundBuffers(packetBuffers []*buf.Buffer, writeBuffers func(packetBuffers []*buf.Buffer) error) error { - if len(packetBuffers) == 0 { - return nil - } - state := d.returnState.Load() - if state == nil { - return writeBuffers(packetBuffers) - } - packets := make([][]byte, len(packetBuffers)) - for i, packetBuffer := range packetBuffers { - packetBuffer.ExtendHeader(state.headroom) - packets[i] = packetBuffer.Bytes() - } - unconsumed := state.returnPath.ReturnPackets(packets) - if len(unconsumed) == 0 { - return nil - } - unconsumedBuffers := make([]*buf.Buffer, len(unconsumed)) - for i, packet := range unconsumed { - packetBuffer := buf.As(packet) - packetBuffer.Advance(state.headroom) - unconsumedBuffers[i] = packetBuffer - } - return writeBuffers(unconsumedBuffers) -} - -func (d *baseDevice) AttachReturn(returnPath tun.Return) error { - headroom := returnPath.ReturnHeadroom() - if headroom > PacketHeadroom { - return E.New("return path headroom ", headroom, " exceeds available ", PacketHeadroom) - } - newState := &returnPathState{ - returnPath: returnPath, - headroom: headroom, - } - for { - currentState := d.returnState.Load() - if currentState != nil { - if currentState.returnPath == returnPath { - return nil - } - return E.New("return path already attached") - } - if d.returnState.CompareAndSwap(nil, newState) { - return nil - } - } -} - -func (d *baseDevice) DetachReturn(returnPath tun.Return) error { - currentState := d.returnState.Load() - if currentState != nil && currentState.returnPath == returnPath { - d.returnState.CompareAndSwap(currentState, nil) - } - return nil -} - -func (d *baseDevice) ReturnPath() (tun.Return, int) { - state := d.returnState.Load() - if state == nil { - return nil, 0 - } - return state.returnPath, state.headroom -} - -type returnPathState struct { - returnPath tun.Return - headroom int -} - -func firstAddresses(addresses []netip.Prefix) (netip.Addr, netip.Addr) { - var inet4Address netip.Addr - var inet6Address netip.Addr - for _, prefix := range addresses { - if prefix.Addr().Is4() && !inet4Address.IsValid() { - inet4Address = prefix.Addr() - } else if prefix.Addr().Is6() && !inet6Address.IsValid() { - inet6Address = prefix.Addr() - } - } - return inet4Address, inet6Address -} - -func splitPrefixes(prefixes []netip.Prefix) ([]netip.Prefix, []netip.Prefix) { - var inet4Prefixes []netip.Prefix - var inet6Prefixes []netip.Prefix - for _, prefix := range prefixes { - if prefix.Addr().Is4() { - inet4Prefixes = append(inet4Prefixes, prefix) - } else { - inet6Prefixes = append(inet6Prefixes, prefix) - } - } - return inet4Prefixes, inet6Prefixes -} diff --git a/transport/openconnect/device_system.go b/transport/openconnect/device_system.go deleted file mode 100644 index abca06d71..000000000 --- a/transport/openconnect/device_system.go +++ /dev/null @@ -1,356 +0,0 @@ -package openconnect - -import ( - "context" - "net" - "net/netip" - "slices" - "sync" - "syscall" - - "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/dialer" - "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-tun" - "github.com/sagernet/sing-tun/gtcpip/header" - "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" - "github.com/sagernet/sing/service" -) - -var _ Device = (*systemDevice)(nil) - -const ( - systemDeviceReadBufferSize = 65535 + tun.PacketOffset - systemDevicePacketRearSpace = 64 -) - -type systemDevice struct { - baseDevice - stateAccess sync.RWMutex - options DeviceOptions - dialer N.Dialer - device tun.Tun - inet4Address netip.Addr - inet6Address netip.Addr - closed bool -} - -func newSystemDevice(options DeviceOptions) (*systemDevice, error) { - if options.Name == "" { - options.Name = tun.CalculateInterfaceName("oc") - } - if options.MTU == 0 { - options.MTU = DefaultMTU - } - interfaceDialer, err := dialer.NewDefault(options.Context, option.DialerOptions{ - AbstractDialerOptions: option.AbstractDialerOptions{ - BindInterface: options.Name, - }, - }) - if err != nil { - return nil, err - } - inet4Address, inet6Address := firstAddresses(options.Configuration.Addresses) - return &systemDevice{ - options: options, - dialer: interfaceDialer, - inet4Address: inet4Address, - inet6Address: inet6Address, - }, nil -} - -func (d *systemDevice) Start() error { - d.stateAccess.Lock() - defer d.stateAccess.Unlock() - return d.startLocked() -} - -func (d *systemDevice) startLocked() error { - if d.closed { - return net.ErrClosed - } - if d.device != nil { - return nil - } - tunOptions := d.buildTunOptions() - tunInterface, err := tun.New(tunOptions) - if err != nil { - return err - } - err = tunInterface.Start() - if err != nil { - tunInterface.Close() - return err - } - d.device = tunInterface - d.options.Logger.Info("started at ", d.options.Name) - go d.readLoop(tunInterface, int(d.options.MTU)) - return nil -} - -func (d *systemDevice) buildTunOptions() tun.Options { - inet4Address, inet6Address := firstAddresses(d.options.Configuration.Addresses) - d.inet4Address = inet4Address - d.inet6Address = inet6Address - inet4Addresses, inet6Addresses := splitPrefixes(d.options.Configuration.Addresses) - networkManager := service.FromContext[adapter.NetworkManager](d.options.Context) - tunOptions := tun.Options{ - Name: d.options.Name, - Inet4Address: inet4Addresses, - Inet6Address: inet6Addresses, - MTU: d.options.MTU, - GSO: true, - InterfaceScope: true, - DNSMode: tun.DNSModeDisabled, - InterfaceMonitor: nil, - InterfaceFinder: nil, - Logger: d.options.Logger, - EXP_DisableDNSHijack: true, - } - if networkManager != nil { - tunOptions.InterfaceMonitor = networkManager.InterfaceMonitor() - tunOptions.InterfaceFinder = networkManager.InterfaceFinder() - } - return tunOptions -} - -func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { - linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) - if isLinuxTUN && linuxTUN.BatchSize() > 1 { - d.readLoopLinux(linuxTUN, linuxTUN.BatchSize(), mtu) - return - } - darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) - if isDarwinTUN { - d.readLoopDarwin(darwinTUN) - return - } - packetBuffer := buf.NewSize(PacketHeadroom + systemDeviceReadBufferSize + systemDevicePacketRearSpace) - defer packetBuffer.Release() - for { - packetBuffer.Reset() - packetBuffer.Resize(PacketHeadroom, 0) - readN, err := tunInterface.Read(packetBuffer.FreeBytes()[:systemDeviceReadBufferSize]) - if err != nil { - if E.IsClosed(err) { - return - } - d.options.Logger.Error(E.Cause(err, "read packet")) - return - } - if readN <= tun.PacketOffset { - continue - } - packetBuffer.Truncate(readN) - packetBuffer.Advance(tun.PacketOffset) - packetBuffer.IncRef() - err = d.writeOutbound([]*buf.Buffer{packetBuffer}) - packetBuffer.DecRef() - if err != nil { - d.options.Logger.Debug(E.Cause(err, "write packet")) - } - } -} - -func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, mtu int) { - packetBuffers := make([]*buf.Buffer, batchSize) - readBuffers := make([][]byte, batchSize) - packetSizes := make([]int, batchSize) - for i := range packetBuffers { - packetBuffers[i] = buf.NewSize(PacketHeadroom + mtu + systemDevicePacketRearSpace) - } - defer buf.ReleaseMulti(packetBuffers) - for { - for i, packetBuffer := range packetBuffers { - packetBuffer.Reset() - packetBuffer.Resize(PacketHeadroom, 0) - readBuffers[i] = packetBuffer.FreeBytes()[:mtu] - } - packetCount, readErr := tunInterface.BatchRead(readBuffers, 0, packetSizes) - for i := range packetCount { - packetBuffers[i].Truncate(packetSizes[i]) - packetBuffers[i].IncRef() - } - if packetCount > 0 { - writeErr := d.writeOutbound(packetBuffers[:packetCount]) - for i := range packetCount { - packetBuffers[i].DecRef() - } - if writeErr != nil { - d.options.Logger.Debug(E.Cause(writeErr, "write packet batch")) - } - } - if readErr != nil { - if E.IsClosed(readErr) { - return - } - d.options.Logger.Error(E.Cause(readErr, "batch read packet")) - return - } - } -} - -func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { - for { - packetBuffers, readErr := tunInterface.BatchRead() - outboundBuffers := packetBuffers[:0] - for _, packetBuffer := range packetBuffers { - if packetBuffer.IsEmpty() { - packetBuffer.Release() - continue - } - outboundBuffers = append(outboundBuffers, packetBuffer) - } - if len(outboundBuffers) > 0 { - writeErr := d.writeOutbound(outboundBuffers) - if writeErr != nil { - d.options.Logger.Debug(E.Cause(writeErr, "write packet batch")) - } - } - if readErr != nil { - if E.IsClosed(readErr) || E.IsMulti(readErr, syscall.EBADF) { - return - } - d.options.Logger.Error(E.Cause(readErr, "batch read packet")) - return - } - } -} - -func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { - d.stateAccess.Lock() - defer d.stateAccess.Unlock() - previousConfiguration := d.options.Configuration - previousMTU := d.options.MTU - updatedMTU := d.options.MTU - if configuration.MTU != 0 { - updatedMTU = configuration.MTU - } - d.options.MTU = updatedMTU - d.options.Configuration = configuration - if d.device == nil { - inet4Address, inet6Address := firstAddresses(configuration.Addresses) - d.inet4Address = inet4Address - d.inet6Address = inet6Address - return nil - } - if !slices.Equal(previousConfiguration.Addresses, configuration.Addresses) || - previousMTU != updatedMTU { - d.device.Close() - d.device = nil - return d.startLocked() - } - return nil -} - -func (d *systemDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { - return d.processInboundBuffers(packetBuffers, d.writeBuffers) -} - -func (d *systemDevice) writeBuffers(packetBuffers []*buf.Buffer) error { - d.stateAccess.RLock() - tunInterface := d.device - d.stateAccess.RUnlock() - if tunInterface == nil { - return E.New("system device is not ready") - } - linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) - if isLinuxTUN { - headroom := linuxTUN.FrontHeadroom() - packets := make([][]byte, len(packetBuffers)) - var temporaryBuffers []*buf.Buffer - for i, packetBuffer := range packetBuffers { - if packetBuffer.Start() >= headroom { - packetBuffer.ExtendHeader(headroom) - packets[i] = packetBuffer.Bytes() - packetBuffer.Advance(headroom) - continue - } - temporaryBuffer := buf.NewSize(headroom + packetBuffer.Len()) - temporaryBuffer.Resize(headroom, 0) - _, _ = temporaryBuffer.Write(packetBuffer.Bytes()) - temporaryBuffer.ExtendHeader(headroom) - packets[i] = temporaryBuffer.Bytes() - temporaryBuffers = append(temporaryBuffers, temporaryBuffer) - } - _, err := linuxTUN.BatchWrite(packets, headroom) - buf.ReleaseMulti(temporaryBuffers) - return err - } - darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) - if isDarwinTUN { - return darwinTUN.BatchWrite(packetBuffers) - } - for _, packetBuffer := range packetBuffers { - err := d.writePacket(packetBuffer.Bytes()) - if err != nil { - return err - } - } - return nil -} - -func (d *systemDevice) writePacket(packet []byte) error { - d.stateAccess.RLock() - tunInterface := d.device - d.stateAccess.RUnlock() - if tunInterface == nil { - return E.New("system device is not ready") - } - if tun.PacketOffset == 0 { - _, err := tunInterface.Write(packet) - return err - } - writeBuffer := make([]byte, tun.PacketOffset+len(packet)) - tun.PacketFillHeader(writeBuffer[:tun.PacketOffset], header.IPVersion(packet)) - copy(writeBuffer[tun.PacketOffset:], packet) - _, err := tunInterface.Write(writeBuffer) - return err -} - -func (d *systemDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if !destination.Addr.IsValid() { - return nil, E.New("invalid non-IP destination") - } - return d.dialer.DialContext(ctx, network, destination) -} - -func (d *systemDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - if !destination.Addr.IsValid() { - return nil, E.New("invalid non-IP destination") - } - return d.dialer.ListenPacket(ctx, destination) -} - -func (d *systemDevice) PortAddresses() (netip.Addr, netip.Addr) { - d.stateAccess.RLock() - defer d.stateAccess.RUnlock() - return d.inet4Address, d.inet6Address -} - -func (d *systemDevice) PortMTU() uint32 { - d.stateAccess.RLock() - defer d.stateAccess.RUnlock() - return d.options.MTU -} - -func (d *systemDevice) Close() error { - d.stateAccess.Lock() - defer d.stateAccess.Unlock() - d.closed = true - if d.device == nil { - return nil - } - err := d.device.Close() - d.device = nil - return err -} - -func (d *systemDevice) configurationAddresses() []netip.Prefix { - d.stateAccess.RLock() - defer d.stateAccess.RUnlock() - return slices.Clone(d.options.Configuration.Addresses) -} diff --git a/transport/openvpn/configuration.go b/transport/openvpn/configuration.go new file mode 100644 index 000000000..e3b3aa630 --- /dev/null +++ b/transport/openvpn/configuration.go @@ -0,0 +1,37 @@ +package openvpn + +import "net/netip" + +const ( + DefaultMTU = 1500 + PacketHeadroom = 4096 +) + +type Configuration struct { + MTU uint32 + Address []netip.Prefix + Routes []Route + ExcludedRoutes []Route + DNS []netip.Addr + DNSServers []DNSServer + SearchDomains []string + DNSRoutes []string + Topology string + Interface string + BlockIPv6 bool +} + +type Route struct { + Prefix netip.Prefix + Gateway netip.Addr + Metric int +} + +type DNSServer struct { + Priority int + Addresses []netip.AddrPort + ResolveDomains []string + DNSSEC string + Transport string + SNI string +} diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go deleted file mode 100644 index feec7b9ce..000000000 --- a/transport/openvpn/device_stack.go +++ /dev/null @@ -1,156 +0,0 @@ -package openvpn - -import ( - "context" - "net" - "net/netip" - "sync" - - "github.com/sagernet/sing-tun" - "github.com/sagernet/sing/common" - "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" -) - -var _ Device = (*stackDevice)(nil) - -type stackDevice struct { - baseDevice - stateAccess sync.RWMutex - options DeviceOptions - stack *tun.Go - memoryTun *tun.MemoryTun - inet4Address netip.Addr - inet6Address netip.Addr - logRouteOptions bool - closeOnce sync.Once -} - -func newStackDevice(options DeviceOptions) (*stackDevice, error) { - if options.MTU == 0 { - options.MTU = DefaultMTU - } - device := &stackDevice{ - options: options, - logRouteOptions: true, - } - device.inet4Address, device.inet6Address = firstAddresses(options.Configuration.Address) - device.memoryTun = tun.NewMemoryTun(tun.MemoryTunOptions{ - MTU: int(options.MTU), - Headroom: PacketHeadroom, - RearSpace: systemDevicePacketRearSpace, - Outbound: device.writeOutbound, - }) - var err error - device.stack, err = newStack(options, device.memoryTun) - if err != nil { - return nil, err - } - return device, nil -} - -func (d *stackDevice) Start() error { - return d.stack.Start() -} - -func (d *stackDevice) UpdateConfiguration(configuration Configuration) error { - d.stateAccess.Lock() - defer d.stateAccess.Unlock() - if d.logRouteOptions && hasRouteOptions(configuration.Routes) { - d.options.Logger.Debug("route gateway and metric options are not representable by the stack device; routes are installed by prefix") - d.logRouteOptions = false - } - if configuration.MTU != 0 { - d.options.MTU = configuration.MTU - d.memoryTun.UpdateMTU(int(configuration.MTU)) - } - d.options.Configuration = configuration - d.inet4Address, d.inet6Address = firstAddresses(configuration.Address) - return nil -} - -func (d *stackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { - return d.processInboundBuffers(packetBuffers, d.writeBuffers) -} - -func (d *stackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { - _, err := d.memoryTun.WritePackets(common.Map(packetBuffers, (*buf.Buffer).Bytes)) - return err -} - -func (d *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - bind, err := d.bindAddress(destination) - if err != nil { - return nil, err - } - switch N.NetworkName(network) { - case N.NetworkTCP: - var tcpConn *tun.GoConn - tcpConn, err = d.stack.DialTCP(ctx, bind, destination.AddrPort()) - if err != nil { - return nil, err - } - return tcpConn, nil - case N.NetworkUDP: - var udpConn *tun.GoUDPConn - udpConn, err = d.stack.DialUDP(netip.AddrPortFrom(bind, 0), destination.AddrPort()) - if err != nil { - return nil, err - } - return udpConn, nil - default: - return nil, E.Extend(N.ErrUnknownNetwork, network) - } -} - -func (d *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - bind, err := d.bindAddress(destination) - if err != nil { - return nil, err - } - udpConn, err := d.stack.ListenUDP(netip.AddrPortFrom(bind, 0)) - if err != nil { - return nil, err - } - return udpConn, nil -} - -func (d *stackDevice) bindAddress(destination M.Socksaddr) (netip.Addr, error) { - d.stateAccess.RLock() - defer d.stateAccess.RUnlock() - if destination.IsIPv4() { - if !d.inet4Address.IsValid() { - return netip.Addr{}, E.New("missing IPv4 local address") - } - return d.inet4Address, nil - } - if d.options.Configuration.BlockIPv6 { - return netip.Addr{}, E.New("IPv6 blocked by pushed block-ipv6") - } - if !d.inet6Address.IsValid() { - return netip.Addr{}, E.New("missing IPv6 local address") - } - return d.inet6Address, nil -} - -func (d *stackDevice) PortAddresses() (netip.Addr, netip.Addr) { - d.stateAccess.RLock() - defer d.stateAccess.RUnlock() - return d.inet4Address, d.inet6Address -} - -func (d *stackDevice) PortMTU() uint32 { - d.stateAccess.RLock() - defer d.stateAccess.RUnlock() - return d.options.MTU -} - -func (d *stackDevice) Close() error { - var err error - d.closeOnce.Do(func() { - err = E.Errors(d.stack.Close(), d.memoryTun.Close()) - }) - return err -} diff --git a/transport/openvpn/device_system_stack.go b/transport/openvpn/device_system_stack.go deleted file mode 100644 index fedbdb906..000000000 --- a/transport/openvpn/device_system_stack.go +++ /dev/null @@ -1,117 +0,0 @@ -package openvpn - -import ( - "net/netip" - - "github.com/sagernet/sing-tun/gtcpip/header" - "github.com/sagernet/sing/common/buf" - E "github.com/sagernet/sing/common/exceptions" -) - -var _ Device = (*systemStackDevice)(nil) - -type systemStackDevice struct { - *systemDevice - stackDevice *stackDevice -} - -func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { - system, err := newSystemDevice(options) - if err != nil { - return nil, err - } - stackDevice, err := newStackDevice(options) - if err != nil { - system.Close() - return nil, err - } - stackDevice.logRouteOptions = false - return &systemStackDevice{ - systemDevice: system, - stackDevice: stackDevice, - }, nil -} - -func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { - d.systemDevice.SetPacketWriter(writer) - d.stackDevice.SetPacketWriter(writer) -} - -func (d *systemStackDevice) Start() error { - err := d.stackDevice.Start() - if err != nil { - return err - } - err = d.systemDevice.Start() - return err -} - -func (d *systemStackDevice) UpdateConfiguration(configuration Configuration) error { - err := d.systemDevice.UpdateConfiguration(configuration) - if err != nil { - return err - } - return d.stackDevice.UpdateConfiguration(configuration) -} - -func (d *systemStackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { - return d.systemDevice.processInboundBuffers(packetBuffers, d.writeBuffers) -} - -func (d *systemStackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { - addresses := d.systemDevice.configurationAddresses() - runStart := 0 - runUsesSystemDevice := false - var writeErr error - for i, packetBuffer := range packetBuffers { - destination := packetDestination(packetBuffer.Bytes()) - useSystemDevice := false - for _, prefix := range addresses { - if prefix.Contains(destination) { - useSystemDevice = true - break - } - } - if i > runStart && useSystemDevice != runUsesSystemDevice { - var err error - if runUsesSystemDevice { - err = d.systemDevice.writeBuffers(packetBuffers[runStart:i]) - } else { - err = d.stackDevice.writeBuffers(packetBuffers[runStart:i]) - } - writeErr = E.Errors(writeErr, err) - runStart = i - } - if i == runStart { - runUsesSystemDevice = useSystemDevice - } - } - if runStart == len(packetBuffers) { - return writeErr - } - if runUsesSystemDevice { - return E.Errors(writeErr, d.systemDevice.writeBuffers(packetBuffers[runStart:])) - } - return E.Errors(writeErr, d.stackDevice.writeBuffers(packetBuffers[runStart:])) -} - -func packetDestination(packet []byte) netip.Addr { - switch header.IPVersion(packet) { - case header.IPv4Version: - if len(packet) < header.IPv4MinimumSize { - return netip.Addr{} - } - return header.IPv4(packet).DestinationAddr() - case header.IPv6Version: - if len(packet) < header.IPv6MinimumSize { - return netip.Addr{} - } - return header.IPv6(packet).DestinationAddr() - default: - return netip.Addr{} - } -} - -func (d *systemStackDevice) Close() error { - return E.Errors(d.stackDevice.Close(), d.systemDevice.Close()) -} diff --git a/transport/wireguard/port.go b/transport/wireguard/port.go index 27ca1419d..ecd72bce1 100644 --- a/transport/wireguard/port.go +++ b/transport/wireguard/port.go @@ -73,7 +73,7 @@ func (e *Endpoint) WritePackets(packets [][]byte) error { } else { source = e.tunDevice.Inet6Address() } - reply, replyOk := tun.BuildUnreachable(packet, source, state.headroom) + reply, replyOk := tun.BuildICMPError(packet, tun.ICMPErrorNoRoute, source, 0, state.headroom) if replyOk { replies = append(replies, reply) }