diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md index c8f7f3e12..56377cdd5 100644 --- a/docs/configuration/endpoint/openconnect.md +++ b/docs/configuration/endpoint/openconnect.md @@ -13,6 +13,9 @@ "system": false, "name": "", + + ... // UDP NAT Fields + "server": "vpn.example.com", "flavor": "anyconnect", "username": "", @@ -378,6 +381,10 @@ Ask for the matching authentication field interactively instead of supplying an Conflict with `form_entries.value`. +## 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/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md index a515b84ac..a0abc3ea2 100644 --- a/docs/configuration/endpoint/openconnect.zh.md +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -13,6 +13,9 @@ "system": false, "name": "", + + ... // UDP NAT 字段 + "server": "vpn.example.com", "flavor": "anyconnect", "username": "", @@ -378,6 +381,10 @@ MCA 证书和私钥必须同时设置或同时为空。 与 `form_entries.value` 冲突。 +## UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + ## 拨号字段 参阅[拨号字段](/zh/configuration/shared/dial/)了解详情。 diff --git a/docs/configuration/endpoint/openvpn-client.md b/docs/configuration/endpoint/openvpn-client.md index a8a61fe91..f82c1ff60 100644 --- a/docs/configuration/endpoint/openvpn-client.md +++ b/docs/configuration/endpoint/openvpn-client.md @@ -69,14 +69,15 @@ "route_metric": 0, "redirect_gateway": false, "redirect_gateway_flags": [], - "keepalive_interval": "", - "keepalive_timeout": "", + "ping_interval": "", + "ping_restart": "", "renegotiate_interval": "", "explicit_exit_notify": 0, "system": false, "name": "", "mtu": 1500, - "udp_timeout": "", + + ... // UDP NAT Fields ... // Dial Fields } @@ -342,7 +343,8 @@ Maximum OpenVPN UDP packet size used to clamp the MSS of TCP connections sent th This prevents TCP packets from exceeding the path MTU after OpenVPN encapsulation. -Disabled when `0`. +When empty, the upstream OpenVPN default is used: `fragment` when configured, +otherwise `1492` for the default tunnel MTU or the configured tunnel MTU. ### fragment @@ -378,9 +380,10 @@ Conflict with non-stub compression enabled by `compression` or `compression_lzo` ### route_no_pull -Ignore routes, route gateways, and redirect-gateway options pushed by the server. +Ignore routes, DNS and DHCP settings, route metrics, `redirect-gateway`, +`redirect-private`, `block-ipv6`, and `block-outside-dns` pushed by the server. -Other pushed options are still accepted, and locally configured `routes` are still used. +Interface configuration, topology, tunnel MTU, `route-gateway`, and locally configured routes are still used. Disabled by default. @@ -388,7 +391,7 @@ Disabled by default. Ordered filters for options pushed by the server. -The first filter whose `text` is a case-insensitive prefix of the complete pushed option is applied. Options that match no filter are accepted. +The first filter whose `text` is a case-sensitive prefix of the complete pushed option is applied. Options that match no filter are accepted. ### pull_filters.action @@ -402,7 +405,7 @@ Filter action, one of `accept`, `ignore`, or `reject`. ==Required== -Case-insensitive prefix to match against the pushed option name and value. +Case-sensitive prefix to match against the pushed option name and value. For example, `route ` matches pushed IPv4 route options without matching `route-gateway`. @@ -438,33 +441,38 @@ OpenVPN `redirect-gateway` flags. Empty by default. -### keepalive_interval +### ping_interval -Interval for sending OpenVPN keepalive ping packets. +Interval after which the client sends a data-channel ping when no packet has been sent to the server. -Locally configured values take precedence over server-pushed keepalive values. +A server-pushed OpenVPN `ping` value overrides this value. + +The value must use whole seconds. Disabled by default. -### keepalive_timeout +### ping_restart -Time without receiving OpenVPN traffic before the connection is restarted. +Time without receiving a packet after which the client reconnects to the server. -Locally configured values take precedence over server-pushed keepalive values. +A server-pushed OpenVPN `ping-restart` value overrides this value. -Disabled by default. +The value must use whole seconds. + +When empty, `120s` is used for UDP connections with pull enabled until the +server pushes another value. No default receive timeout is used for TCP. ### renegotiate_interval OpenVPN TLS renegotiation interval. -If empty or set to `0s`, the OpenVPN default `1h` is used. +When empty, the OpenVPN default `1h` is used. ### explicit_exit_notify Number of OpenVPN exit notifications sent when closing a UDP connection. -Disabled when `0`. At most `10` notifications are sent. +Notifications are sent one second apart. Disabled when `0`. ### system @@ -486,11 +494,9 @@ OpenVPN interface MTU. When empty, `1500` is used until a server-pushed MTU is received. -### udp_timeout +## UDP NAT Fields -UDP NAT expiration time. - -`5m` is used by default. +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. ## Dial Fields diff --git a/docs/configuration/endpoint/openvpn-client.zh.md b/docs/configuration/endpoint/openvpn-client.zh.md index bc48ecab6..f34631f16 100644 --- a/docs/configuration/endpoint/openvpn-client.zh.md +++ b/docs/configuration/endpoint/openvpn-client.zh.md @@ -69,14 +69,15 @@ "route_metric": 0, "redirect_gateway": false, "redirect_gateway_flags": [], - "keepalive_interval": "", - "keepalive_timeout": "", + "ping_interval": "", + "ping_restart": "", "renegotiate_interval": "", "explicit_exit_notify": 0, "system": false, "name": "", "mtu": 1500, - "udp_timeout": "", + + ... // UDP NAT 字段 ... // 拨号字段 } @@ -342,7 +343,7 @@ OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连 这可以避免 TCP packet 在 OpenVPN 封装后超过 path MTU。 -设为 `0` 时禁用。 +为空时使用上游 OpenVPN 默认值:配置了 `fragment` 时使用其值;否则默认 tunnel MTU 使用 `1492`,自定义 tunnel MTU 使用该 MTU。 ### fragment @@ -378,9 +379,10 @@ Compression 可能削弱流量机密性。仅在服务器要求时启用。 ### route_no_pull -忽略服务器推送的 route、route gateway 和 redirect-gateway 选项。 +忽略服务器推送的 route、DNS 和 DHCP 设置、route metric、`redirect-gateway`、 +`redirect-private`、`block-ipv6` 和 `block-outside-dns`。 -仍会接受其他推送选项,并继续使用本地配置的 `routes`。 +仍会使用接口配置、topology、tunnel MTU、`route-gateway` 和本地配置的 route。 默认禁用。 @@ -388,7 +390,7 @@ Compression 可能削弱流量机密性。仅在服务器要求时启用。 服务器推送选项的有序 pull filter 列表。 -应用第一个 `text` 为完整推送选项大小写不敏感前缀的 filter。未匹配任何 filter 的选项会被接受。 +应用第一个 `text` 为完整推送选项大小写敏感前缀的 filter。未匹配任何 filter 的选项会被接受。 ### pull_filters.action @@ -402,7 +404,7 @@ Filter action,可选值为 `accept`、`ignore` 或 `reject`。 ==必填== -用于匹配推送选项名称和值的大小写不敏感前缀。 +用于匹配推送选项名称和值的大小写敏感前缀。 例如,`route ` 会匹配推送的 IPv4 route 选项,但不会匹配 `route-gateway`。 @@ -438,33 +440,37 @@ OpenVPN `redirect-gateway` flag。 默认为空。 -### keepalive_interval +### ping_interval -发送 OpenVPN keepalive ping packet 的间隔。 +客户端未向服务器发送任何 packet 时,发送 data channel ping 的间隔。 -本地配置值优先于服务器推送的 keepalive 值。 +服务器推送的 OpenVPN `ping` 值优先于该值。 + +该值必须使用整秒。 默认禁用。 -### keepalive_timeout +### ping_restart -未接收 OpenVPN 流量后重新启动连接的时间。 +客户端未收到任何 packet 后重新连接服务器的时间。 -本地配置值优先于服务器推送的 keepalive 值。 +服务器推送的 OpenVPN `ping-restart` 值优先于该值。 -默认禁用。 +该值必须使用整秒。 + +为空时,启用了 pull 的 UDP 连接会使用 `120s`,直到服务器推送其他值。TCP 不使用默认接收超时。 ### renegotiate_interval OpenVPN TLS 重新协商间隔。 -如果为空或设为 `0s`,使用 OpenVPN 默认值 `1h`。 +为空时使用 OpenVPN 默认值 `1h`。 ### explicit_exit_notify 关闭 UDP 连接时发送的 OpenVPN exit notification 数量。 -设为 `0` 时禁用。最多发送 `10` 个 notification。 +Notification 之间间隔一秒。设为 `0` 时禁用。 ### system @@ -486,11 +492,9 @@ OpenVPN 接口 MTU。 为空时使用服务器推送的 MTU;收到服务器配置前使用 `1500`。 -### udp_timeout +## UDP NAT 字段 -UDP NAT 过期时间。 - -默认使用 `5m`。 +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 ## 拨号字段 diff --git a/docs/configuration/endpoint/openvpn-server.md b/docs/configuration/endpoint/openvpn-server.md index 583d61262..43ee713fc 100644 --- a/docs/configuration/endpoint/openvpn-server.md +++ b/docs/configuration/endpoint/openvpn-server.md @@ -18,6 +18,7 @@ "max_clients": 1024, "address": [], "topology": "subnet", + "duplicate_cn": false, "users": [ { "username": "", @@ -36,7 +37,8 @@ "type": "tls_crypt", "key": [], "key_path": "", - "direction": "" + "direction": "", + "force_cookie": false } }, "data_ciphers": [], @@ -47,12 +49,16 @@ "dns": [], "redirect_gateway": false, "redirect_gateway_flags": [], - "block_outside_dns": false + "block_outside_dns": false, + "ping_interval": "", + "ping_restart": "" }, - "keepalive_interval": "", - "keepalive_timeout": "", + "ping_interval": "", + "ping_restart": "", "renegotiate_interval": "", - "udp_timeout": "" + "handshake_window": "1m", + + ... // UDP NAT Fields } ``` @@ -62,7 +68,7 @@ ## Listen Fields -See [Listen Fields](/configuration/shared/listen/) for details. +See [Listen Fields](/configuration/shared/listen/) for details. `udp_timeout` is part of the [UDP NAT Fields](#udp-nat-fields) below. ## Fields @@ -120,6 +126,14 @@ OpenVPN topology pushed to clients, one of `subnet`, `p2p` or `net30`. `subnet` will be used by default. +### duplicate_cn + +Allow multiple active clients with the same authenticated certificate common name or username. + +When disabled, a newly authenticated session replaces the existing session with the same identity and reuses its tunnel address when available. + +Disabled by default. + ### users List of OpenVPN username/password users. @@ -242,6 +256,15 @@ Only available when `tls.control_wrap.type` is `tls_auth`. If empty, the key is used bidirectionally, matching an omitted `key-direction` on both peers. +### tls.control_wrap.force_cookie + +Require `tls-crypt-v2` clients over UDP to support stateless session cookies. + +Only available when `tls.control_wrap.type` is `tls_crypt_v2`. When disabled, +clients without cookie support are accepted using the upstream `allow-noncookie` behavior. + +Disabled by default. + ### data_ciphers Allowed OpenVPN data channel ciphers. @@ -294,15 +317,45 @@ Only available when `push.redirect_gateway` is enabled. Push `block-outside-dns` to clients, which blocks DNS queries outside the VPN on Windows clients. -### keepalive_interval +### push.ping_interval -OpenVPN keepalive ping interval to push to clients. +OpenVPN `ping` interval pushed to clients. + +After the interval passes without sending a packet, the client sends a data-channel ping to the server. + +The value must use whole seconds. Disabled by default. -### keepalive_timeout +### push.ping_restart -OpenVPN keepalive ping timeout to push to clients. +OpenVPN `ping-restart` timeout pushed to clients. + +After the timeout passes without receiving a packet, the client reconnects to the server. + +The value must use whole seconds. + +Disabled by default. + +### ping_interval + +Interval after which the server sends a data-channel ping when no packet has been sent to a client. + +This value applies to the server. Use `push.ping_interval` to configure clients. + +The value must use whole seconds. + +Disabled by default. + +### ping_restart + +Time without receiving a packet after which the server closes the client session. + +This value applies to the server. Use `push.ping_restart` to configure clients. + +The server timeout should be longer than the client timeout so the client can reconnect before the server discards its session. + +The value must use whole seconds. Disabled by default. @@ -310,10 +363,16 @@ Disabled by default. OpenVPN TLS renegotiation interval. -If empty or set to `0s`, the OpenVPN default `1h` is used. +When empty, the OpenVPN default `1h` is used. -### udp_timeout +### handshake_window -UDP NAT expiration time for traffic through the OpenVPN interface. +Maximum time allowed for the initial TLS handshake and each TLS renegotiation. -`5m` will be used by default. +`1m` is used by default. + +## UDP NAT Fields + +These fields configure UDP sessions for traffic through the OpenVPN interface. + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/endpoint/openvpn-server.zh.md b/docs/configuration/endpoint/openvpn-server.zh.md index 9cd9ff766..f9e6fdf52 100644 --- a/docs/configuration/endpoint/openvpn-server.zh.md +++ b/docs/configuration/endpoint/openvpn-server.zh.md @@ -18,6 +18,7 @@ "max_clients": 1024, "address": [], "topology": "subnet", + "duplicate_cn": false, "users": [ { "username": "", @@ -36,7 +37,8 @@ "type": "tls_crypt", "key": [], "key_path": "", - "direction": "" + "direction": "", + "force_cookie": false } }, "data_ciphers": [], @@ -47,12 +49,16 @@ "dns": [], "redirect_gateway": false, "redirect_gateway_flags": [], - "block_outside_dns": false + "block_outside_dns": false, + "ping_interval": "", + "ping_restart": "" }, - "keepalive_interval": "", - "keepalive_timeout": "", + "ping_interval": "", + "ping_restart": "", "renegotiate_interval": "", - "udp_timeout": "" + "handshake_window": "1m", + + ... // UDP NAT 字段 } ``` @@ -62,7 +68,7 @@ ## 监听字段 -参阅 [监听字段](/zh/configuration/shared/listen/)。 +参阅 [监听字段](/zh/configuration/shared/listen/)。`udp_timeout` 属于下方的 [UDP NAT 字段](#udp-nat-字段)。 ## 字段 @@ -120,6 +126,14 @@ OpenVPN 服务器地址前缀列表。 默认使用 `subnet`。 +### duplicate_cn + +允许具有相同认证证书 common name 或用户名的多个客户端同时在线。 + +禁用时,新认证的会话会替换具有相同身份的现有会话,并在可用时复用其 tunnel 地址。 + +默认禁用。 + ### users OpenVPN 用户名/密码用户列表。 @@ -242,6 +256,14 @@ OpenVPN `tls-auth` 密钥方向,`server` 或 `client` 之一。 如果为空,密钥被双向使用,与两端均省略 `key-direction` 的行为一致。 +### tls.control_wrap.force_cookie + +要求 UDP 上的 `tls-crypt-v2` 客户端支持无状态 session cookie。 + +仅当 `tls.control_wrap.type` 为 `tls_crypt_v2` 时可用。禁用时,不支持 cookie 的客户端会按照上游 `allow-noncookie` 行为被接受。 + +默认禁用。 + ### data_ciphers 允许的 OpenVPN 数据信道加密方式。 @@ -294,15 +316,45 @@ IPv4 和 IPv6 前缀可以混用。 向客户端推送 `block-outside-dns`,在 Windows 客户端上阻止 VPN 之外的 DNS 查询。 -### keepalive_interval +### push.ping_interval -推送给客户端的 OpenVPN keepalive ping 间隔。 +推送给客户端的 OpenVPN `ping` 间隔。 + +在该间隔内未发送任何 packet 后,客户端会向服务器发送一个 data channel ping。 + +该值必须使用整秒。 默认禁用。 -### keepalive_timeout +### push.ping_restart -推送给客户端的 OpenVPN keepalive ping 超时。 +推送给客户端的 OpenVPN `ping-restart` 超时。 + +在该超时时间内未收到任何 packet 后,客户端会重新连接服务器。 + +该值必须使用整秒。 + +默认禁用。 + +### ping_interval + +服务器未向客户端发送任何 packet 时,发送 data channel ping 的间隔。 + +该值应用于服务器。使用 `push.ping_interval` 配置客户端。 + +该值必须使用整秒。 + +默认禁用。 + +### ping_restart + +服务器未收到任何 packet 后关闭客户端会话的时间。 + +该值应用于服务器。使用 `push.ping_restart` 配置客户端。 + +服务器超时应长于客户端超时,以便客户端在服务器丢弃其会话前重新连接。 + +该值必须使用整秒。 默认禁用。 @@ -310,10 +362,16 @@ IPv4 和 IPv6 前缀可以混用。 OpenVPN TLS 重协商间隔。 -如果为空或设为 `0s`,使用 OpenVPN 默认值 `1h`。 +为空时使用 OpenVPN 默认值 `1h`。 -### udp_timeout +### handshake_window -通过 OpenVPN 接口的流量的 UDP NAT 过期时间。 +初始 TLS 握手和每次 TLS 重协商允许使用的最长时间。 -默认使用 `5m`。 +默认使用 `1m`。 + +## UDP NAT 字段 + +这些字段配置通过 OpenVPN 接口的流量的 UDP 会话。 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/go.mod b/go.mod index 3c5f747f5..0bc3be05a 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba - github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109 + github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 diff --git a/go.sum b/go.sum index 4afa849fa..e0bc38e90 100644 --- a/go.sum +++ b/go.sum @@ -287,8 +287,8 @@ github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONf github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba h1:S87Ej/jFssn0qhPF1ExF0YIV0USfGZF2If6kSjGLPt8= github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109 h1:j1cyRquNhaFXHkkbQ5oyldS75jhv0WrBToDXUYPVSOw= -github.com/sagernet/sing-openvpn v0.0.0-20260717092601-0db6ebb53109/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 h1:IfZqHohaWz13eqc6SAUHkmP9xhMpMBDIShl7OtgRd5Y= +github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= diff --git a/option/openconnect.go b/option/openconnect.go index 121542ff6..28cef8823 100644 --- a/option/openconnect.go +++ b/option/openconnect.go @@ -6,6 +6,10 @@ type OpenConnectEndpointOptions struct { DialerOptions System bool `json:"system,omitempty"` Name string `json:"name,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` Server string `json:"server"` Flavor string `json:"flavor,omitempty"` Username string `json:"username,omitempty"` diff --git a/option/openvpn.go b/option/openvpn.go index 68bde24d1..538598f32 100644 --- a/option/openvpn.go +++ b/option/openvpn.go @@ -8,9 +8,12 @@ import ( ) type OpenVPNEndpointOptions struct { - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - MTU uint32 `json:"mtu,omitempty"` + 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 OpenVPNClientEndpointOptions struct { @@ -41,8 +44,8 @@ type OpenVPNClientEndpointOptions struct { RouteMetric int `json:"route_metric,omitempty"` RedirectGateway bool `json:"redirect_gateway,omitempty"` RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` - KeepaliveInterval badoption.Duration `json:"keepalive_interval,omitempty"` - KeepaliveTimeout badoption.Duration `json:"keepalive_timeout,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` ExplicitExitNotify uint32 `json:"explicit_exit_notify,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` @@ -55,15 +58,17 @@ type OpenVPNServerEndpointOptions struct { MaxClients int `json:"max_clients,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address"` Topology string `json:"topology,omitempty"` + DuplicateCN bool `json:"duplicate_cn,omitempty"` Users []auth.User `json:"users,omitempty"` TLS *OpenVPNInboundTLSOptions `json:"tls,omitempty"` DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` DataCiphersFallback string `json:"data_ciphers_fallback,omitempty"` Auth string `json:"auth,omitempty"` Push *OpenVPNPushOptions `json:"push,omitempty"` - KeepaliveInterval badoption.Duration `json:"keepalive_interval,omitempty"` - KeepaliveTimeout badoption.Duration `json:"keepalive_timeout,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` + HandshakeWindow badoption.Duration `json:"handshake_window,omitempty"` } type OpenVPNRemoteOptions struct { @@ -97,14 +102,14 @@ type OpenVPNOutboundTLSOptions struct { } type OpenVPNInboundTLSOptions struct { - Certificate badoption.Listable[string] `json:"certificate,omitempty"` - CertificatePath string `json:"certificate_path,omitempty"` - Key badoption.Listable[string] `json:"key,omitempty"` - KeyPath string `json:"key_path,omitempty"` - ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` - ClientCertificatePath string `json:"client_certificate_path,omitempty"` - VerifyClientCertificate string `json:"verify_client_certificate,omitempty"` - ControlWrap *OpenVPNControlWrapOptions `json:"control_wrap,omitempty"` + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + VerifyClientCertificate string `json:"verify_client_certificate,omitempty"` + ControlWrap *OpenVPNInboundControlWrapOptions `json:"control_wrap,omitempty"` } type OpenVPNControlWrapOptions struct { @@ -114,10 +119,20 @@ type OpenVPNControlWrapOptions struct { Direction string `json:"direction,omitempty"` } +type OpenVPNInboundControlWrapOptions struct { + Type string `json:"type,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + Direction string `json:"direction,omitempty"` + ForceCookie bool `json:"force_cookie,omitempty"` +} + type OpenVPNPushOptions struct { Routes badoption.Listable[netip.Prefix] `json:"routes,omitempty"` DNS badoption.Listable[netip.Addr] `json:"dns,omitempty"` RedirectGateway bool `json:"redirect_gateway,omitempty"` RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` BlockOutsideDNS bool `json:"block_outside_dns,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` } diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index b39b6cff6..84f8820a0 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -120,15 +120,24 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL if err != nil { return nil, err } + udpTimeout := C.UDPTimeout + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } + networkManager := service.FromContext[adapter.NetworkManager](ctx) device, err := openconnecttransport.NewDevice(openconnecttransport.DeviceOptions{ - Context: ctx, - Logger: logger, - System: options.System, - Handler: openConnectEndpoint, - UDPTimeout: C.UDPTimeout, - ICMPTimeout: C.ICMPTimeout, - Name: options.Name, - MTU: openconnecttransport.DefaultMTU, + Context: ctx, + Logger: logger, + System: options.System, + Handler: openConnectEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: networkManager.InterfaceFinder(), + Name: options.Name, + MTU: openconnecttransport.DefaultMTU, Configuration: openconnecttransport.Configuration{ MTU: openconnecttransport.DefaultMTU, }, diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index 25334535e..d3b4323d7 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -116,14 +116,18 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co udpTimeout = time.Duration(options.UDPTimeout) } device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ - Context: ctx, - Logger: logger, - System: options.System, - Handler: clientEndpoint, - UDPTimeout: udpTimeout, - ICMPTimeout: C.ICMPTimeout, - Name: options.Name, - MTU: options.MTU, + Context: ctx, + Logger: logger, + System: options.System, + Handler: clientEndpoint, + 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, + MTU: options.MTU, Configuration: ovpntransport.Configuration{ MTU: options.MTU, Address: clientOptions.Tunnel.LocalAddress, @@ -260,11 +264,11 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint Context: c.loopContext, Mode: ovpn.ModeTLS, Transport: ovpn.ClientTransportOptions{ - Remotes: remotes, - RemoteRandom: options.RemoteRandom, - Protocol: protocol, - ExplicitExitNotify: options.ExplicitExitNotify, - DialContext: c.transportDialContext, + Remotes: remotes, + RemoteRandom: options.RemoteRandom, + Protocol: protocol, + ExplicitExitNotify: options.ExplicitExitNotify, + DialContextWithAddressIndex: c.transportDialContextWithAddressIndex, }, DataChannel: ovpn.ClientDataChannelOptions{ MTU: options.MTU, @@ -301,8 +305,8 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint }, Timing: ovpn.ClientTimingOptions{ RenegotiationInterval: time.Duration(options.RenegotiateInterval), - PingInterval: time.Duration(options.KeepaliveInterval), - PingRestart: time.Duration(options.KeepaliveTimeout), + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), }, KeyDirection: keyDirection, OnTunnelConfiguration: c.handleTunnelConfiguration, @@ -310,21 +314,21 @@ func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpoint }, nil } -func (c *ClientEndpoint) transportDialContext(ctx context.Context, network string, address string) (net.Conn, error) { +func (c *ClientEndpoint) transportDialContextWithAddressIndex(ctx context.Context, network string, address string, addressIndex int) (net.Conn, error) { destination := M.ParseSocksaddr(address) - var ( - connection net.Conn - err error - ) if destination.IsDomain() { destinationAddresses, lookupErr := c.dnsRouter.Lookup(ctx, destination.Fqdn, c.queryOptions) if lookupErr != nil { return nil, lookupErr } - connection, err = N.DialSerial(ctx, c.outboundDialer, network, destination, destinationAddresses) - } else { - connection, err = c.outboundDialer.DialContext(ctx, network, destination) + if addressIndex < 0 || addressIndex >= len(destinationAddresses) { + return nil, ovpn.ErrRemoteAddressExhausted + } + destination = M.SocksaddrFrom(destinationAddresses[addressIndex], destination.Port) + } else if addressIndex != 0 { + return nil, ovpn.ErrRemoteAddressExhausted } + connection, err := c.outboundDialer.DialContext(ctx, network, destination) if err != nil { return nil, err } diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go index 98138d2ca..0001f1831 100644 --- a/protocol/openvpn/server.go +++ b/protocol/openvpn/server.go @@ -91,6 +91,7 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co } serverOptions.Context = loopContext serverOptions.Authentication.Authenticator = authenticatorFromUsers(options.Users) + serverOptions.Authentication.DuplicateCN = options.DuplicateCN serverOptions.Logger = logger serverEndpoint.serverOptions = serverOptions udpTimeout := C.UDPTimeout @@ -102,14 +103,18 @@ func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.Co deviceRoutes = append(deviceRoutes, ovpntransport.Route{Prefix: prefix.Masked()}) } device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ - Context: ctx, - Logger: logger, - System: options.System, - Handler: serverEndpoint, - UDPTimeout: udpTimeout, - ICMPTimeout: C.ICMPTimeout, - Name: options.Name, - MTU: options.MTU, + Context: ctx, + Logger: logger, + System: options.System, + Handler: serverEndpoint, + 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, + MTU: options.MTU, Configuration: ovpntransport.Configuration{ MTU: options.MTU, Address: options.Address, @@ -299,6 +304,9 @@ func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.Serve TLS: tlsOptions, Timing: ovpn.ServerTimingOptions{ RenegotiationInterval: time.Duration(options.RenegotiateInterval), + HandWindow: time.Duration(options.HandshakeWindow), + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), }, } applyServerPushOptions(&serverOptions, options) @@ -331,13 +339,16 @@ func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.Server } keyDirection := -1 controlWrap := options.ControlWrap - if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "") { + if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "" || controlWrap.ForceCookie) { wrapKey, wrapErr := requiredMaterialSource("tls.control_wrap.key", controlWrap.Key, controlWrap.KeyPath) if wrapErr != nil { return ovpn.ServerTLSOptions{}, 0, wrapErr } switch controlWrap.Type { case "tls_auth": + if controlWrap.ForceCookie { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.force_cookie` is only supported by `tls_crypt_v2`") + } keyDirection, err = keyDirectionValue(controlWrap.Direction) if err != nil { return ovpn.ServerTLSOptions{}, 0, err @@ -348,9 +359,13 @@ func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.Server return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`") } if controlWrap.Type == "tls_crypt" { + if controlWrap.ForceCookie { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.force_cookie` is only supported by `tls_crypt_v2`") + } tlsOptions.Crypt = wrapKey } else { tlsOptions.CryptV2 = wrapKey + tlsOptions.CryptV2ForceCookie = controlWrap.ForceCookie } case "": return ovpn.ServerTLSOptions{}, 0, E.New("missing OpenVPN control wrap type") @@ -382,16 +397,14 @@ func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.Op Topology: topology, LocalAddress: localAddresses, } - serverOptions.Push = ovpn.ServerPushOptions{ - PingInterval: time.Duration(options.KeepaliveInterval), - PingRestart: time.Duration(options.KeepaliveTimeout), - } if options.Push == nil { return } serverOptions.Push.Routes = slices.Clone(options.Push.Routes) serverOptions.Push.DNS = slices.Clone(options.Push.DNS) serverOptions.Push.BlockOutsideDNS = options.Push.BlockOutsideDNS + serverOptions.Push.PingInterval = time.Duration(options.Push.PingInterval) + serverOptions.Push.PingRestart = time.Duration(options.Push.PingRestart) if options.Push.RedirectGateway { serverOptions.Push.RedirectGateway = true if len(options.Push.RedirectGatewayFlags) > 0 { diff --git a/test/endpoint_udp_nat_test.go b/test/endpoint_udp_nat_test.go new file mode 100644 index 000000000..7dae4b026 --- /dev/null +++ b/test/endpoint_udp_nat_test.go @@ -0,0 +1,261 @@ +package main + +import ( + "context" + "net" + "net/netip" + "sync/atomic" + "testing" + "time" + + openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" + openvpntransport "github.com/sagernet/sing-box/transport/openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "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 + payload []byte +} + +type endpointUDPNATSession struct { + id uint64 + conn N.PacketConn + closed chan struct{} +} + +type endpointUDPNATHandler struct { + nextSessionID atomic.Uint64 + packets chan endpointUDPNATPacket +} + +func (h *endpointUDPNATHandler) JudgeFlow(uint8, netip.AddrPort, netip.AddrPort, []byte) tun.FlowVerdict { + return tun.FlowVerdict{Action: tun.ActionAccept} +} + +func (h *endpointUDPNATHandler) NewConnectionEx(_ context.Context, conn net.Conn, _ M.Socksaddr, _ M.Socksaddr, onClose N.CloseHandlerFunc) { + err := conn.Close() + if onClose != nil { + onClose(err) + } +} + +func (h *endpointUDPNATHandler) NewPacketConnectionEx(_ context.Context, conn N.PacketConn, _ M.Socksaddr, _ M.Socksaddr, onClose N.CloseHandlerFunc) { + session := &endpointUDPNATSession{ + id: h.nextSessionID.Add(1), + conn: conn, + closed: make(chan struct{}), + } + go func() { + defer close(session.closed) + for { + packetBuffer := buf.NewPacket() + destination, err := conn.ReadPacket(packetBuffer) + if err != nil { + packetBuffer.Release() + if onClose != nil { + onClose(err) + } + return + } + payload := append([]byte(nil), packetBuffer.Bytes()...) + packetBuffer.Release() + h.packets <- endpointUDPNATPacket{ + session: session, + destination: destination, + payload: payload, + } + } + }() +} + +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() + if !tun.WithGVisor { + t.Skip("requires gVisor") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + handler := &endpointUDPNATHandler{packets: make(chan endpointUDPNATPacket, 4)} + device, err := newDevice(ctx, handler) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, device.close()) + }) + outboundPackets := make(chan []byte, 4) + device.setPacketWriter(func(packetBuffers []*buf.Buffer) error { + for _, packetBuffer := range packetBuffers { + payload, isUDP := endpointUDPPayload(packetBuffer.Bytes()) + packetBuffer.Release() + if isUDP { + outboundPackets <- payload + } + } + return nil + }) + + 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")) + firstPacket := waitEndpointUDPNATPacket(t, handler.packets) + require.Equal(t, M.SocksaddrFromNetIP(firstDestination), firstPacket.destination) + require.Equal(t, []byte("request-one"), firstPacket.payload) + + require.NoError(t, firstPacket.session.conn.WritePacket(buf.As([]byte("blocked")), M.SocksaddrFromNetIP(secondDestination))) + require.NoError(t, firstPacket.session.conn.WritePacket(buf.As([]byte("allowed-one")), M.SocksaddrFromNetIP(firstDestination))) + require.Equal(t, []byte("allowed-one"), waitEndpointUDPResponse(t, outboundPackets)) + + writeEndpointUDPPacket(t, device, 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) + require.NotEqual(t, firstPacket.session.id, secondPacket.session.id) + select { + case <-firstPacket.session.closed: + case <-time.After(5 * time.Second): + t.Fatal("first UDP NAT session was not evicted at max size") + } + + require.NoError(t, secondPacket.session.conn.WritePacket(buf.As([]byte("allowed-two")), M.SocksaddrFromNetIP(secondDestination))) + require.Equal(t, []byte("allowed-two"), waitEndpointUDPResponse(t, outboundPackets)) +} + +func writeEndpointUDPPacket(t *testing.T, device endpointUDPNATDevice, source netip.AddrPort, destination netip.AddrPort, payload []byte) { + t.Helper() + packet := make([]byte, header.IPv4MinimumSize+header.UDPMinimumSize+len(payload)) + ipHeader := header.IPv4(packet) + ipHeader.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(packet)), + TTL: 64, + Protocol: uint8(header.UDPProtocolNumber), + SrcAddr: source.Addr(), + DstAddr: destination.Addr(), + }) + ipHeader.SetChecksum(^ipHeader.CalculateChecksum()) + udpHeader := header.UDP(packet[header.IPv4MinimumSize:]) + udpHeader.Encode(&header.UDPFields{ + SrcPort: source.Port(), + DstPort: destination.Port(), + Length: uint16(header.UDPMinimumSize + len(payload)), + }) + copy(udpHeader.Payload(), payload) + packetBuffer := buf.As(packet) + require.NoError(t, device.writeInboundBuffers([]*buf.Buffer{packetBuffer})) + packetBuffer.Release() +} + +func endpointUDPPayload(packet []byte) ([]byte, bool) { + if len(packet) < header.IPv4MinimumSize { + return nil, false + } + ipHeader := header.IPv4(packet) + if !ipHeader.IsValid(len(packet)) || ipHeader.Protocol() != uint8(header.UDPProtocolNumber) { + return nil, false + } + udpPayload := ipHeader.Payload() + if len(udpPayload) < header.UDPMinimumSize { + return nil, false + } + udpHeader := header.UDP(udpPayload) + return append([]byte(nil), udpHeader.Payload()...), true +} + +func waitEndpointUDPNATPacket(t *testing.T, packets <-chan endpointUDPNATPacket) endpointUDPNATPacket { + t.Helper() + select { + case packet := <-packets: + return packet + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UDP NAT packet") + return endpointUDPNATPacket{} + } +} + +func waitEndpointUDPResponse(t *testing.T, packets <-chan []byte) []byte { + t.Helper() + select { + case packet := <-packets: + return packet + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UDP response") + return nil + } +} diff --git a/test/go.mod b/test/go.mod index 7fb11fcd0..5bb65a5eb 100644 --- a/test/go.mod +++ b/test/go.mod @@ -12,10 +12,11 @@ require ( github.com/gofrs/uuid/v5 v5.4.0 github.com/opencontainers/image-spec v1.1.0 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 + github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 + github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 @@ -152,15 +153,14 @@ require ( github.com/sagernet/nftables v0.3.0-mod.4 // indirect github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect github.com/sagernet/sing-mux v0.3.5 // indirect - github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e // indirect - github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d // indirect + github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba // indirect + github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 // indirect github.com/sagernet/sing-shadowtls v0.2.1 // indirect github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb // indirect - github.com/sagernet/sing-tun v0.8.12-0.20260717024008-39eed1f6361d // indirect github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb // indirect github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a // indirect github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect github.com/smallstep/pkcs7 v0.1.1 // indirect diff --git a/test/go.sum b/test/go.sum index 8b2e18e6d..80e57b66f 100644 --- a/test/go.sum +++ b/test/go.sum @@ -298,16 +298,16 @@ github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4IC github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8 h1:dyRIj+MZ2rc9JVzJoG04jxu+MpvHrLIZLJr0QjNAMGg= -github.com/sagernet/sing v0.8.12-0.20260717023913-84ab32b56cb8/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 h1:WyL7xI7h+mNF6we/arqVNDEB+0iK2SwJRjim8rGydDM= +github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e h1:Kgcf16uKnxBNJMsR8MaWlORtOV2/qi+6yH830+b7Yfc= -github.com/sagernet/sing-openconnect v0.0.0-20260717061548-458a8732933e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= -github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d h1:KGvybsWqE+Qkd9Ns2AzrrBSyNfbBJ7IZZqgj+oWa6SM= -github.com/sagernet/sing-openvpn v0.0.0-20260717055507-7e569eca5e4d/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= +github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba h1:S87Ej/jFssn0qhPF1ExF0YIV0USfGZF2If6kSjGLPt8= +github.com/sagernet/sing-openconnect v0.0.0-20260717081856-cf2c71a71aba/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247 h1:IfZqHohaWz13eqc6SAUHkmP9xhMpMBDIShl7OtgRd5Y= +github.com/sagernet/sing-openvpn v0.0.0-20260718013246-3cd8a7b83247/go.mod h1:CmTGnS5ijVSqFQV1dTq4WvFLUoz7bk9xasBPsX8NcYo= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= @@ -326,8 +326,8 @@ github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkV github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544 h1:j2tab0dGHutfclhwZxrkSDMXwGXtozIo5BV4DgwS+1Q= -github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717024045-1edfbb9ee544/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a h1:mORXldIuzgU8Bk6n9KpOmLv1rV7Ta8us+lZ9JSv2eBw= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWnRmeNfPE1tWXRZIFgKJd5BH5okoDHKZkkI5bw= github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= diff --git a/test/openconnect_test.go b/test/openconnect_test.go index 771730434..6b494478a 100644 --- a/test/openconnect_test.go +++ b/test/openconnect_test.go @@ -21,6 +21,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" 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" @@ -125,6 +126,8 @@ func TestOpenConnectDockerInterop(t *testing.T) { require.Nil(subtest, status.AuthForm) err := exchangeOpenConnectTCPEcho(endpoint, 256*1024, 30*time.Second) require.NoError(subtest, err) + err = exchangeOpenConnectUDPEcho(endpoint, 1400, 30*time.Second) + require.NoError(subtest, err) }) t.Run("interactive_password_auth", func(subtest *testing.T) { @@ -174,11 +177,15 @@ func TestOpenConnectDockerInterop(t *testing.T) { func openConnectInstanceOptions(server string, certificateAuthorityPath string, username string, password string) option.Options { endpointOptions := option.OpenConnectEndpointOptions{ - Server: server, - Flavor: "anyconnect", - Username: username, - Password: password, - NoUDP: true, + Server: server, + Flavor: "anyconnect", + Username: username, + Password: password, + NoUDP: true, + UDPTimeout: badoption.Duration(time.Minute), + UDPMapping: option.UDPNATBehaviorAddressDependent, + UDPFiltering: option.UDPNATBehaviorAddressAndPortDependent, + UDPNATMax: 128, TLS: option.OpenConnectTLSOptions{ CertificateAuthorityPath: certificateAuthorityPath, }, @@ -318,6 +325,38 @@ func exchangeOpenConnectTCPEcho(endpoint adapter.OpenConnectEndpoint, payloadSiz return nil } +func exchangeOpenConnectUDPEcho(endpoint adapter.OpenConnectEndpoint, payloadSize int, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + conn, err := endpoint.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddrHostPort(openConnectTunnelAddress, openConnectEchoPort)) + if err != nil { + return E.Cause(err, "dial ocserv tunnel UDP echo") + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(timeout)) + if err != nil { + return E.Cause(err, "set ocserv tunnel UDP echo deadline") + } + payload := make([]byte, payloadSize) + _, err = rand.Read(payload) + if err != nil { + return E.Cause(err, "generate ocserv tunnel UDP echo payload") + } + _, err = conn.Write(payload) + if err != nil { + return E.Cause(err, "write ocserv tunnel UDP echo payload") + } + response := make([]byte, payloadSize+1) + responseLength, err := conn.Read(response) + if err != nil { + return E.Cause(err, "read ocserv tunnel UDP echo payload") + } + if !bytes.Equal(response[:responseLength], payload) { + return E.New("ocserv tunnel UDP echo payload mismatch") + } + return nil +} + func waitForOpenConnectTCPEcho(t *testing.T, endpoint adapter.OpenConnectEndpoint, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) diff --git a/test/openvpn_test.go b/test/openvpn_test.go index 1d1b02983..09f17c6c0 100644 --- a/test/openvpn_test.go +++ b/test/openvpn_test.go @@ -150,10 +150,16 @@ func runOpenVPNSelfToSelf(t *testing.T, testCase openVPNSelfCase) { ClientCertificatePath: certificates.caPath, }, } + serverOptions.UDPMapping = option.UDPNATBehaviorAddressDependent + serverOptions.UDPFiltering = option.UDPNATBehaviorAddressAndPortDependent + serverOptions.UDPNATMax = 128 clientOptions := newOpenVPNTLSClientOptions(testCase.protocol, openVPNPort, certificates.caPath, certificates.clientCertPath, certificates.clientKeyPath) + clientOptions.UDPMapping = option.UDPNATBehaviorAddressDependent + clientOptions.UDPFiltering = option.UDPNATBehaviorAddressAndPortDependent + clientOptions.UDPNATMax = 128 if testCase.tlsCrypt { tlsCryptKeyPath := writeOpenVPNStaticKeyFile(t, createOpenVPNStaticKey(t)) - serverOptions.TLS.ControlWrap = &option.OpenVPNControlWrapOptions{ + serverOptions.TLS.ControlWrap = &option.OpenVPNInboundControlWrapOptions{ Type: "tls_crypt", KeyPath: tlsCryptKeyPath, } @@ -538,8 +544,12 @@ func TestOpenVPNClientReconnectSelfToSelf(t *testing.T) { KeyPath: certificates.serverKeyPath, ClientCertificatePath: certificates.caPath, }, - KeepaliveInterval: badoption.Duration(time.Second), - KeepaliveTimeout: badoption.Duration(2 * time.Second), + PingInterval: badoption.Duration(time.Second), + PingRestart: badoption.Duration(4 * time.Second), + Push: &option.OpenVPNPushOptions{ + PingInterval: badoption.Duration(time.Second), + PingRestart: badoption.Duration(2 * time.Second), + }, Users: []auth.User{ { Username: openVPNTLSUsername, diff --git a/test/testdata/openconnect/ocserv/echo_server.py b/test/testdata/openconnect/ocserv/echo_server.py index 91099c792..beb934e29 100644 --- a/test/testdata/openconnect/ocserv/echo_server.py +++ b/test/testdata/openconnect/ocserv/echo_server.py @@ -12,11 +12,21 @@ def echo(connection): connection.sendall(data) -listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -listener.bind(("0.0.0.0", 18080)) -listener.listen() +def echo_udp(connection): + while True: + data, address = connection.recvfrom(65536) + connection.sendto(data, address) + + +udp_listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +udp_listener.bind(("0.0.0.0", 18080)) +threading.Thread(target=echo_udp, args=(udp_listener,), daemon=True).start() + +tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +tcp_listener.bind(("0.0.0.0", 18080)) +tcp_listener.listen() print("openconnect echo ready", flush=True) while True: - accepted, _ = listener.accept() + accepted, _ = tcp_listener.accept() threading.Thread(target=echo, args=(accepted,), daemon=True).start() diff --git a/transport/openconnect/device.go b/transport/openconnect/device.go index a3a9219ce..2edcd5e18 100644 --- a/transport/openconnect/device.go +++ b/transport/openconnect/device.go @@ -9,6 +9,7 @@ import ( "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" @@ -36,15 +37,20 @@ type Device interface { } type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - System bool - Handler tun.Handler - UDPTimeout time.Duration - ICMPTimeout time.Duration - Name string - MTU uint32 - Configuration Configuration + 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 + ExcludeInterface []string + Name string + MTU uint32 + Configuration Configuration } type Configuration struct { diff --git a/transport/openconnect/device_stack.go b/transport/openconnect/device_stack.go index 2887c27bf..3df15ebc6 100644 --- a/transport/openconnect/device_stack.go +++ b/transport/openconnect/device_stack.go @@ -37,6 +37,7 @@ type stackDevice struct { endpoint *stackEndpoint inet4Address netip.Addr inet6Address netip.Addr + udpForwarder *tun.UDPForwarder icmpForwarder *tun.ICMPForwarder closeOnce sync.Once } @@ -66,9 +67,17 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } if options.Handler != nil { ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) - ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ - Timeout: options.UDPTimeout, - }).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + ExcludeInterface: options.ExcludeInterface, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + device.udpForwarder = udpForwarder icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) @@ -78,6 +87,12 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } func (d *stackDevice) Start() error { + if d.udpForwarder != nil { + err := d.udpForwarder.Start() + if err != nil { + return err + } + } return nil } @@ -232,6 +247,9 @@ func (d *stackDevice) PortMTU() uint32 { func (d *stackDevice) Close() error { d.closeOnce.Do(func() { close(d.endpoint.done) + if d.udpForwarder != nil { + d.udpForwarder.Close() + } if d.icmpForwarder != nil { d.icmpForwarder.Close() } diff --git a/transport/openconnect/device_system_stack.go b/transport/openconnect/device_system_stack.go index faa9826a9..2042881e9 100644 --- a/transport/openconnect/device_system_stack.go +++ b/transport/openconnect/device_system_stack.go @@ -24,6 +24,8 @@ func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { } stackOptions := options stackOptions.System = false + stackOptions.Name = system.options.Name + stackOptions.ExcludeInterface = []string{system.options.Name} stackDevice, err := newStackDevice(stackOptions) if err != nil { system.Close() @@ -40,6 +42,15 @@ func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { 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 { diff --git a/transport/openvpn/device.go b/transport/openvpn/device.go index 0fe16b853..1553fbe36 100644 --- a/transport/openvpn/device.go +++ b/transport/openvpn/device.go @@ -9,6 +9,7 @@ import ( "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" @@ -36,15 +37,20 @@ type Device interface { } type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - System bool - Handler tun.Handler - UDPTimeout time.Duration - ICMPTimeout time.Duration - Name string - MTU uint32 - Configuration Configuration + 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 + ExcludeInterface []string + Name string + MTU uint32 + Configuration Configuration } type Configuration struct { diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go index 764ebfc26..c13c994af 100644 --- a/transport/openvpn/device_stack.go +++ b/transport/openvpn/device_stack.go @@ -37,6 +37,7 @@ type stackDevice struct { endpoint *stackEndpoint inet4Address netip.Addr inet6Address netip.Addr + udpForwarder *tun.UDPForwarder icmpForwarder *tun.ICMPForwarder logRouteOptions bool closeOnce sync.Once @@ -68,9 +69,17 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } if options.Handler != nil { ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) - ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ - Timeout: options.UDPTimeout, - }).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + ExcludeInterface: options.ExcludeInterface, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + device.udpForwarder = udpForwarder icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) @@ -80,6 +89,12 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { } func (d *stackDevice) Start() error { + if d.udpForwarder != nil { + err := d.udpForwarder.Start() + if err != nil { + return err + } + } return nil } @@ -249,6 +264,9 @@ func (d *stackDevice) PortMTU() uint32 { func (d *stackDevice) Close() error { d.closeOnce.Do(func() { close(d.endpoint.done) + if d.udpForwarder != nil { + d.udpForwarder.Close() + } if d.icmpForwarder != nil { d.icmpForwarder.Close() } diff --git a/transport/openvpn/device_system_stack.go b/transport/openvpn/device_system_stack.go index 3762c2db4..d0d9a9778 100644 --- a/transport/openvpn/device_system_stack.go +++ b/transport/openvpn/device_system_stack.go @@ -24,6 +24,8 @@ func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { } stackOptions := options stackOptions.System = false + stackOptions.Name = system.options.Name + stackOptions.ExcludeInterface = []string{system.options.Name} stackDevice, err := newStackDevice(stackOptions) if err != nil { system.Close() @@ -41,6 +43,15 @@ func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { 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 {