Improve idle connection management

This commit is contained in:
世界 2026-09-04 02:19:31 +08:00
parent 6b0ca9a24d
commit 7ad8567e67
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
26 changed files with 541 additions and 37 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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.

View file

@ -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/)。

View file

@ -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.

View file

@ -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/)。

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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/)。

View file

@ -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"

6
go.mod
View file

@ -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

12
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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()
}

View file

@ -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{})

View file

@ -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{})

View file

@ -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)
}

View file

@ -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

View file

@ -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()

View file

@ -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)
}

View file

@ -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()
}
}
}

View file

@ -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))