diff --git a/constant/proxy.go b/constant/proxy.go index 45cb1484..8ea820e9 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -18,6 +18,7 @@ const ( TypeHysteria = "hysteria" TypeTor = "tor" TypeSSH = "ssh" + TypeShadowTLS = "shadowtls" ) const ( diff --git a/docs/configuration/inbound/shadowtls.md b/docs/configuration/inbound/shadowtls.md new file mode 100644 index 00000000..6d2e72e7 --- /dev/null +++ b/docs/configuration/inbound/shadowtls.md @@ -0,0 +1,31 @@ +### Structure + +```json +{ + "type": "shadowtls", + "tag": "st-in", + + ... // Listen Fields + + "handshake": { + "server": "google.com", + "server_port": 443, + + ... // Dial Fields + } +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen) for details. + + +### Fields + +#### handshake + +==Required== + +Handshake server address and [dial options](/configuration/shared/dial). + diff --git a/docs/configuration/inbound/shadowtls.zh.md b/docs/configuration/inbound/shadowtls.zh.md new file mode 100644 index 00000000..c9a8f57a --- /dev/null +++ b/docs/configuration/inbound/shadowtls.zh.md @@ -0,0 +1,29 @@ +### 结构 + +```json +{ + "type": "shadowtls", + "tag": "st-in", + + ... // 监听字段 + + "handshake": { + "server": "google.com", + "server_port": 443, + + ... // 拨号字段 + } +} +``` + +### 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### handshake + +==必填== + +握手服务器地址和 [拨号参数](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/shadowtls.md b/docs/configuration/outbound/shadowtls.md new file mode 100644 index 00000000..9032d05b --- /dev/null +++ b/docs/configuration/outbound/shadowtls.md @@ -0,0 +1,38 @@ +### Structure + +```json +{ + "type": "shadowtls", + "tag": "st-out", + + "server": "127.0.0.1", + "server_port": 1080, + "tls": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### tls + +==Required== + +TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial) for details. diff --git a/docs/configuration/outbound/shadowtls.zh.md b/docs/configuration/outbound/shadowtls.zh.md new file mode 100644 index 00000000..43c926ab --- /dev/null +++ b/docs/configuration/outbound/shadowtls.zh.md @@ -0,0 +1,38 @@ +### 结构 + +```json +{ + "type": "shadowtls", + "tag": "st-out", + + "server": "127.0.0.1", + "server_port": 1080, + "tls": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### tls + +==必填== + +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/examples/shadowtls.md b/docs/examples/shadowtls.md new file mode 100644 index 00000000..5335d399 --- /dev/null +++ b/docs/examples/shadowtls.md @@ -0,0 +1,55 @@ +#### Server + +```json +{ + "inbounds": [ + { + "type": "shadowtls", + "listen": "::", + "listen_port": 4443, + "handshake": { + "server": "google.com", + "server_port": 443 + }, + "detour": "shadowsocks-in" + }, + { + "type": "shadowsocks", + "tag": "shadowsocks-in", + "listen": "127.0.0.1", + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==" + } + ] +} +``` + +#### Client + +```json +{ + "outbounds": [ + { + "type": "shadowsocks", + "method": "2022-blake3-aes-128-gcm", + "password": "8JCsPssfgS8tiRwiMlhARg==", + "detour": "shadowtls-out", + "multiplex": { + "enabled": 1, + "max_connections": 4, + "min_streams": 4 + } + }, + { + "type": "shadowtls", + "tag": "shadowtls-out", + "server": "127.0.0.1", + "server_port": 4443, + "tls": { + "enabled": true, + "server_name": "google.com" + } + } + ] +} +``` \ No newline at end of file diff --git a/inbound/builder.go b/inbound/builder.go index ae5b1a6e..f9e4b18c 100644 --- a/inbound/builder.go +++ b/inbound/builder.go @@ -39,6 +39,8 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, o return NewNaive(ctx, router, logger, options.Tag, options.NaiveOptions) case C.TypeHysteria: return NewHysteria(ctx, router, logger, options.Tag, options.HysteriaOptions) + case C.TypeShadowTLS: + return NewShadowTLS(ctx, router, logger, options.Tag, options.ShadowTLSOptions) default: return nil, E.New("unknown inbound type: ", options.Type) } diff --git a/inbound/shadowsocks.go b/inbound/shadowsocks.go index f36f40cc..e1d60950 100644 --- a/inbound/shadowsocks.go +++ b/inbound/shadowsocks.go @@ -87,15 +87,3 @@ func (h *Shadowsocks) NewPacket(ctx context.Context, conn N.PacketConn, buffer * func (h *Shadowsocks) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { return os.ErrInvalid } - -func (h *Shadowsocks) newConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { - h.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) - return h.router.RouteConnection(ctx, conn, metadata) -} - -func (h *Shadowsocks) newPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { - ctx = log.ContextWithNewID(ctx) - h.logger.InfoContext(ctx, "inbound packet connection from ", metadata.Source) - h.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) - return h.router.RoutePacketConnection(ctx, conn, metadata) -} diff --git a/inbound/shadowtls.go b/inbound/shadowtls.go new file mode 100644 index 00000000..fcc22cc6 --- /dev/null +++ b/inbound/shadowtls.go @@ -0,0 +1,93 @@ +package inbound + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + 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" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/task" +) + +type ShadowTLS struct { + myInboundAdapter + handshakeDialer N.Dialer + handshakeAddr M.Socksaddr +} + +func NewShadowTLS(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowTLSInboundOptions) (*ShadowTLS, error) { + inbound := &ShadowTLS{ + myInboundAdapter: myInboundAdapter{ + protocol: C.TypeShadowTLS, + network: []string{N.NetworkTCP}, + ctx: ctx, + router: router, + logger: logger, + tag: tag, + listenOptions: options.ListenOptions, + }, + handshakeDialer: dialer.New(router, options.Handshake.DialerOptions), + handshakeAddr: options.Handshake.ServerOptions.Build(), + } + inbound.connHandler = inbound + return inbound, nil +} + +func (s *ShadowTLS) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + handshakeConn, err := s.handshakeDialer.DialContext(ctx, N.NetworkTCP, s.handshakeAddr) + if err != nil { + return err + } + var handshake task.Group + handshake.Append("client handshake", func(ctx context.Context) error { + return s.copyUntilHandshakeFinished(handshakeConn, conn) + }) + handshake.Append("server handshake", func(ctx context.Context) error { + return s.copyUntilHandshakeFinished(conn, handshakeConn) + }) + handshake.FastFail() + err = handshake.Run(ctx) + if err != nil { + return err + } + return s.newConnection(ctx, conn, metadata) +} + +func (s *ShadowTLS) copyUntilHandshakeFinished(dst io.Writer, src io.Reader) error { + const handshake = 0x16 + const changeCipherSpec = 0x14 + var hasSeenChangeCipherSpec bool + var tlsHdr [5]byte + for { + _, err := io.ReadFull(src, tlsHdr[:]) + if err != nil { + return err + } + length := binary.BigEndian.Uint16(tlsHdr[3:]) + _, err = io.Copy(dst, io.MultiReader(bytes.NewReader(tlsHdr[:]), io.LimitReader(src, int64(length)))) + if err != nil { + return err + } + if tlsHdr[0] != handshake { + if tlsHdr[0] != changeCipherSpec { + return E.New("unexpected tls frame type: ", tlsHdr[0]) + } + if !hasSeenChangeCipherSpec { + hasSeenChangeCipherSpec = true + continue + } + } + if hasSeenChangeCipherSpec { + return nil + } + } +} diff --git a/mkdocs.yml b/mkdocs.yml index eab3f3f6..795e49b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Trojan: configuration/inbound/trojan.md - Naive: configuration/inbound/naive.md - Hysteria: configuration/inbound/hysteria.md + - ShadowTLS: configuration/inbound/shadowtls.md - Tun: configuration/inbound/tun.md - Redirect: configuration/inbound/redirect.md - TProxy: configuration/inbound/tproxy.md @@ -82,6 +83,7 @@ nav: - Trojan: configuration/outbound/trojan.md - WireGuard: configuration/outbound/wireguard.md - Hysteria: configuration/outbound/hysteria.md + - ShadowTLS: configuration/outbound/shadowtls.md - Tor: configuration/outbound/tor.md - SSH: configuration/outbound/ssh.md - DNS: configuration/outbound/dns.md @@ -95,6 +97,7 @@ nav: - Shadowsocks Server: examples/ss-server.md - Shadowsocks Client: examples/ss-client.md - Shadowsocks Tun: examples/ss-tun.md + - ShadowTLS: examples/shadowtls.md - DNS Hijack: examples/dns-hijack.md - Contributing: - contributing/index.md diff --git a/option/inbound.go b/option/inbound.go index 80092d75..550e4647 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -21,6 +21,7 @@ type _Inbound struct { TrojanOptions TrojanInboundOptions `json:"-"` NaiveOptions NaiveInboundOptions `json:"-"` HysteriaOptions HysteriaInboundOptions `json:"-"` + ShadowTLSOptions ShadowTLSInboundOptions `json:"-"` } type Inbound _Inbound @@ -52,6 +53,8 @@ func (h Inbound) MarshalJSON() ([]byte, error) { v = h.NaiveOptions case C.TypeHysteria: v = h.HysteriaOptions + case C.TypeShadowTLS: + v = h.ShadowTLSOptions default: return nil, E.New("unknown inbound type: ", h.Type) } @@ -89,6 +92,8 @@ func (h *Inbound) UnmarshalJSON(bytes []byte) error { v = &h.NaiveOptions case C.TypeHysteria: v = &h.HysteriaOptions + case C.TypeShadowTLS: + v = &h.ShadowTLSOptions default: return E.New("unknown inbound type: ", h.Type) } diff --git a/option/outbound.go b/option/outbound.go index aa53a3a3..b4ca57aa 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -20,6 +20,7 @@ type _Outbound struct { HysteriaOptions HysteriaOutboundOptions `json:"-"` TorOptions TorOutboundOptions `json:"-"` SSHOptions SSHOutboundOptions `json:"-"` + ShadowTLSOptions ShadowTLSOutboundOptions `json:"-"` SelectorOptions SelectorOutboundOptions `json:"-"` } @@ -50,6 +51,8 @@ func (h Outbound) MarshalJSON() ([]byte, error) { v = h.TorOptions case C.TypeSSH: v = h.SSHOptions + case C.TypeShadowTLS: + v = h.ShadowTLSOptions case C.TypeSelector: v = h.SelectorOptions default: @@ -87,6 +90,8 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error { v = &h.TorOptions case C.TypeSSH: v = &h.SSHOptions + case C.TypeShadowTLS: + v = &h.ShadowTLSOptions case C.TypeSelector: v = &h.SelectorOptions default: diff --git a/option/shadowtls.go b/option/shadowtls.go new file mode 100644 index 00000000..7db12556 --- /dev/null +++ b/option/shadowtls.go @@ -0,0 +1,17 @@ +package option + +type ShadowTLSInboundOptions struct { + ListenOptions + Handshake ShadowTLSHandshakeOptions `json:"handshake"` +} + +type ShadowTLSHandshakeOptions struct { + ServerOptions + DialerOptions +} + +type ShadowTLSOutboundOptions struct { + OutboundDialerOptions + ServerOptions + TLS *OutboundTLSOptions `json:"tls,omitempty"` +} diff --git a/outbound/builder.go b/outbound/builder.go index fff9eb42..fc00044f 100644 --- a/outbound/builder.go +++ b/outbound/builder.go @@ -39,6 +39,8 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, o return NewTor(ctx, router, logger, options.Tag, options.TorOptions) case C.TypeSSH: return NewSSH(ctx, router, logger, options.Tag, options.SSHOptions) + case C.TypeShadowTLS: + return NewShadowTLS(ctx, router, logger, options.Tag, options.ShadowTLSOptions) case C.TypeSelector: return NewSelector(router, logger, options.Tag, options.SelectorOptions) default: diff --git a/outbound/shadowtls.go b/outbound/shadowtls.go new file mode 100644 index 00000000..71586bc9 --- /dev/null +++ b/outbound/shadowtls.go @@ -0,0 +1,84 @@ +package outbound + +import ( + "context" + "crypto/tls" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ adapter.Outbound = (*ShadowTLS)(nil) + +type ShadowTLS struct { + myOutboundAdapter + dialer N.Dialer + serverAddr M.Socksaddr + tlsConfig *tls.Config +} + +func NewShadowTLS(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowTLSOutboundOptions) (*ShadowTLS, error) { + outbound := &ShadowTLS{ + myOutboundAdapter: myOutboundAdapter{ + protocol: C.TypeShadowTLS, + network: []string{N.NetworkTCP}, + router: router, + logger: logger, + tag: tag, + }, + dialer: dialer.NewOutbound(router, options.OutboundDialerOptions), + serverAddr: options.ServerOptions.Build(), + } + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + options.TLS.MinVersion = "1.2" + options.TLS.MaxVersion = "1.2" + var err error + outbound.tlsConfig, err = dialer.TLSConfig(options.Server, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + return outbound, nil +} + +func (s *ShadowTLS) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + default: + return nil, os.ErrInvalid + } + conn, err := s.dialer.DialContext(ctx, N.NetworkTCP, s.serverAddr) + if err != nil { + return nil, err + } + tlsConn, err := dialer.TLSClient(ctx, conn, s.tlsConfig) + if err != nil { + return nil, err + } + err = tlsConn.HandshakeContext(ctx) + if err != nil { + return nil, err + } + return conn, nil +} + +func (s *ShadowTLS) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, os.ErrInvalid +} + +func (s *ShadowTLS) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + return NewConnection(ctx, s, conn, metadata) +} + +func (s *ShadowTLS) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + return os.ErrInvalid +} diff --git a/route/router.go b/route/router.go index a95ed1f1..b3957f5a 100644 --- a/route/router.go +++ b/route/router.go @@ -519,8 +519,9 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad if !common.Contains(injectable.Network(), N.NetworkTCP) { return E.New("inject: TCP unsupported") } - metadata.InboundDetour = "" metadata.LastInbound = metadata.Inbound + metadata.Inbound = metadata.InboundDetour + metadata.InboundDetour = "" err := injectable.NewConnection(ctx, conn, metadata) if err != nil { return E.Cause(err, "inject ", detour.Tag()) @@ -599,8 +600,9 @@ func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, m if !common.Contains(injectable.Network(), N.NetworkUDP) { return E.New("inject: UDP unsupported") } - metadata.InboundDetour = "" metadata.LastInbound = metadata.Inbound + metadata.Inbound = metadata.InboundDetour + metadata.InboundDetour = "" err := injectable.NewPacketConnection(ctx, conn, metadata) if err != nil { return E.Cause(err, "inject ", detour.Tag()) diff --git a/test/box_test.go b/test/box_test.go index 1d824ee4..ee8247fd 100644 --- a/test/box_test.go +++ b/test/box_test.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/bufio" + "github.com/sagernet/sing/common/debug" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/protocol/socks" @@ -17,6 +18,11 @@ import ( ) func startInstance(t *testing.T, options option.Options) { + if debug.Enabled { + options.Log = &option.LogOptions{ + Level: "trace", + } + } var instance *box.Box var err error for retry := 0; retry < 3; retry++ { diff --git a/test/clash_test.go b/test/clash_test.go index da3f7314..076fb115 100644 --- a/test/clash_test.go +++ b/test/clash_test.go @@ -36,6 +36,7 @@ const ( ImageBoringTun = "ghcr.io/ntkme/boringtun:edge" ImageHysteria = "tobyxdd/hysteria:latest" ImageNginx = "nginx:stable" + ImageShadowTLS = "ghcr.io/ihciah/shadow-tls:latest" ) var allImages = []string{ @@ -46,7 +47,8 @@ var allImages = []string{ ImageNaive, ImageBoringTun, ImageHysteria, - // ImageNginx, + ImageNginx, + ImageShadowTLS, } var localIP = netip.MustParseAddr("127.0.0.1") diff --git a/test/shadowtls_test.go b/test/shadowtls_test.go new file mode 100644 index 00000000..b4e6067b --- /dev/null +++ b/test/shadowtls_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-shadowsocks/shadowaead_2022" + F "github.com/sagernet/sing/common/format" +) + +func TestShadowTLS(t *testing.T) { + method := shadowaead_2022.List[0] + password := mkBase64(t, 16) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + MixedOptions: option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: option.ListenAddress(netip.IPv4Unspecified()), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeShadowTLS, + Tag: "in", + ShadowTLSOptions: option.ShadowTLSInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: option.ListenAddress(netip.IPv4Unspecified()), + ListenPort: serverPort, + Detour: "detour", + }, + Handshake: option.ShadowTLSHandshakeOptions{ + ServerOptions: option.ServerOptions{ + Server: "google.com", + ServerPort: 443, + }, + }, + }, + }, + { + Type: C.TypeShadowsocks, + Tag: "detour", + ShadowsocksOptions: option.ShadowsocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: option.ListenAddress(netip.IPv4Unspecified()), + ListenPort: otherPort, + }, + Method: method, + Password: password, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeShadowsocks, + ShadowsocksOptions: option.ShadowsocksOutboundOptions{ + Method: method, + Password: password, + OutboundDialerOptions: option.OutboundDialerOptions{ + DialerOptions: option.DialerOptions{ + Detour: "detour", + }, + }, + }, + }, + { + Type: C.TypeShadowTLS, + Tag: "detour", + ShadowTLSOptions: option.ShadowTLSOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + }, + }, + }, + { + Type: C.TypeDirect, + Tag: "direct", + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{{ + DefaultOptions: option.DefaultRule{ + Inbound: []string{"detour"}, + Outbound: "direct", + }, + }}, + }, + }) + testTCP(t, clientPort, testPort) +} + +func TestShadowTLSOutbound(t *testing.T) { + startDockerContainer(t, DockerOptions{ + Image: ImageShadowTLS, + Ports: []uint16{serverPort, otherPort}, + EntryPoint: "shadow-tls", + Cmd: []string{"--threads", "1", "server", "0.0.0.0:" + F.ToString(serverPort), "127.0.0.1:" + F.ToString(otherPort), "google.com:443"}, + }) + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + MixedOptions: option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: option.ListenAddress(netip.IPv4Unspecified()), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeMixed, + Tag: "detour", + MixedOptions: option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: option.ListenAddress(netip.IPv4Unspecified()), + ListenPort: otherPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeSocks, + SocksOptions: option.SocksOutboundOptions{ + OutboundDialerOptions: option.OutboundDialerOptions{ + DialerOptions: option.DialerOptions{ + Detour: "detour", + }, + }, + }, + }, + { + Type: C.TypeShadowTLS, + Tag: "detour", + ShadowTLSOptions: option.ShadowTLSOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "google.com", + }, + }, + }, + { + Type: C.TypeDirect, + Tag: "direct", + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{{ + DefaultOptions: option.DefaultRule{ + Inbound: []string{"detour"}, + Outbound: "direct", + }, + }}, + }, + }) + testTCP(t, clientPort, testPort) +}