Compare commits

..

8 Commits

Author SHA1 Message Date
Sergei Maklagin
209b89a4a3 Add tunnel 2025-07-06 18:31:06 +03:00
Sergei Maklagin
765111a552 Merge tag 'v1.11.14' into HEAD 2025-06-19 20:18:51 +03:00
世界
9b8ab3e61e Bump version 2025-06-19 11:57:44 +08:00
dyhkwong
47f18e823a Fix: macOS udp find process should use unspecified fallback
be8d63ba8f
2025-06-18 08:34:59 +08:00
世界
2d1b824b62 Fix gLazyConn race 2025-06-17 14:24:11 +08:00
世界
d511698f3f Fix slowOpenConn 2025-06-12 08:05:04 +08:00
世界
cb435ea232 Fix default network strategy 2025-06-12 08:05:04 +08:00
世界
43a9016c83 Fix leak in hijack-dns 2025-06-06 14:28:09 +08:00
37 changed files with 1327 additions and 36 deletions

View File

@@ -42,14 +42,16 @@ type InboundManager interface {
}
type InboundContext struct {
Inbound string
InboundType string
IPVersion uint8
Network string
Source M.Socksaddr
Destination M.Socksaddr
User string
Outbound string
Inbound string
InboundType string
IPVersion uint8
Network string
Source M.Socksaddr
Destination M.Socksaddr
TunnelSource string
TunnelDestination string
User string
Outbound string
// sniffer

View File

@@ -100,10 +100,6 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
} else if networkManager.AutoDetectInterface() {
if platformInterface != nil {
networkStrategy = (*C.NetworkStrategy)(options.NetworkStrategy)
if networkStrategy == nil {
networkStrategy = common.Ptr(C.NetworkStrategyDefault)
defaultNetworkStrategy = true
}
networkType = common.Map(options.NetworkType, option.InterfaceType.Build)
fallbackNetworkType = common.Map(options.FallbackNetworkType, option.InterfaceType.Build)
if networkStrategy == nil && len(networkType) == 0 && len(fallbackNetworkType) == 0 {
@@ -115,6 +111,10 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
if networkFallbackDelay == 0 && defaultOptions.FallbackDelay != 0 {
networkFallbackDelay = defaultOptions.FallbackDelay
}
if networkStrategy == nil {
networkStrategy = common.Ptr(C.NetworkStrategyDefault)
defaultNetworkStrategy = true
}
bindFunc := networkManager.ProtectFunc()
dialer.Control = control.Append(dialer.Control, bindFunc)
listener.Control = control.Append(listener.Control, bindFunc)

View File

@@ -10,9 +10,7 @@ import (
"sync"
"time"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/bufio"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
@@ -26,7 +24,9 @@ type slowOpenConn struct {
destination M.Socksaddr
conn net.Conn
create chan struct{}
done chan struct{}
access sync.Mutex
closeOnce sync.Once
err error
}
@@ -45,6 +45,7 @@ func DialSlowContext(dialer *tcpDialer, ctx context.Context, network string, des
network: network,
destination: destination,
create: make(chan struct{}),
done: make(chan struct{}),
}, nil
}
@@ -55,8 +56,8 @@ func (c *slowOpenConn) Read(b []byte) (n int, err error) {
if c.err != nil {
return 0, c.err
}
case <-c.ctx.Done():
return 0, c.ctx.Err()
case <-c.done:
return 0, os.ErrClosed
}
}
return c.conn.Read(b)
@@ -74,12 +75,15 @@ func (c *slowOpenConn) Write(b []byte) (n int, err error) {
return 0, c.err
}
return c.conn.Write(b)
case <-c.done:
return 0, os.ErrClosed
default:
}
c.conn, err = c.dialer.DialContext(c.ctx, c.network, c.destination.String(), b)
conn, err := c.dialer.DialContext(c.ctx, c.network, c.destination.String(), b)
if err != nil {
c.conn = nil
c.err = E.Cause(err, "dial tcp fast open")
c.err = err
} else {
c.conn = conn
}
n = len(b)
close(c.create)
@@ -87,7 +91,13 @@ func (c *slowOpenConn) Write(b []byte) (n int, err error) {
}
func (c *slowOpenConn) Close() error {
return common.Close(c.conn)
c.closeOnce.Do(func() {
close(c.done)
if c.conn != nil {
c.conn.Close()
}
})
return nil
}
func (c *slowOpenConn) LocalAddr() net.Addr {
@@ -152,8 +162,8 @@ func (c *slowOpenConn) WriteTo(w io.Writer) (n int64, err error) {
if c.err != nil {
return 0, c.err
}
case <-c.ctx.Done():
return 0, c.ctx.Err()
case <-c.done:
return 0, c.err
}
}
return bufio.Copy(w, c.conn)

View File

@@ -76,6 +76,8 @@ func findProcessName(network string, ip netip.Addr, port int) (string, error) {
// rup8(sizeof(xtcpcb_n))
itemSize += 208
}
var fallbackUDPProcess string
// skip the first xinpgen(24 bytes) block
for i := 24; i+itemSize <= len(buf); i += itemSize {
// offset of xinpcb_n and xsocket_n
@@ -90,10 +92,12 @@ func findProcessName(network string, ip netip.Addr, port int) (string, error) {
flag := buf[inp+44]
var srcIP netip.Addr
srcIsIPv4 := false
switch {
case flag&0x1 > 0 && isIPv4:
// ipv4
srcIP = netip.AddrFrom4(*(*[4]byte)(buf[inp+76 : inp+80]))
srcIsIPv4 = true
case flag&0x2 > 0 && !isIPv4:
// ipv6
srcIP = netip.AddrFrom16(*(*[16]byte)(buf[inp+64 : inp+80]))
@@ -101,13 +105,21 @@ func findProcessName(network string, ip netip.Addr, port int) (string, error) {
continue
}
if ip != srcIP {
continue
if ip == srcIP {
// xsocket_n.so_last_pid
pid := readNativeUint32(buf[so+68 : so+72])
return getExecPathFromPID(pid)
}
// xsocket_n.so_last_pid
pid := readNativeUint32(buf[so+68 : so+72])
return getExecPathFromPID(pid)
// udp packet connection may be not equal with srcIP
if network == N.NetworkUDP && srcIP.IsUnspecified() && isIPv4 == srcIsIPv4 {
pid := readNativeUint32(buf[so+68 : so+72])
fallbackUDPProcess, _ = getExecPathFromPID(pid)
}
}
if network == N.NetworkUDP && len(fallbackUDPProcess) > 0 {
return fallbackUDPProcess, nil
}
return "", ErrNotFound

View File

@@ -25,6 +25,8 @@ const (
TypeVLESS = "vless"
TypeTUIC = "tuic"
TypeHysteria2 = "hysteria2"
TypeTunnelClient = "tunnel_client"
TypeTunnelServer = "tunnel_server"
)
const (
@@ -86,6 +88,10 @@ func ProxyDisplayName(proxyType string) string {
return "Selector"
case TypeURLTest:
return "URLTest"
case TypeTunnelClient:
return "Tunnel Client"
case TypeTunnelServer:
return "Tunnel Server"
default:
return "Unknown"
}

View File

@@ -2,6 +2,13 @@
icon: material/alert-decagram
---
### 1.11.14
* Fixes and improvements
_We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we
violated the rules (TestFlight users are not affected)._
### 1.11.13
* Fixes and improvements

View File

@@ -0,0 +1,64 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_client",
"tag": "tunnel",
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe",
"outbound": {
"type": "vless",
"tag": "vless-out",
"server": "0.0.0.0",
"server_port": 8000,
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"network": "tcp"
}
}
],
"inbounds": [
{
"type": "mixed",
"tag": "mixed-in",
"listen_port": 7897
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
},
{
"outbound": "tunnel",
"override_tunnel_destination": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,62 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_server",
"tag": "tunnel",
"uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13",
"users": [
{
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe"
}
],
"inbound": {
"type": "vless",
"tag": "vless-in",
"listen": "0.0.0.0",
"listen_port": 8000,
"users": [
{
"name": "vless",
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937"
}
]
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,64 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_client",
"tag": "tunnel",
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe",
"outbound": {
"type": "vless",
"tag": "vless-out",
"server": "0.0.0.0",
"server_port": 8000,
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"network": "tcp"
}
}
],
"inbounds": [
{
"type": "mixed",
"tag": "mixed-in",
"listen_port": 7897
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
},
{
"outbound": "tunnel",
"override_tunnel_destination": "487f6073-3300-4819-a07d-39652e45fb4d"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,53 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_client",
"tag": "tunnel",
"uuid": "487f6073-3300-4819-a07d-39652e45fb4d",
"key": "3d74d616-2502-4c17-9cc3-92c366550f4f",
"outbound": {
"type": "vless",
"tag": "vless-out",
"server": "0.0.0.0",
"server_port": 8000,
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"network": "tcp"
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,77 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_server",
"tag": "tunnel",
"uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13",
"users": [
{
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe"
},
{
"uuid": "487f6073-3300-4819-a07d-39652e45fb4d",
"key": "3d74d616-2502-4c17-9cc3-92c366550f4f"
}
],
"inbound": {
"type": "vless",
"tag": "vless-in",
"listen": "0.0.0.0",
"listen_port": 8000,
"users": [
{
"name": "vless",
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937"
}
]
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
},
{
"tunnel_source": [
"9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"487f6073-3300-4819-a07d-39652e45fb4d"
],
"tunnel_destination": [
"9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"487f6073-3300-4819-a07d-39652e45fb4d"
],
"outbound": "tunnel"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,52 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"inbounds": [
{
"type": "mixed",
"tag": "mixed-in",
"listen_port": 7897
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "vless",
"tag": "vless-out",
"server": "0.0.0.0",
"server_port": 8000,
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"network": "tcp"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
}
],
"final": "vless-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,67 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_server",
"tag": "tunnel",
"uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13",
"users": [
{
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe"
}
],
"inbound": {
"type": "vless",
"tag": "vless-in",
"listen": "0.0.0.0",
"listen_port": 8000,
"users": [
{
"name": "vless",
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937"
}
]
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
},
{
"inbound": "vless-in",
"outbound": "tunnel",
"override_tunnel_destination": "9b65b7e1-04c8-4717-8f45-2aa61fd25937"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,53 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_client",
"tag": "tunnel",
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe",
"outbound": {
"type": "vless",
"tag": "vless-out",
"server": "0.0.0.0",
"server_port": 8000,
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"network": "tcp"
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,53 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_client",
"tag": "tunnel",
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe",
"outbound": {
"type": "vless",
"tag": "vless-out",
"server": "0.0.0.0",
"server_port": 8000,
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"network": "tcp"
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

View File

@@ -0,0 +1,73 @@
{
"log": {
"level": "error"
},
"dns": {
"servers": [
{
"address": "local",
"detour": "direct"
}
]
},
"endpoints": [
{
"type": "tunnel_server",
"tag": "tunnel",
"uuid": "f79f7678-55e7-432d-a15f-6e8ab2b7fe13",
"users": [
{
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937",
"key": "1c9b2ccf-b0c0-4c26-868d-a55a4edad3fe"
}
],
"inbound": {
"type": "vless",
"tag": "vless-in",
"listen": "0.0.0.0",
"listen_port": 8000,
"users": [
{
"name": "vless",
"uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937"
}
]
}
}
],
"inbounds": [
{
"type": "mixed",
"tag": "mixed-in",
"listen_port": 7897
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
},
{
"type": "dns",
"tag": "dns-out"
}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "dns-out"
},
{
"port": 53,
"outbound": "dns-out"
},
{
"outbound": "tunnel",
"override_tunnel_destination": "9b65b7e1-04c8-4717-8f45-2aa61fd25937"
}
],
"final": "direct-out",
"auto_detect_interface": true
}
}

2
go.mod
View File

@@ -37,7 +37,7 @@ require (
github.com/sagernet/sing-shadowsocks v0.2.8
github.com/sagernet/sing-shadowsocks2 v0.2.1
github.com/sagernet/sing-shadowtls v0.2.0
github.com/sagernet/sing-tun v0.6.5
github.com/sagernet/sing-tun v0.6.8
github.com/sagernet/sing-vmess v0.2.3
github.com/sagernet/smux v1.5.34-mod.2
github.com/sagernet/utls v1.6.7

4
go.sum
View File

@@ -149,8 +149,8 @@ github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnq
github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
github.com/sagernet/sing-shadowtls v0.2.0 h1:cLKe4OAOFwuhmAIuPLj//CIL7Q9js+pIDardhJ+/osk=
github.com/sagernet/sing-shadowtls v0.2.0/go.mod h1:agU+Fw5X+xnWVyRHyFthoZCX3MfWKCFPm4JUf+1oaxo=
github.com/sagernet/sing-tun v0.6.5 h1:nGfD6GNq/r0tEjdZHOV3BS6fydSmd4kBAokU5rffssg=
github.com/sagernet/sing-tun v0.6.5/go.mod h1:fisFCbC4Vfb6HqQNcwPJi2CDK2bf0Xapyz3j3t4cnHE=
github.com/sagernet/sing-tun v0.6.8 h1:tr+LKHe09C2I9GfNuB2vnzaZm+ekoNlAhLLrdiLjtAA=
github.com/sagernet/sing-tun v0.6.8/go.mod h1:fisFCbC4Vfb6HqQNcwPJi2CDK2bf0Xapyz3j3t4cnHE=
github.com/sagernet/sing-vmess v0.2.3 h1:z6Ym8dnZG7k1fP3+54vz8G0tvRVJeOoTFFeUPwXTD44=
github.com/sagernet/sing-vmess v0.2.3/go.mod h1:jDAZ0A0St1zVRkyvhAPRySOFfhC+4SQtO5VYyeFotgA=
github.com/sagernet/smux v1.5.34-mod.2 h1:gkmBjIjlJ2zQKpLigOkFur5kBKdV6bNRoFu2WkltRQ4=

View File

@@ -26,6 +26,7 @@ import (
"github.com/sagernet/sing-box/protocol/tor"
"github.com/sagernet/sing-box/protocol/trojan"
"github.com/sagernet/sing-box/protocol/tun"
"github.com/sagernet/sing-box/protocol/tunnel"
"github.com/sagernet/sing-box/protocol/vless"
"github.com/sagernet/sing-box/protocol/vmess"
E "github.com/sagernet/sing/common/exceptions"
@@ -88,6 +89,9 @@ func OutboundRegistry() *outbound.Registry {
func EndpointRegistry() *endpoint.Registry {
registry := endpoint.NewRegistry()
tunnel.RegisterServerEndpoint(registry)
tunnel.RegisterClientEndpoint(registry)
registerWireGuardEndpoint(registry)
return registry

View File

@@ -88,6 +88,8 @@ type RawDefaultRule struct {
SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"`
Port badoption.Listable[uint16] `json:"port,omitempty"`
PortRange badoption.Listable[string] `json:"port_range,omitempty"`
TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"`
TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"`
ProcessName badoption.Listable[string] `json:"process_name,omitempty"`
ProcessPath badoption.Listable[string] `json:"process_path,omitempty"`
ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"`

View File

@@ -142,8 +142,9 @@ type RouteActionOptions struct {
}
type RawRouteOptionsActionOptions struct {
OverrideAddress string `json:"override_address,omitempty"`
OverridePort uint16 `json:"override_port,omitempty"`
OverrideAddress string `json:"override_address,omitempty"`
OverridePort uint16 `json:"override_port,omitempty"`
OverrideTunnelDestination string `json:"override_tunnel_destination,omitempty"`
NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"`
FallbackDelay uint32 `json:"fallback_delay,omitempty"`

View File

@@ -89,6 +89,8 @@ type RawDefaultDNSRule struct {
SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"`
Port badoption.Listable[uint16] `json:"port,omitempty"`
PortRange badoption.Listable[string] `json:"port_range,omitempty"`
TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"`
TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"`
ProcessName badoption.Listable[string] `json:"process_name,omitempty"`
ProcessPath badoption.Listable[string] `json:"process_path,omitempty"`
ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"`

View File

@@ -194,6 +194,8 @@ type DefaultHeadlessRule struct {
SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"`
Port badoption.Listable[uint16] `json:"port,omitempty"`
PortRange badoption.Listable[string] `json:"port_range,omitempty"`
TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"`
TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"`
ProcessName badoption.Listable[string] `json:"process_name,omitempty"`
ProcessPath badoption.Listable[string] `json:"process_path,omitempty"`
ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"`

21
option/tunnel.go Normal file
View File

@@ -0,0 +1,21 @@
package option
import "github.com/sagernet/sing/common/json/badoption"
type TunnelClientEndpointOptions struct {
UUID string `json:"uuid"`
Key string `json:"key"`
Outbound Outbound `json:"outbound"`
}
type TunnelServerEndpointOptions struct {
UUID string `json:"uuid"`
Users []TunnelUser `json:"users"`
Inbound Inbound `json:"inbound"`
ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"`
}
type TunnelUser struct {
UUID string `json:"uuid"`
Key string `json:"key"`
}

151
protocol/tunnel/client.go Normal file
View File

@@ -0,0 +1,151 @@
package tunnel
import (
"context"
"net"
"os"
"time"
"github.com/gofrs/uuid/v5"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/adapter/outbound"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
)
func RegisterClientEndpoint(registry *endpoint.Registry) {
endpoint.Register[option.TunnelClientEndpointOptions](registry, C.TypeTunnelClient, NewClientEndpoint)
}
type ClientEndpoint struct {
outbound.Adapter
ctx context.Context
outbound adapter.Outbound
router adapter.ConnectionRouterEx
logger logger.ContextLogger
uuid uuid.UUID
key uuid.UUID
}
func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelClientEndpointOptions) (adapter.Endpoint, error) {
clientUUID, err := uuid.FromString(options.UUID)
if err != nil {
return nil, err
}
clientKey, err := uuid.FromString(options.Key)
if err != nil {
return nil, err
}
client := &ClientEndpoint{
Adapter: outbound.NewAdapter(C.TypeTunnelClient, tag, []string{N.NetworkTCP}, []string{}),
ctx: ctx,
router: router,
logger: logger,
uuid: clientUUID,
key: clientKey,
}
outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx)
outbound, err := outboundRegistry.CreateOutbound(ctx, router, logger, options.Outbound.Tag, options.Outbound.Type, options.Outbound.Options)
if err != nil {
return nil, err
}
client.outbound = outbound
return client, nil
}
func (c *ClientEndpoint) Start(stage adapter.StartStage) error {
if stage != adapter.StartStatePostStart {
return nil
}
for range 5 {
go func() {
for {
select {
case <-c.ctx.Done():
return
default:
err := c.startInboundConn()
if err != nil {
c.logger.ErrorContext(c.ctx, err)
time.Sleep(time.Second * 5)
}
}
}
}()
}
return nil
}
func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
if network != N.NetworkTCP {
return nil, os.ErrInvalid
}
var destinationUUID *uuid.UUID
if metadata := adapter.ContextFrom(ctx); metadata != nil {
if metadata.TunnelDestination != "" {
uuid, err := uuid.FromString(metadata.TunnelDestination)
if err != nil {
return nil, err
}
destinationUUID = &uuid
}
}
if destinationUUID == nil {
return nil, E.New("tunnel destination not set")
}
if *destinationUUID == c.uuid {
return nil, E.New("routing loop")
}
conn, err := c.outbound.DialContext(ctx, N.NetworkTCP, Destination)
if err != nil {
return nil, err
}
err = WriteRequest(conn, &Request{UUID: c.key, Command: CommandTCP, DestinationUUID: *destinationUUID, Destination: destination})
return conn, err
}
func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return nil, os.ErrInvalid
}
func (c *ClientEndpoint) Close() error {
return nil
}
func (c *ClientEndpoint) startInboundConn() error {
conn, err := c.outbound.DialContext(c.ctx, N.NetworkTCP, Destination)
if err != nil {
return err
}
err = WriteRequest(conn, &Request{UUID: c.key, Command: CommandInbound, Destination: Destination})
if err != nil {
return err
}
request, err := ReadRequest(conn)
if err != nil {
return err
}
go c.connHandler(conn, request)
return nil
}
func (c *ClientEndpoint) connHandler(conn net.Conn, request *Request) {
metadata := adapter.InboundContext{
Source: M.ParseSocksaddr(conn.RemoteAddr().String()),
Destination: request.Destination,
}
if request.UUID == c.uuid {
c.logger.ErrorContext(c.ctx, "routing loop")
conn.Close()
return
}
metadata.TunnelSource = request.UUID.String()
c.router.RouteConnectionEx(c.ctx, conn, metadata, func(it error) {})
}

View File

@@ -0,0 +1,91 @@
package tunnel
import (
"encoding/binary"
"io"
"github.com/gofrs/uuid/v5"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/buf"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
)
const (
Version = 0
)
const (
CommandInbound = 1
CommandTCP = 2
)
var Destination = M.Socksaddr{
Fqdn: "sp.tunnel.sing-box.arpa",
Port: 444,
}
var AddressSerializer = M.NewSerializer(
M.AddressFamilyByte(0x01, M.AddressFamilyIPv4),
M.AddressFamilyByte(0x03, M.AddressFamilyIPv6),
M.AddressFamilyByte(0x02, M.AddressFamilyFqdn),
M.PortThenAddress(),
)
type Request struct {
UUID uuid.UUID
Command byte
DestinationUUID uuid.UUID
Destination M.Socksaddr
}
func ReadRequest(reader io.Reader) (*Request, error) {
var request Request
var version uint8
err := binary.Read(reader, binary.BigEndian, &version)
if err != nil {
return nil, err
}
if version != Version {
return nil, E.New("unknown version: ", version)
}
_, err = io.ReadFull(reader, request.UUID[:])
if err != nil {
return nil, err
}
err = binary.Read(reader, binary.BigEndian, &request.Command)
if err != nil {
return nil, err
}
_, err = io.ReadFull(reader, request.DestinationUUID[:])
if err != nil {
return nil, err
}
request.Destination, err = AddressSerializer.ReadAddrPort(reader)
if err != nil {
return nil, err
}
return &request, nil
}
func WriteRequest(writer io.Writer, request *Request) error {
var requestLen int
requestLen += 1 // version
requestLen += 16 // UUID
requestLen += 16 // destinationUUID
requestLen += 1 // command
requestLen += AddressSerializer.AddrPortLen(request.Destination)
buffer := buf.NewSize(requestLen)
defer buffer.Release()
common.Must(
buffer.WriteByte(Version),
common.Error(buffer.Write(request.UUID[:])),
buffer.WriteByte(request.Command),
common.Error(buffer.Write(request.DestinationUUID[:])),
)
err := AddressSerializer.WriteAddrPort(buffer, request.Destination)
if err != nil {
return err
}
return common.Error(writer.Write(buffer.Bytes()))
}

41
protocol/tunnel/router.go Normal file
View File

@@ -0,0 +1,41 @@
package tunnel
import (
"context"
"net"
"os"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
type Router struct {
adapter.Router
logger logger.ContextLogger
handler func(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) error
}
func NewRouter(router adapter.Router, logger logger.ContextLogger, handler func(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) error) *Router {
return &Router{Router: router, logger: logger, handler: handler}
}
func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
return r.handler(ctx, conn, metadata, func(error) {})
}
func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error {
return os.ErrInvalid
}
func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
if err := r.handler(ctx, conn, metadata, onClose); err != nil {
r.logger.ErrorContext(ctx, err)
N.CloseOnHandshakeFailure(conn, onClose, err)
}
}
func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
r.logger.ErrorContext(ctx, os.ErrInvalid)
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
}

203
protocol/tunnel/server.go Normal file
View File

@@ -0,0 +1,203 @@
package tunnel
import (
"context"
"net"
"os"
"sync"
"time"
"github.com/gofrs/uuid/v5"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/adapter/outbound"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
)
func RegisterServerEndpoint(registry *endpoint.Registry) {
endpoint.Register[option.TunnelServerEndpointOptions](registry, C.TypeTunnelServer, NewServerEndpoint)
}
type ServerEndpoint struct {
outbound.Adapter
logger logger.ContextLogger
inbound adapter.Inbound
router adapter.Router
uuid uuid.UUID
users map[uuid.UUID]uuid.UUID
keys map[uuid.UUID]uuid.UUID
conns map[uuid.UUID]chan net.Conn
timeout time.Duration
mtx sync.Mutex
}
func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunnelServerEndpointOptions) (adapter.Endpoint, error) {
serverUUID, err := uuid.FromString(options.UUID)
if err != nil {
return nil, err
}
server := &ServerEndpoint{
Adapter: outbound.NewAdapter(C.TypeTunnelServer, tag, []string{N.NetworkTCP}, []string{}),
logger: logger,
router: router,
uuid: serverUUID,
}
inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx)
inbound, err := inboundRegistry.Create(ctx, NewRouter(router, logger, server.connHandler), logger, options.Inbound.Tag, options.Inbound.Type, options.Inbound.Options)
if err != nil {
return nil, err
}
server.inbound = inbound
server.users = make(map[uuid.UUID]uuid.UUID, len(options.Users))
server.keys = make(map[uuid.UUID]uuid.UUID, len(options.Users))
server.conns = make(map[uuid.UUID]chan net.Conn)
for _, user := range options.Users {
key, err := uuid.FromString(user.Key)
if err != nil {
return nil, err
}
uuid, err := uuid.FromString(user.UUID)
if err != nil {
return nil, err
}
server.users[key] = uuid
server.keys[uuid] = key
server.conns[uuid] = make(chan net.Conn, 10)
}
if options.ConnectTimeout != 0 {
server.timeout = time.Duration(options.ConnectTimeout)
} else {
server.timeout = C.TCPConnectTimeout
}
return server, nil
}
func (s *ServerEndpoint) Start(stage adapter.StartStage) error {
return s.inbound.Start(stage)
}
func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
if network != N.NetworkTCP {
return nil, os.ErrInvalid
}
var sourceUUID *uuid.UUID
var ch chan net.Conn
if metadata := adapter.ContextFrom(ctx); metadata != nil {
if metadata.TunnelDestination != "" {
tunnelDestination, err := uuid.FromString(metadata.TunnelDestination)
if err != nil {
return nil, err
}
s.mtx.Lock()
var ok bool
ch, ok = s.conns[tunnelDestination]
if !ok {
return nil, E.New("user ", metadata.TunnelDestination, " not found")
}
s.mtx.Unlock()
}
if metadata.TunnelSource != "" {
tunnelSource, err := uuid.FromString(metadata.TunnelSource)
if err != nil {
return nil, err
}
sourceUUID = &tunnelSource
}
}
if ch == nil {
return nil, E.New("tunnel destination not set")
}
if sourceUUID == nil {
sourceUUID = &s.uuid
}
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
select {
case conn := <-ch:
err := WriteRequest(conn, &Request{UUID: *sourceUUID, Command: CommandTCP, Destination: destination})
if err != nil {
s.logger.ErrorContext(ctx, err)
continue
}
return conn, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return nil, os.ErrInvalid
}
func (s *ServerEndpoint) Close() error {
return common.Close(s.inbound)
}
func (s *ServerEndpoint) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error {
if metadata.Destination != Destination {
s.router.RouteConnectionEx(ctx, conn, metadata, onClose)
return nil
}
request, err := ReadRequest(conn)
if err != nil {
return err
}
if request.Command == CommandInbound {
s.mtx.Lock()
defer s.mtx.Unlock()
uuid, ok := s.users[request.UUID]
if !ok {
return E.New("key ", request.UUID.String(), " not found")
}
ch := s.conns[uuid]
select {
case ch <- conn:
default:
oldConn := <-ch
oldConn.Close()
ch <- conn
}
return nil
}
if request.Command == CommandTCP {
sourceUUID, ok := s.users[request.UUID]
if !ok {
return E.New("key ", request.UUID, " not found")
}
if sourceUUID == request.DestinationUUID {
return E.New("routing loop on ", sourceUUID)
}
s.mtx.Lock()
if request.DestinationUUID != s.uuid {
_, ok = s.keys[request.DestinationUUID]
if !ok {
return E.New("user ", sourceUUID, " not found")
}
}
s.mtx.Unlock()
metadata.Inbound = s.Tag()
metadata.InboundType = C.TypeTunnelServer
metadata.Destination = request.Destination
metadata.TunnelSource = sourceUUID.String()
metadata.TunnelDestination = request.DestinationUUID.String()
s.router.RouteConnectionEx(ctx, conn, metadata, onClose)
return nil
}
return E.New("command ", request.Command, " not found")
}

View File

@@ -31,7 +31,7 @@ func (r *Router) hijackDNSStream(ctx context.Context, conn net.Conn, metadata ad
}
}
func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetBuffers []*N.PacketBuffer, metadata adapter.InboundContext) {
func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetBuffers []*N.PacketBuffer, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
if natConn, isNatConn := conn.(udpnat.Conn); isNatConn {
metadata.Destination = M.Socksaddr{}
for _, packet := range packetBuffers {
@@ -45,10 +45,12 @@ func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetB
conn: conn,
ctx: ctx,
metadata: metadata,
onClose: onClose,
})
return
}
err := dnsOutbound.NewDNSPacketConnection(ctx, r, conn, packetBuffers, metadata)
N.CloseOnHandshakeFailure(conn, onClose, err)
if err != nil && !E.IsClosedOrCanceled(err) {
r.dnsLogger.ErrorContext(ctx, E.Cause(err, "process packet connection"))
}
@@ -85,8 +87,16 @@ type dnsHijacker struct {
conn N.PacketConn
ctx context.Context
metadata adapter.InboundContext
onClose N.CloseHandlerFunc
}
func (h *dnsHijacker) NewPacketEx(buffer *buf.Buffer, destination M.Socksaddr) {
go ExchangeDNSPacket(h.ctx, h.router, h.conn, buffer, h.metadata, destination)
}
func (h *dnsHijacker) Close() error {
if h.onClose != nil {
h.onClose(nil)
}
return nil
}

View File

@@ -120,7 +120,7 @@ func (r *Router) routeConnection(ctx context.Context, conn net.Conn, metadata ad
for _, buffer := range buffers {
conn = bufio.NewCachedConn(conn, buffer)
}
r.hijackDNSStream(ctx, conn, metadata)
N.CloseOnHandshakeFailure(conn, onClose, r.hijackDNSStream(ctx, conn, metadata))
return nil
}
}
@@ -233,7 +233,7 @@ func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, m
N.CloseOnHandshakeFailure(conn, onClose, action.Error(ctx))
return nil
case *rule.RuleActionHijackDNS:
r.hijackDNSPacket(ctx, conn, packetBuffers, metadata)
r.hijackDNSPacket(ctx, conn, packetBuffers, metadata, onClose)
return nil
}
}
@@ -425,6 +425,9 @@ match:
Fqdn: metadata.Destination.Fqdn,
}
}
if routeOptions.OverrideTunnelDestination != "" {
metadata.TunnelDestination = routeOptions.OverrideTunnelDestination
}
if routeOptions.NetworkStrategy != nil {
metadata.NetworkStrategy = routeOptions.NetworkStrategy
}

View File

@@ -33,6 +33,7 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti
RuleActionRouteOptions: RuleActionRouteOptions{
OverrideAddress: M.ParseSocksaddrHostPort(action.RouteOptions.OverrideAddress, 0),
OverridePort: action.RouteOptions.OverridePort,
OverrideTunnelDestination: action.RouteOptions.OverrideTunnelDestination,
NetworkStrategy: (*C.NetworkStrategy)(action.RouteOptions.NetworkStrategy),
FallbackDelay: time.Duration(action.RouteOptions.FallbackDelay),
UDPDisableDomainUnmapping: action.RouteOptions.UDPDisableDomainUnmapping,
@@ -147,6 +148,7 @@ func (r *RuleActionRoute) String() string {
type RuleActionRouteOptions struct {
OverrideAddress M.Socksaddr
OverridePort uint16
OverrideTunnelDestination string
NetworkStrategy *C.NetworkStrategy
NetworkType []C.InterfaceType
FallbackNetworkType []C.InterfaceType
@@ -168,6 +170,9 @@ func (r *RuleActionRouteOptions) String() string {
if r.OverridePort > 0 {
descriptions = append(descriptions, F.ToString("override-port=", r.OverridePort))
}
if r.OverrideTunnelDestination != "" {
descriptions = append(descriptions, F.ToString("override-tunnel-destination=", r.OverrideTunnelDestination))
}
if r.NetworkStrategy != nil {
descriptions = append(descriptions, F.ToString("network-strategy=", r.NetworkStrategy))
}

View File

@@ -189,6 +189,16 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio
rule.destinationPortItems = append(rule.destinationPortItems, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.TunnelSource) > 0 {
item := NewTunnelSourceItem(options.TunnelSource)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.TunnelDestination) > 0 {
item := NewTunnelDestinationItem(options.TunnelDestination)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.ProcessName) > 0 {
item := NewProcessItem(options.ProcessName)
rule.items = append(rule.items, item)

View File

@@ -180,6 +180,16 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op
rule.destinationPortItems = append(rule.destinationPortItems, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.TunnelSource) > 0 {
item := NewTunnelSourceItem(options.TunnelSource)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.TunnelDestination) > 0 {
item := NewTunnelDestinationItem(options.TunnelDestination)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.ProcessName) > 0 {
item := NewProcessItem(options.ProcessName)
rule.items = append(rule.items, item)

View File

@@ -121,6 +121,16 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR
rule.destinationPortItems = append(rule.destinationPortItems, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.TunnelSource) > 0 {
item := NewTunnelSourceItem(options.TunnelSource)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.TunnelDestination) > 0 {
item := NewTunnelDestinationItem(options.TunnelDestination)
rule.items = append(rule.items, item)
rule.allItems = append(rule.allItems, item)
}
if len(options.ProcessName) > 0 {
item := NewProcessItem(options.ProcessName)
rule.items = append(rule.items, item)

View File

@@ -0,0 +1,35 @@
package rule
import (
"strings"
"github.com/sagernet/sing-box/adapter"
F "github.com/sagernet/sing/common/format"
)
var _ RuleItem = (*TunnelDestinationItem)(nil)
type TunnelDestinationItem struct {
destinations []string
destinationMap map[string]bool
}
func NewTunnelDestinationItem(destinations []string) *TunnelDestinationItem {
rule := &TunnelDestinationItem{destinations, make(map[string]bool)}
for _, destination := range destinations {
rule.destinationMap[destination] = true
}
return rule
}
func (r *TunnelDestinationItem) Match(metadata *adapter.InboundContext) bool {
return r.destinationMap[metadata.TunnelDestination]
}
func (r *TunnelDestinationItem) String() string {
if len(r.destinations) == 1 {
return F.ToString("tunnel_destination=", r.destinations[0])
} else {
return F.ToString("tunnel_destination=[", strings.Join(r.destinations, " "), "]")
}
}

View File

@@ -0,0 +1,35 @@
package rule
import (
"strings"
"github.com/sagernet/sing-box/adapter"
F "github.com/sagernet/sing/common/format"
)
var _ RuleItem = (*TunnelSourceItem)(nil)
type TunnelSourceItem struct {
sources []string
sourceMap map[string]bool
}
func NewTunnelSourceItem(sources []string) *TunnelSourceItem {
rule := &TunnelSourceItem{sources, make(map[string]bool)}
for _, source := range sources {
rule.sourceMap[source] = true
}
return rule
}
func (r *TunnelSourceItem) Match(metadata *adapter.InboundContext) bool {
return r.sourceMap[metadata.TunnelSource]
}
func (r *TunnelSourceItem) String() string {
if len(r.sources) == 1 {
return F.ToString("tunnel_source=", r.sources[0])
} else {
return F.ToString("tunnel_source=[", strings.Join(r.sources, " "), "]")
}
}