Refactor OpenVPN and OpenConnect endpoints

This commit is contained in:
世界
2026-08-30 17:41:43 +08:00
parent b02ec478c5
commit 6269e1c2b3
25 changed files with 758 additions and 182 deletions
@@ -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.
@@ -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/)了解详情。
+27 -21
View File
@@ -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
@@ -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/)
## 拨号字段
+73 -14
View File
@@ -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.
@@ -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/)。
+1 -1
View File
@@ -48,7 +48,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.20260803041931-6c84c468bea2
github.com/sagernet/sing-shadowsocks v0.2.8
github.com/sagernet/sing-shadowsocks2 v0.2.1
+2 -2
View File
@@ -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.20260803041931-6c84c468bea2 h1:XhJro6+Ou+WOPjfs22m14lY7Sh6sWPm/f0QiyFUgM60=
github.com/sagernet/sing-quic v0.6.4-0.20260803041931-6c84c468bea2/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0=
github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE=
+4
View File
@@ -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"`
+30 -15
View File
@@ -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"`
}
+17 -8
View File
@@ -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,
},
+27 -23
View File
@@ -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
}
+26 -13
View File
@@ -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 {
+261
View File
@@ -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
}
}
+5 -5
View File
@@ -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
+8 -8
View File
@@ -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=
+44 -5
View File
@@ -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)
+13 -3
View File
@@ -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,
+15 -5
View File
@@ -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()
+15 -9
View File
@@ -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 {
+21 -3
View File
@@ -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()
}
@@ -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 {
+15 -9
View File
@@ -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 {
+21 -3
View File
@@ -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()
}
+11
View File
@@ -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 {