diff --git a/adapter/inbound.go b/adapter/inbound.go index 173dd0ee..2ba8205c 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -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 diff --git a/constant/proxy.go b/constant/proxy.go index 8708604a..d85e5c08 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -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" } diff --git a/examples/tunnel/client->server/client.json b/examples/tunnel/client->server/client.json new file mode 100644 index 00000000..b81c85c8 --- /dev/null +++ b/examples/tunnel/client->server/client.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/client->server/server.json b/examples/tunnel/client->server/server.json new file mode 100644 index 00000000..5d3c7d51 --- /dev/null +++ b/examples/tunnel/client->server/server.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/client1->server->client2/client1.json b/examples/tunnel/client1->server->client2/client1.json new file mode 100644 index 00000000..afc06229 --- /dev/null +++ b/examples/tunnel/client1->server->client2/client1.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/client1->server->client2/client2.json b/examples/tunnel/client1->server->client2/client2.json new file mode 100644 index 00000000..18830c4d --- /dev/null +++ b/examples/tunnel/client1->server->client2/client2.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/client1->server->client2/server.json b/examples/tunnel/client1->server->client2/server.json new file mode 100644 index 00000000..2167ffe3 --- /dev/null +++ b/examples/tunnel/client1->server->client2/server.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/proxy_client->server->tunnel_client/proxy_client.json b/examples/tunnel/proxy_client->server->tunnel_client/proxy_client.json new file mode 100644 index 00000000..7cf461de --- /dev/null +++ b/examples/tunnel/proxy_client->server->tunnel_client/proxy_client.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/proxy_client->server->tunnel_client/server.json b/examples/tunnel/proxy_client->server->tunnel_client/server.json new file mode 100644 index 00000000..e24d3c74 --- /dev/null +++ b/examples/tunnel/proxy_client->server->tunnel_client/server.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json b/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json new file mode 100644 index 00000000..aebee2e4 --- /dev/null +++ b/examples/tunnel/proxy_client->server->tunnel_client/tunnel_client.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/server->client/client.json b/examples/tunnel/server->client/client.json new file mode 100644 index 00000000..aebee2e4 --- /dev/null +++ b/examples/tunnel/server->client/client.json @@ -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 + } +} \ No newline at end of file diff --git a/examples/tunnel/server->client/server.json b/examples/tunnel/server->client/server.json new file mode 100644 index 00000000..c224b9df --- /dev/null +++ b/examples/tunnel/server->client/server.json @@ -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 + } +} \ No newline at end of file diff --git a/include/registry.go b/include/registry.go index f4b8b61d..eaa2598d 100644 --- a/include/registry.go +++ b/include/registry.go @@ -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 diff --git a/option/rule.go b/option/rule.go index b769dab8..a5962956 100644 --- a/option/rule.go +++ b/option/rule.go @@ -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"` diff --git a/option/rule_action.go b/option/rule_action.go index 35f334d6..38a7d026 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -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"` diff --git a/option/rule_dns.go b/option/rule_dns.go index b437eb54..cdfc3afb 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -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"` diff --git a/option/rule_set.go b/option/rule_set.go index 610d7ba2..276b7856 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -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"` diff --git a/option/tunnel.go b/option/tunnel.go new file mode 100644 index 00000000..cc1df36b --- /dev/null +++ b/option/tunnel.go @@ -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"` +} diff --git a/protocol/tunnel/client.go b/protocol/tunnel/client.go new file mode 100644 index 00000000..4e4f9c2f --- /dev/null +++ b/protocol/tunnel/client.go @@ -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) {}) +} diff --git a/protocol/tunnel/protocol.go b/protocol/tunnel/protocol.go new file mode 100644 index 00000000..19e6f1cd --- /dev/null +++ b/protocol/tunnel/protocol.go @@ -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())) +} diff --git a/protocol/tunnel/router.go b/protocol/tunnel/router.go new file mode 100644 index 00000000..e2e708b5 --- /dev/null +++ b/protocol/tunnel/router.go @@ -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) +} diff --git a/protocol/tunnel/server.go b/protocol/tunnel/server.go new file mode 100644 index 00000000..d447be8c --- /dev/null +++ b/protocol/tunnel/server.go @@ -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") +} diff --git a/route/route.go b/route/route.go index d9bf2638..9d60f598 100644 --- a/route/route.go +++ b/route/route.go @@ -425,6 +425,9 @@ match: Fqdn: metadata.Destination.Fqdn, } } + if routeOptions.OverrideTunnelDestination != "" { + metadata.TunnelDestination = routeOptions.OverrideTunnelDestination + } if routeOptions.NetworkStrategy != nil { metadata.NetworkStrategy = routeOptions.NetworkStrategy } diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index d4c6625d..6246c7be 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -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)) } diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 56cf32dc..e06502eb 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -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) diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index d6eb7104..a198b009 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -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) diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index ba17ca37..78c5b7ca 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -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) diff --git a/route/rule/rule_item_tunnel_destination.go b/route/rule/rule_item_tunnel_destination.go new file mode 100644 index 00000000..34f711d6 --- /dev/null +++ b/route/rule/rule_item_tunnel_destination.go @@ -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, " "), "]") + } +} diff --git a/route/rule/rule_item_tunnel_source.go b/route/rule/rule_item_tunnel_source.go new file mode 100644 index 00000000..6a2f01cb --- /dev/null +++ b/route/rule/rule_item_tunnel_source.go @@ -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, " "), "]") + } +}