From 7ad8567e6747e855e7d89f882345c8f26459be9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 4 Sep 2026 02:19:31 +0800 Subject: [PATCH] Improve idle connection management --- adapter/endpoint.go | 6 + common/urltest/urltest.go | 2 + docs/configuration/endpoint/openconnect.md | 11 ++ docs/configuration/endpoint/openconnect.zh.md | 11 ++ docs/configuration/endpoint/openvpn-client.md | 11 ++ .../endpoint/openvpn-client.zh.md | 11 ++ docs/configuration/endpoint/tailscale.md | 11 ++ docs/configuration/endpoint/tailscale.zh.md | 11 ++ docs/configuration/endpoint/wireguard.md | 11 ++ docs/configuration/endpoint/wireguard.zh.md | 11 ++ docs/schema.json | 12 ++ go.mod | 6 +- go.sum | 12 +- option/openconnect.go | 1 + option/openvpn.go | 1 + option/tailscale.go | 1 + option/wireguard.go | 1 + protocol/anytls/outbound.go | 6 +- protocol/openconnect/client.go | 62 +++++++- protocol/openvpn/client.go | 62 +++++++- protocol/tailscale/endpoint.go | 150 ++++++++++++++++++ protocol/tailscale/port.go | 1 + protocol/wireguard/endpoint.go | 11 ++ route/reference.go | 70 +++++++- transport/wireguard/endpoint.go | 83 ++++++++-- transport/wireguard/port.go | 3 +- 26 files changed, 541 insertions(+), 37 deletions(-) diff --git a/adapter/endpoint.go b/adapter/endpoint.go index 63d7ce9d0..75781f67d 100644 --- a/adapter/endpoint.go +++ b/adapter/endpoint.go @@ -14,6 +14,12 @@ type Endpoint interface { Outbound } +type OnDemandEndpoint interface { + Endpoint + OnDemand() bool + SetKeepIdleConnections(keep bool) +} + type EndpointRegistry interface { option.EndpointOptionsRegistry Create(ctx context.Context, router Router, logger log.ContextLogger, tag string, endpointType string, options any) (Endpoint, error) diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go index 26efe39cb..d04d34b1c 100644 --- a/common/urltest/urltest.go +++ b/common/urltest/urltest.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/sagernet/sing-anytls" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-mux" @@ -85,6 +86,7 @@ func URLTest(ctx context.Context, link string, detour N.Dialer) (uint16, error) if isMultiplexOutbound && multiplexOutbound.MultiplexEnabled() { warmContext := adapter.ContextWithKeepSession(ctx) warmContext = mux.ContextWithKeepSession(warmContext) + warmContext = anytls.ContextWithKeepSession(warmContext) warmContext = contextWithQUICKeepSession(warmContext) warmContext = snell.ContextWithKeepSession(warmContext) _, err := urlTest(warmContext, link, detour) diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md index ef8e3c342..7be3858d8 100644 --- a/docs/configuration/endpoint/openconnect.md +++ b/docs/configuration/endpoint/openconnect.md @@ -2,6 +2,10 @@ !!! question "Since sing-box 1.14.0" +!!! quote "Changes in sing-box 1.15.0" + + :material-plus: [on_demand](#on_demand) + ==Client only== ## Structure @@ -107,6 +111,7 @@ "promote": false } ], + "on_demand": false, ... // Dial Fields } @@ -620,6 +625,12 @@ Ask for the matching authentication field interactively instead of supplying an Conflict with `form_entries.value`. +### on_demand + +!!! question "Since sing-box 1.15.0" + +Allow the endpoint to be disconnected when necessary. + ## UDP NAT Fields See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md index eab4f2e32..ed9223296 100644 --- a/docs/configuration/endpoint/openconnect.zh.md +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -2,6 +2,10 @@ !!! question "自 sing-box 1.14.0 起" +!!! quote "sing-box 1.15.0 中的更改" + + :material-plus: [on_demand](#on_demand) + ==仅客户端== ## 结构 @@ -107,6 +111,7 @@ "promote": false } ], + "on_demand": false, ... // 拨号字段 } @@ -620,6 +625,12 @@ MCA 证书和私钥必须同时设置或同时为空。 与 `form_entries.value` 冲突。 +### on_demand + +!!! question "自 sing-box 1.15.0 起" + +允许该 endpoint 在需要时断开连接。 + ## UDP NAT 字段 参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/docs/configuration/endpoint/openvpn-client.md b/docs/configuration/endpoint/openvpn-client.md index 7501beba9..33c1b8473 100644 --- a/docs/configuration/endpoint/openvpn-client.md +++ b/docs/configuration/endpoint/openvpn-client.md @@ -2,6 +2,10 @@ !!! question "Since sing-box 1.14.0" +!!! quote "Changes in sing-box 1.15.0" + + :material-plus: [on_demand](#on_demand) + ## Structure ```json @@ -100,6 +104,7 @@ "system": false, "name": "", "mtu": 1500, + "on_demand": false, ... // UDP NAT Fields @@ -684,6 +689,12 @@ OpenVPN interface MTU. When empty, `1500` is used until a server-pushed MTU is received. +### on_demand + +!!! question "Since sing-box 1.15.0" + +Allow the endpoint to be disconnected when necessary. + ## UDP NAT Fields See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/endpoint/openvpn-client.zh.md b/docs/configuration/endpoint/openvpn-client.zh.md index fd7fdb133..3a49de08b 100644 --- a/docs/configuration/endpoint/openvpn-client.zh.md +++ b/docs/configuration/endpoint/openvpn-client.zh.md @@ -2,6 +2,10 @@ !!! question "自 sing-box 1.14.0 起" +!!! quote "sing-box 1.15.0 中的更改" + + :material-plus: [on_demand](#on_demand) + ## 结构 ```json @@ -100,6 +104,7 @@ "system": false, "name": "", "mtu": 1500, + "on_demand": false, ... // UDP NAT 字段 @@ -670,6 +675,12 @@ OpenVPN 接口 MTU。 为空时使用服务器推送的 MTU;收到服务器配置前使用 `1500`。 +### on_demand + +!!! question "自 sing-box 1.15.0 起" + +允许该 endpoint 在需要时断开连接。 + ## UDP NAT 字段 参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/docs/configuration/endpoint/tailscale.md b/docs/configuration/endpoint/tailscale.md index a66dcd192..e0ce566b9 100644 --- a/docs/configuration/endpoint/tailscale.md +++ b/docs/configuration/endpoint/tailscale.md @@ -2,6 +2,10 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.15.0" + + :material-plus: [on_demand](#on_demand) + !!! quote "Changes in sing-box 1.14.0" :material-plus: [listen_port](#listen_port) @@ -45,6 +49,7 @@ icon: material/new-box "udp_timeout": "5m", "ssh_server": false, "taildrop_directory": "", + "on_demand": false, ... // Dial Fields } @@ -224,6 +229,12 @@ is. `Taildrop` is used by default. +#### on_demand + +!!! question "Since sing-box 1.15.0" + +Allow the endpoint to be disconnected when necessary. + ### Dial Fields !!! note diff --git a/docs/configuration/endpoint/tailscale.zh.md b/docs/configuration/endpoint/tailscale.zh.md index 887f279c2..07a14136a 100644 --- a/docs/configuration/endpoint/tailscale.zh.md +++ b/docs/configuration/endpoint/tailscale.zh.md @@ -2,6 +2,10 @@ icon: material/new-box --- +!!! quote "sing-box 1.15.0 中的更改" + + :material-plus: [on_demand](#on_demand) + !!! quote "sing-box 1.14.0 中的更改" :material-plus: [listen_port](#listen_port) @@ -45,6 +49,7 @@ icon: material/new-box "udp_timeout": "5m", "ssh_server": false, "taildrop_directory": "", + "on_demand": false, ... // 拨号字段 } @@ -222,6 +227,12 @@ UDP NAT 过期时间。 默认使用 `Taildrop`。 +#### on_demand + +!!! question "自 sing-box 1.15.0 起" + +允许该 endpoint 在需要时断开连接。 + ### 拨号字段 !!! note diff --git a/docs/configuration/endpoint/wireguard.md b/docs/configuration/endpoint/wireguard.md index 070be3977..e772a1591 100644 --- a/docs/configuration/endpoint/wireguard.md +++ b/docs/configuration/endpoint/wireguard.md @@ -1,3 +1,7 @@ +!!! quote "Changes in sing-box 1.15.0" + + :material-plus: [on_demand](#on_demand) + !!! quote "Changes in sing-box 1.14.0" :material-plus: [udp_mapping](/configuration/shared/udp-nat/#udp_mapping) @@ -34,6 +38,7 @@ ... // UDP NAT Fields "workers": 0, + "on_demand": false, ... // Dial Fields } @@ -126,6 +131,12 @@ WireGuard worker count. CPU count is used by default. +#### on_demand + +!!! question "Since sing-box 1.15.0" + +Allow the endpoint to be disconnected when necessary. + ### UDP NAT Fields See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/endpoint/wireguard.zh.md b/docs/configuration/endpoint/wireguard.zh.md index 354358d1c..77565bd77 100644 --- a/docs/configuration/endpoint/wireguard.zh.md +++ b/docs/configuration/endpoint/wireguard.zh.md @@ -1,3 +1,7 @@ +!!! quote "sing-box 1.15.0 中的更改" + + :material-plus: [on_demand](#on_demand) + !!! quote "sing-box 1.14.0 中的更改" :material-plus: [udp_mapping](/zh/configuration/shared/udp-nat/#udp_mapping) @@ -34,6 +38,7 @@ ... // UDP NAT 字段 "workers": 0, + "on_demand": false, ... // 拨号字段 } @@ -128,6 +133,12 @@ WireGuard worker 数量。 默认使用 CPU 数量。 +#### on_demand + +!!! question "自 sing-box 1.15.0 起" + +允许该 endpoint 在需要时断开连接。 + ### UDP NAT 字段 参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/docs/schema.json b/docs/schema.json index 48a6c3099..9b3cd6605 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -3675,6 +3675,9 @@ "items": { "$ref": "#/$defs/OpenConnectFormEntryOptions" } + }, + "on_demand": { + "type": "boolean" } }, "required": [ @@ -4080,6 +4083,9 @@ }, "udp_timeout": { "$ref": "#/$defs/UDPTimeout" + }, + "on_demand": { + "type": "boolean" } }, "required": [ @@ -4546,6 +4552,9 @@ }, "taildrop_directory": { "type": "string" + }, + "on_demand": { + "type": "boolean" } }, "required": [ @@ -4629,6 +4638,9 @@ "workers": { "type": "integer" }, + "on_demand": { + "type": "boolean" + }, "detour": { "type": "string", "x-tag-reference": "outbound" diff --git a/go.mod b/go.mod index 7567d858b..8cc532780 100644 --- a/go.mod +++ b/go.mod @@ -45,11 +45,11 @@ require ( github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.61.0-sing-box-mod.9 github.com/sagernet/sing v0.9.7-0.20260929150544-6f21f2425a95 - github.com/sagernet/sing-anytls v0.0.0-20260902142641-43bb1cbea74e + github.com/sagernet/sing-anytls v0.0.0-20260904135308-cec2d74334be github.com/sagernet/sing-cloudflared v0.1.4-0.20261002084126-c1255ae368f2 github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475 - github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58 - github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-7c3045dbddce + github.com/sagernet/sing-openconnect v0.1.1-0.20260929151226-29757281a247 + github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-f330676d6d4a github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b github.com/sagernet/sing-shadowsocks v0.2.9-0.20260929204512-65740e0f0e3e github.com/sagernet/sing-shadowsocks2 v0.2.2-0.20260929152114-a69d1086332b diff --git a/go.sum b/go.sum index 5b710877c..8b7e6f616 100644 --- a/go.sum +++ b/go.sum @@ -318,16 +318,16 @@ github.com/sagernet/quic-go v0.61.0-sing-box-mod.9 h1:Lojx77uC63TxZO38Z1OSo6aOoB github.com/sagernet/quic-go v0.61.0-sing-box-mod.9/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= github.com/sagernet/sing v0.9.7-0.20260929150544-6f21f2425a95 h1:taM2SOI5HK/Ubpi3FO0Bc7bKHsFVdGe4YS/jjXD4kAM= github.com/sagernet/sing v0.9.7-0.20260929150544-6f21f2425a95/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= -github.com/sagernet/sing-anytls v0.0.0-20260902142641-43bb1cbea74e h1:le983HDWe+D4LnnexDf3WXgHGYV8mxQ41n0cQL39cro= -github.com/sagernet/sing-anytls v0.0.0-20260902142641-43bb1cbea74e/go.mod h1:VRKLnj0ObEsN228h+zuzf91p+6sjtYE8SBaLPVsBy5o= +github.com/sagernet/sing-anytls v0.0.0-20260904135308-cec2d74334be h1:0uMXu8kcJU0XLc2vfjGH6q6khmECCc5WYpD9dqlu1Xg= +github.com/sagernet/sing-anytls v0.0.0-20260904135308-cec2d74334be/go.mod h1:VRKLnj0ObEsN228h+zuzf91p+6sjtYE8SBaLPVsBy5o= github.com/sagernet/sing-cloudflared v0.1.4-0.20261002084126-c1255ae368f2 h1:+AfbAXGEV8tezIYHeShzwHm7IMUE5kYGVFLo0ly7W24= github.com/sagernet/sing-cloudflared v0.1.4-0.20261002084126-c1255ae368f2/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475 h1:aG0DqHEOjax7DyfE1GTAXZotOg3QGVCz3/8rCSua2kg= github.com/sagernet/sing-mux v0.3.10-0.20260929204512-caf09fe32475/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q= -github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58 h1:WOPVlvbw3zGl4IzpRcfqB901blgDKuiplVrpptf/TOY= -github.com/sagernet/sing-openconnect v0.1.1-0.20260929151225-cbd68c45de58/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= -github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-7c3045dbddce h1:uB2DkIJLZ9waR/ZLBO9IF/ws5BWg0FEegLi90ibhX5I= -github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-7c3045dbddce/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= +github.com/sagernet/sing-openconnect v0.1.1-0.20260929151226-29757281a247 h1:Ui4XtxNgQiEquK+ch4dYhthKsVlGQpdCdKIpOuJVyog= +github.com/sagernet/sing-openconnect v0.1.1-0.20260929151226-29757281a247/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-f330676d6d4a h1:Q16Twlwk9fssoCSUTDo5WqPg0EZHG9JSV9SC3zRy9M0= +github.com/sagernet/sing-openvpn v0.1.1-0.20260929151220-f330676d6d4a/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b h1:2Jx/URJAPt32MEbLmAR5nzH87tO6NQZLRfRFvFqBWuk= github.com/sagernet/sing-quic v0.7.2-0.20261002084117-75c3ac4fa12b/go.mod h1:rJFeVxbEej2iuyeCJ3drKW+p5rOgtLIW3XhZ6ty7Fig= github.com/sagernet/sing-shadowsocks v0.2.9-0.20260929204512-65740e0f0e3e h1:LKgE/uuqp/D2kgxYViOz8vf4slaCUfpFywXjtuwD5gU= diff --git a/option/openconnect.go b/option/openconnect.go index bc597b5ef..8183e7c9f 100644 --- a/option/openconnect.go +++ b/option/openconnect.go @@ -46,6 +46,7 @@ type OpenConnectEndpointOptions struct { AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` TLS OpenConnectTLSOptions `json:"tls,omitempty"` FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` + OnDemand bool `json:"on_demand,omitempty"` } type OpenConnectTokenOptions struct { diff --git a/option/openvpn.go b/option/openvpn.go index 80ad31f39..4ba746e8d 100644 --- a/option/openvpn.go +++ b/option/openvpn.go @@ -70,6 +70,7 @@ type OpenVPNClientEndpointOptions struct { HandshakeWindow badoption.Duration `json:"handshake_window,omitempty"` ExplicitExitNotify uint32 `json:"explicit_exit_notify,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` + OnDemand bool `json:"on_demand,omitempty"` } type OpenVPNServerEndpointOptions struct { diff --git a/option/tailscale.go b/option/tailscale.go index fe14c0782..2c383c4ee 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -34,6 +34,7 @@ type TailscaleEndpointOptions struct { UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` SSHServer *TailscaleSSHServerOptions `json:"ssh_server,omitempty"` TaildropDirectory string `json:"taildrop_directory,omitempty"` + OnDemand bool `json:"on_demand,omitempty"` } type _TailscaleSSHServerOptions struct { diff --git a/option/wireguard.go b/option/wireguard.go index 8d11d9974..020b81cc4 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -19,6 +19,7 @@ type WireGuardEndpointOptions struct { UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` UDPNATMax uint32 `json:"udp_nat_max,omitempty"` Workers int `json:"workers,omitempty"` + OnDemand bool `json:"on_demand,omitempty"` DialerOptions } diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 7add4bd70..03aa1c544 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -27,7 +27,7 @@ func RegisterOutbound(registry *outbound.Registry) { var ( _ adapter.OutboundWithMultiplex = (*Outbound)(nil) _ adapter.InterfaceUpdateListener = (*Outbound)(nil) - _ adapter.IdleConnectionCloser = (*Outbound)(nil) + _ adapter.IdleConnectionKeeper = (*Outbound)(nil) ) type Outbound struct { @@ -122,6 +122,10 @@ func (h *Outbound) InterfaceUpdated(ctx context.Context) { h.client.Reset() } +func (h *Outbound) SetKeepIdleConnections(keep bool) { + h.client.SetKeepIdleConnections(keep) +} + func (h *Outbound) CloseIdleConnections() { h.client.CloseIdleConnections() } diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index 2bb9a3d78..5665fdc91 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -37,6 +37,7 @@ var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) _ adapter.FlowOutbound = (*Endpoint)(nil) _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) + _ adapter.OnDemandEndpoint = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) _ tun.Port = (*Endpoint)(nil) ) @@ -48,6 +49,7 @@ type Endpoint struct { dnsRouter adapter.DNSRouter client *openconnect.Client device openconnecttransport.Device + onDemand bool server string flavor string stateAccess sync.Mutex @@ -111,6 +113,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL cancelLoop: cancelLoop, dnsRouter: service.FromContext[adapter.DNSRouter](ctx), statusUpdated: make(chan struct{}), + onDemand: options.OnDemand, } openConnectEndpoint.state.Store(new(clientState)) success := false @@ -490,6 +493,49 @@ func (e *Endpoint) InterfaceUpdated(ctx context.Context) { e.client.RestartSession() } +func (e *Endpoint) OnDemand() bool { + return e.onDemand +} + +func (e *Endpoint) SetKeepIdleConnections(keep bool) { + if !keep { + e.client.Suspend() + } +} + +func (e *Endpoint) waitReady(ctx context.Context) error { + if !e.onDemand { + if !e.ready() || !e.client.Ready() { + return E.New("endpoint is not ready yet") + } + return nil + } + e.client.Resume() + waitCtx, cancel := context.WithTimeout(ctx, C.TCPTimeout) + defer cancel() + err := e.client.WaitReady(waitCtx) + if err != nil { + return err + } + for { + e.statusAccess.Lock() + statusUpdated := e.statusUpdated + terminalError := e.terminalError + e.statusAccess.Unlock() + if terminalError != "" { + return E.New(terminalError) + } + if e.ready() { + return nil + } + select { + case <-waitCtx.Done(): + return waitCtx.Err() + case <-statusUpdated: + } + } +} + func (e *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { return adapter.PreMatchFlow } @@ -524,6 +570,9 @@ func (e *Endpoint) ready() bool { } func (e *Endpoint) WritePackets(packets [][]byte) error { + if e.onDemand { + e.client.Resume() + } if !e.ready() { return E.New("endpoint is not ready yet") } @@ -535,6 +584,9 @@ func (e *Endpoint) WritePackets(packets [][]byte) error { } func (e *Endpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error { + if e.onDemand { + e.client.Resume() + } if !e.ready() { buf.ReleaseMulti(packetBuffers) return nil @@ -561,8 +613,9 @@ func (e *Endpoint) DialContext(ctx context.Context, network string, destination case N.NetworkUDP: e.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - if !e.ready() || !e.client.Ready() { - return nil, E.New("endpoint is not ready yet") + readyErr := e.waitReady(ctx) + if readyErr != nil { + return nil, readyErr } if destination.IsDomain() { destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) @@ -579,8 +632,9 @@ func (e *Endpoint) DialContext(ctx context.Context, network string, destination func (e *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { e.logger.InfoContext(ctx, "outbound packet connection to ", destination) - if !e.ready() || !e.client.Ready() { - return nil, netip.Addr{}, E.New("endpoint is not ready yet") + readyErr := e.waitReady(ctx) + if readyErr != nil { + return nil, netip.Addr{}, readyErr } if destination.IsDomain() { destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go index a0e585e03..cb5b79a4d 100644 --- a/protocol/openvpn/client.go +++ b/protocol/openvpn/client.go @@ -36,6 +36,7 @@ var ( _ adapter.OutboundWithPreferredRoutes = (*ClientEndpoint)(nil) _ adapter.FlowOutbound = (*ClientEndpoint)(nil) _ adapter.InterfaceUpdateListener = (*ClientEndpoint)(nil) + _ adapter.OnDemandEndpoint = (*ClientEndpoint)(nil) _ dialer.PacketDialerWithDestination = (*ClientEndpoint)(nil) _ tun.Port = (*ClientEndpoint)(nil) ) @@ -50,6 +51,7 @@ type ClientEndpoint struct { queryOptions adapter.DNSQueryOptions client *ovpn.Client device ovpntransport.Device + onDemand bool stateAccess sync.Mutex state atomic.Pointer[clientState] dnsTransport *DNSTransport @@ -83,6 +85,7 @@ func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.Co cancelLoop: cancelLoop, dnsRouter: service.FromContext[adapter.DNSRouter](ctx), statusUpdated: make(chan struct{}), + onDemand: options.OnDemand, } success := false defer func() { @@ -666,6 +669,49 @@ func (c *ClientEndpoint) InterfaceUpdated(ctx context.Context) { c.client.RestartSession() } +func (c *ClientEndpoint) OnDemand() bool { + return c.onDemand +} + +func (c *ClientEndpoint) SetKeepIdleConnections(keep bool) { + if !keep { + c.client.Suspend() + } +} + +func (c *ClientEndpoint) waitReady(ctx context.Context) error { + if !c.onDemand { + if !c.ready() || !c.client.Ready() { + return E.New("endpoint is not ready yet") + } + return nil + } + c.client.Resume() + waitCtx, cancel := context.WithTimeout(ctx, C.TCPTimeout) + defer cancel() + err := c.client.WaitReady(waitCtx) + if err != nil { + return err + } + for { + c.statusAccess.Lock() + statusUpdated := c.statusUpdated + terminalError := c.terminalError + c.statusAccess.Unlock() + if terminalError != "" { + return E.New(terminalError) + } + if c.ready() { + return nil + } + select { + case <-waitCtx.Done(): + return waitCtx.Err() + case <-statusUpdated: + } + } +} + func (c *ClientEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { return adapter.PreMatchFlow } @@ -700,6 +746,9 @@ func (c *ClientEndpoint) ready() bool { } func (c *ClientEndpoint) WritePackets(packets [][]byte) error { + if c.onDemand { + c.client.Resume() + } state := c.state.Load() if !state.started || !state.tunnelConfigured { return E.New("endpoint is not ready yet") @@ -728,6 +777,9 @@ func (c *ClientEndpoint) WritePackets(packets [][]byte) error { } func (c *ClientEndpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error { + if c.onDemand { + c.client.Resume() + } state := c.state.Load() if !state.started || !state.tunnelConfigured { buf.ReleaseMulti(packetBuffers) @@ -769,8 +821,9 @@ func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destin case N.NetworkUDP: c.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - if !c.ready() || !c.client.Ready() { - return nil, E.New("endpoint is not ready yet") + readyErr := c.waitReady(ctx) + if readyErr != nil { + return nil, readyErr } if destination.IsDomain() { destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) @@ -787,8 +840,9 @@ func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destin func (c *ClientEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { c.logger.InfoContext(ctx, "outbound packet connection to ", destination) - if !c.ready() || !c.client.Ready() { - return nil, netip.Addr{}, E.New("endpoint is not ready yet") + readyErr := c.waitReady(ctx) + if readyErr != nil { + return nil, netip.Addr{}, readyErr } if destination.IsDomain() { destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index b7b6b90e0..e8f3f9126 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -48,6 +48,7 @@ import ( tailscaleroot "github.com/sagernet/tailscale" _ "github.com/sagernet/tailscale/feature/relayserver" "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnlocal" tsDNS "github.com/sagernet/tailscale/net/dns" "github.com/sagernet/tailscale/net/netmon" "github.com/sagernet/tailscale/net/netns" @@ -68,6 +69,7 @@ var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) _ adapter.Referrer = (*Endpoint)(nil) + _ adapter.OnDemandEndpoint = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) _ tun.Port = (*Endpoint)(nil) ) @@ -120,6 +122,13 @@ type Endpoint struct { sshServerInstance *tailssh.Server sshServerOptions *option.TailscaleSSHServerOptions taildrop *taildropManager + localBackend atomic.Pointer[ipnlocal.LocalBackend] + onDemand bool + suspendAccess sync.Mutex + idleRequested atomic.Bool + resumeDone chan struct{} + resumePending atomic.Bool + suspended atomic.Bool systemInterface bool systemInterfaceName string @@ -245,6 +254,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL systemInterfaceName: options.SystemInterfaceName, systemInterfaceMTU: options.SystemInterfaceMTU, keyAuth: options.AuthKey != "", + onDemand: options.OnDemand, }, nil } @@ -439,6 +449,11 @@ func (t *Endpoint) postStart(scope *adapter.Scope) error { return nil }) localBackend := t.server.ExportLocalBackend() + t.localBackend.Store(localBackend) + scope.Add(func() error { + t.localBackend.Store(nil) + return nil + }) scope.Add(func() error { t.taildrop.close() return nil @@ -598,6 +613,7 @@ func (t *Endpoint) watchState(ctx context.Context) { if exitNodePending { tryApplyExitNode() } + t.suspendIfRequested() } return true }) @@ -752,6 +768,132 @@ func (t *Endpoint) InterfaceUpdated(ctx context.Context) { } } +func (t *Endpoint) OnDemand() bool { + return t.onDemand +} + +func (t *Endpoint) SetKeepIdleConnections(keep bool) { + t.idleRequested.Store(!keep) + if !keep { + t.suspendAccess.Lock() + t.suspendLocked() + t.suspendAccess.Unlock() + return + } + if t.systemInterface { + t.requestResume() + } +} + +func (t *Endpoint) requestResume() { + if !t.suspended.Load() || !t.resumePending.CompareAndSwap(false, true) { + return + } + go func() { + defer t.resumePending.Store(false) + err := t.resume(t.ctx) + if err != nil { + t.logger.Error(E.Cause(err, "resume")) + } + }() +} + +func (t *Endpoint) suspendIfRequested() { + if !t.idleRequested.Load() { + return + } + t.suspendAccess.Lock() + if t.idleRequested.Load() { + t.suspendLocked() + } + t.suspendAccess.Unlock() +} + +func (t *Endpoint) suspendLocked() { + if t.suspended.Load() || t.resumeDone != nil { + return + } + localBackend := t.localBackend.Load() + if localBackend == nil || localBackend.State() != ipn.Running { + return + } + _, err := localBackend.EditPrefs(&ipn.MaskedPrefs{ + Prefs: ipn.Prefs{WantRunning: false}, + WantRunningSet: true, + }) + if err != nil { + t.logger.Error(E.Cause(err, "suspend")) + return + } + t.suspended.Store(true) +} + +func (t *Endpoint) resume(ctx context.Context) error { + t.idleRequested.Store(false) + if !t.suspended.Load() { + return nil + } + t.suspendAccess.Lock() + if !t.suspended.Load() { + t.suspendAccess.Unlock() + return nil + } + resumeDone := t.resumeDone + if resumeDone == nil { + localBackend := t.localBackend.Load() + if localBackend == nil { + t.suspendAccess.Unlock() + return E.New("Tailscale is not ready yet") + } + _, err := localBackend.EditPrefs(&ipn.MaskedPrefs{ + Prefs: ipn.Prefs{WantRunning: true}, + WantRunningSet: true, + }) + if err != nil { + t.suspendAccess.Unlock() + return err + } + resumeDone = make(chan struct{}) + t.resumeDone = resumeDone + go t.awaitRunning(localBackend, resumeDone) + } + t.suspendAccess.Unlock() + select { + case <-resumeDone: + case <-ctx.Done(): + return ctx.Err() + } + if t.suspended.Load() { + return E.New("Tailscale backend is not running") + } + return nil +} + +func (t *Endpoint) awaitRunning(localBackend *ipnlocal.LocalBackend, resumeDone chan struct{}) { + running := localBackend.State() == ipn.Running + if !running { + watchCtx, cancel := context.WithTimeout(t.ctx, C.TCPTimeout) + localBackend.WatchNotifications(watchCtx, ipn.NotifyInitialState, nil, func(notify *ipn.Notify) bool { + if notify.State != nil && *notify.State == ipn.Running { + running = true + return false + } + return notify.ErrMessage == nil + }) + cancel() + } + t.suspendAccess.Lock() + t.resumeDone = nil + if running { + t.suspended.Store(false) + if t.idleRequested.Load() { + t.suspendLocked() + } + } + t.suspendAccess.Unlock() + close(resumeDone) +} + func (t *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { switch network { case N.NetworkTCP: @@ -762,6 +904,10 @@ func (t *Endpoint) DialContext(ctx context.Context, network string, destination if !t.started.Load() { return nil, E.New("Tailscale is not ready yet") } + resumeErr := t.resume(ctx) + if resumeErr != nil { + return nil, resumeErr + } if destination.IsDomain() { destinationAddresses, err := t.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) if err != nil { @@ -821,6 +967,10 @@ func (t *Endpoint) listenPacketWithAddress(ctx context.Context, destination M.So if !t.started.Load() { return nil, E.New("Tailscale is not ready yet") } + resumeErr := t.resume(ctx) + if resumeErr != nil { + return nil, resumeErr + } if t.systemDialer != nil { return t.systemDialer.ListenPacket(ctx, destination) } diff --git a/protocol/tailscale/port.go b/protocol/tailscale/port.go index 39f8dd384..488165a40 100644 --- a/protocol/tailscale/port.go +++ b/protocol/tailscale/port.go @@ -114,6 +114,7 @@ func (t *Endpoint) WritePackets(packets [][]byte) error { if !t.started.Load() { return E.New("Tailscale is not ready yet") } + t.requestResume() unmatched, err := t.wgEngine.InputPackets(packets) if err != nil || len(unmatched) == 0 { return err diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index c80cf86fa..2a9837c9e 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -29,6 +29,7 @@ import ( var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) + _ adapter.OnDemandEndpoint = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) ) @@ -44,6 +45,7 @@ type Endpoint struct { logger logger.ContextLogger localAddresses []netip.Prefix endpoint *wireguard.Endpoint + onDemand bool bindAccess sync.Mutex started atomic.Bool } @@ -56,6 +58,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL dnsRouter: service.FromContext[adapter.DNSRouter](ctx), logger: logger, localAddresses: options.Address, + onDemand: options.OnDemand, } if options.Detour != "" && options.ListenPort != 0 { return nil, E.New("`listen_port` is conflict with `detour`") @@ -163,6 +166,14 @@ func (w *Endpoint) InterfaceUpdated(ctx context.Context) { go w.updateBind(ctx) } +func (w *Endpoint) OnDemand() bool { + return w.onDemand +} + +func (w *Endpoint) SetKeepIdleConnections(keep bool) { + w.endpoint.SetIdle(!keep) +} + func (w *Endpoint) updateBind(ctx context.Context) { w.bindAccess.Lock() defer w.bindAccess.Unlock() diff --git a/route/reference.go b/route/reference.go index ac4511f99..7cd3e0557 100644 --- a/route/reference.go +++ b/route/reference.go @@ -3,6 +3,7 @@ package route import ( "context" "slices" + "sync/atomic" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/urltest" @@ -11,6 +12,7 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/observable" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" ) var _ adapter.LifecycleService = (*ReferenceManager)(nil) @@ -23,6 +25,9 @@ type ReferenceManager struct { staticOutbounds []string staticTransports []string subscriber *observable.Subscriber[struct{}] + pauseManager pause.Manager + devicePaused atomic.Bool + idleFlushed bool keepIdle map[any]bool unreferencedTransports map[string]bool } @@ -58,6 +63,7 @@ func NewReferenceManager(ctx context.Context, logger log.ContextLogger, options dnsRules: dnsRules, staticOutbounds: staticOutbounds, staticTransports: staticTransports, + pauseManager: service.FromContext[pause.Manager](ctx), } } @@ -91,8 +97,28 @@ func (m *ReferenceManager) Start(stage adapter.StartStage, scope *adapter.Scope) if clashMode != nil { clashMode.AddUpdateHook(m.subscriber) } + if m.pauseManager != nil { + m.devicePaused.Store(m.pauseManager.IsDevicePaused()) + } m.update() go m.loop() + if m.pauseManager != nil { + pauseCallback := m.pauseManager.RegisterCallback(func(event int) { + switch event { + case pause.EventDevicePaused: + m.devicePaused.Store(true) + case pause.EventDeviceWake: + m.devicePaused.Store(false) + default: + return + } + m.subscriber.Emit(struct{}{}) + }) + scope.Add(func() error { + m.pauseManager.UnregisterCallback(pauseCallback) + return nil + }) + } return nil } @@ -137,7 +163,13 @@ func (m *ReferenceManager) update() { outboundQueue = append(outboundQueue, defaultOutbound.Tag()) } } + var onDemandEndpoints []adapter.OnDemandEndpoint for _, endpoint := range endpointManager.Endpoints() { + onDemandEndpoint, isOnDemandEndpoint := endpoint.(adapter.OnDemandEndpoint) + if isOnDemandEndpoint && onDemandEndpoint.OnDemand() { + onDemandEndpoints = append(onDemandEndpoints, onDemandEndpoint) + continue + } outboundQueue = append(outboundQueue, endpoint.Tag()) } for _, inbound := range inboundManager.Inbounds() { @@ -195,6 +227,7 @@ func (m *ReferenceManager) update() { } } + devicePaused := m.devicePaused.Load() keepIdle := make(map[any]bool) for _, outbound := range outboundManager.Outbounds() { keeper, isKeeper := outbound.(adapter.IdleConnectionKeeper) @@ -211,6 +244,18 @@ func (m *ReferenceManager) update() { keep: referencedOutbounds[outbound.Tag()], }) } + for _, endpoint := range onDemandEndpoints { + m.applyKeepIdle(keepIdle, idleTarget{ + value: endpoint, + keeper: endpoint, + kind: "endpoint/", + action: "suspending", + typeName: endpoint.Type(), + tag: endpoint.Tag(), + keep: referencedOutbounds[endpoint.Tag()] && !devicePaused, + devicePaused: devicePaused, + }) + } unreferencedTransports := make(map[string]bool) for _, transport := range transportManager.Transports() { tag := transport.Tag() @@ -239,6 +284,10 @@ func (m *ReferenceManager) update() { } m.keepIdle = keepIdle m.unreferencedTransports = unreferencedTransports + if devicePaused && !m.idleFlushed { + m.CloseIdleConnections() + } + m.idleFlushed = devicePaused } type idleKeeper interface { @@ -246,13 +295,14 @@ type idleKeeper interface { } type idleTarget struct { - value any - keeper idleKeeper - kind string - action string - typeName string - tag string - keep bool + value any + keeper idleKeeper + kind string + action string + typeName string + tag string + keep bool + devicePaused bool } func (m *ReferenceManager) applyKeepIdle(keepIdle map[any]bool, target idleTarget) { @@ -262,7 +312,11 @@ func (m *ReferenceManager) applyKeepIdle(keepIdle map[any]bool, target idleTarge return } if !target.keep { - m.logger.Debug(target.kind, target.typeName, "[", target.tag, "] is unreferenced, ", target.action) + if target.devicePaused { + m.logger.Debug(target.kind, target.typeName, "[", target.tag, "] device paused, ", target.action) + } else { + m.logger.Debug(target.kind, target.typeName, "[", target.tag, "] is unreferenced, ", target.action) + } } target.keeper.SetKeepIdleConnections(target.keep) } diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index f942851fb..c49dc3202 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -9,6 +9,8 @@ import ( "net/netip" "os" "strings" + "sync" + "sync/atomic" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/service/powerreport" @@ -33,11 +35,14 @@ type Endpoint struct { allowedAddress []netip.Prefix tunDevice Device returnDevice *returnDeviceWrapper - device *device.Device + device atomic.Pointer[device.Device] allowedIPs *device.AllowedIPs egressPool *tun.UDPEgressPool pause pause.Manager pauseCallback *list.Element[pause.Callback] + stateAccess sync.Mutex + suspended atomic.Bool + networkPaused bool } func NewEndpoint(options EndpointOptions) (*Endpoint, error) { @@ -253,7 +258,7 @@ func (e *Endpoint) Start(postStart bool) error { wgDevice.Close() return E.Cause(err, "setup wireguard: \n", ipcConf.String()) } - e.device = wgDevice + e.device.Store(wgDevice) e.pause = service.FromContext[pause.Manager](e.options.Context) if e.pause != nil { e.pauseCallback = e.pause.RegisterCallback(e.onPauseUpdated) @@ -266,6 +271,7 @@ func (e *Endpoint) DialContext(ctx context.Context, network string, destination if !destination.Addr.IsValid() { return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") } + e.resume() return e.tunDevice.DialContext(ctx, network, destination) } @@ -273,9 +279,51 @@ func (e *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (n if !destination.Addr.IsValid() { return nil, E.Cause(os.ErrInvalid, "invalid non-IP destination") } + e.resume() return e.tunDevice.ListenPacket(ctx, destination) } +func (e *Endpoint) SetIdle(idle bool) { + e.stateAccess.Lock() + defer e.stateAccess.Unlock() + wgDevice := e.device.Load() + if wgDevice == nil { + return + } + if idle { + if e.suspended.Load() { + return + } + e.suspended.Store(true) + wgDevice.Down() + } else if e.options.System { + e.resumeLocked(wgDevice) + } +} + +func (e *Endpoint) resume() { + if !e.suspended.Load() { + return + } + e.stateAccess.Lock() + defer e.stateAccess.Unlock() + wgDevice := e.device.Load() + if wgDevice == nil { + return + } + e.resumeLocked(wgDevice) +} + +func (e *Endpoint) resumeLocked(wgDevice *device.Device) { + if !e.suspended.Load() { + return + } + e.suspended.Store(false) + if !e.networkPaused { + wgDevice.Up() + } +} + func (e *Endpoint) Close() error { if e.pauseCallback != nil { e.pause.UnregisterCallback(e.pauseCallback) @@ -285,10 +333,14 @@ func (e *Endpoint) Close() error { e.egressPool.Close() e.egressPool = nil } - if e.device != nil { - e.device.Down() - e.device.Close() - e.device = nil + e.stateAccess.Lock() + wgDevice := e.device.Swap(nil) + if wgDevice != nil { + wgDevice.Down() + wgDevice.Close() + } + e.stateAccess.Unlock() + if wgDevice != nil { return nil } return e.tunDevice.Close() @@ -302,18 +354,29 @@ func (e *Endpoint) Lookup(address netip.Addr) *device.Peer { } func (e *Endpoint) BindUpdate() error { - if e.device == nil { + wgDevice := e.device.Load() + if wgDevice == nil { return nil } - return e.device.BindUpdate() + return wgDevice.BindUpdate() } func (e *Endpoint) onPauseUpdated(event int) { + e.stateAccess.Lock() + defer e.stateAccess.Unlock() + wgDevice := e.device.Load() + if wgDevice == nil { + return + } switch event { case pause.EventNetworkPause: - e.device.Down() + e.networkPaused = true + wgDevice.Down() case pause.EventNetworkWake: - e.device.Up() + e.networkPaused = false + if !e.suspended.Load() { + wgDevice.Up() + } } } diff --git a/transport/wireguard/port.go b/transport/wireguard/port.go index f410bad52..27ca1419d 100644 --- a/transport/wireguard/port.go +++ b/transport/wireguard/port.go @@ -19,10 +19,11 @@ func (e *Endpoint) PortMTU() uint32 { } func (e *Endpoint) WritePackets(packets [][]byte) error { - wgDevice := e.device + wgDevice := e.device.Load() if wgDevice == nil { return E.New("WireGuard device is not ready") } + e.resume() packetRefs := make([]*device.InputPacketRef, 0, len(packets)) refs := make([]device.InputPacketRef, len(packets)) packetSlices := make([][]byte, len(packets))