世界 преди 1 година
родител
ревизия
9dc3bb975a

+ 7 - 2
adapter/inbound.go

@@ -31,11 +31,16 @@ type InboundContext struct {
 	Network     string
 	Source      M.Socksaddr
 	Destination M.Socksaddr
-	Domain      string
-	Protocol    string
 	User        string
 	Outbound    string
 
+	// sniffer
+
+	Protocol     string
+	Domain       string
+	Client       string
+	SniffContext any
+
 	// cache
 
 	InboundDetour        string

+ 29 - 0
common/ja3/LICENSE

@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2018, Open Systems AG
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 3 - 0
common/ja3/README.md

@@ -0,0 +1,3 @@
+# JA3
+
+mod from: https://github.com/open-ch/ja3

+ 31 - 0
common/ja3/error.go

@@ -0,0 +1,31 @@
+// Copyright (c) 2018, Open Systems AG. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style license
+// that can be found in the LICENSE file in the root of the source
+// tree.
+
+package ja3
+
+import "fmt"
+
+// Error types
+const (
+	LengthErr        string = "length check %v failed"
+	ContentTypeErr   string = "content type not matching"
+	VersionErr       string = "version check %v failed"
+	HandshakeTypeErr string = "handshake type not matching"
+	SNITypeErr       string = "SNI type not supported"
+)
+
+// ParseError can be encountered while parsing a segment
+type ParseError struct {
+	errType string
+	check   int
+}
+
+func (e *ParseError) Error() string {
+	if e.errType == LengthErr || e.errType == VersionErr {
+		return fmt.Sprintf(e.errType, e.check)
+	}
+	return fmt.Sprint(e.errType)
+}

+ 83 - 0
common/ja3/ja3.go

@@ -0,0 +1,83 @@
+// Copyright (c) 2018, Open Systems AG. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style license
+// that can be found in the LICENSE file in the root of the source
+// tree.
+
+package ja3
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+
+	"golang.org/x/exp/slices"
+)
+
+type ClientHello struct {
+	Version             uint16
+	CipherSuites        []uint16
+	Extensions          []uint16
+	EllipticCurves      []uint16
+	EllipticCurvePF     []uint8
+	Versions            []uint16
+	SignatureAlgorithms []uint16
+	ServerName          string
+	ja3ByteString       []byte
+	ja3Hash             string
+}
+
+func (j *ClientHello) Equals(another *ClientHello, ignoreExtensionsSequence bool) bool {
+	if j.Version != another.Version {
+		return false
+	}
+	if !slices.Equal(j.CipherSuites, another.CipherSuites) {
+		return false
+	}
+	if !ignoreExtensionsSequence && !slices.Equal(j.Extensions, another.Extensions) {
+		return false
+	}
+	if ignoreExtensionsSequence && !slices.Equal(j.Extensions, another.sortedExtensions()) {
+		return false
+	}
+	if !slices.Equal(j.EllipticCurves, another.EllipticCurves) {
+		return false
+	}
+	if !slices.Equal(j.EllipticCurvePF, another.EllipticCurvePF) {
+		return false
+	}
+	if !slices.Equal(j.SignatureAlgorithms, another.SignatureAlgorithms) {
+		return false
+	}
+	return true
+}
+
+func (j *ClientHello) sortedExtensions() []uint16 {
+	extensions := make([]uint16, len(j.Extensions))
+	copy(extensions, j.Extensions)
+	slices.Sort(extensions)
+	return extensions
+}
+
+func Compute(payload []byte) (*ClientHello, error) {
+	ja3 := ClientHello{}
+	err := ja3.parseSegment(payload)
+	return &ja3, err
+}
+
+func (j *ClientHello) String() string {
+	if j.ja3ByteString == nil {
+		j.marshalJA3()
+	}
+	return string(j.ja3ByteString)
+}
+
+func (j *ClientHello) Hash() string {
+	if j.ja3ByteString == nil {
+		j.marshalJA3()
+	}
+	if j.ja3Hash == "" {
+		h := md5.Sum(j.ja3ByteString)
+		j.ja3Hash = hex.EncodeToString(h[:])
+	}
+	return j.ja3Hash
+}

+ 357 - 0
common/ja3/parser.go

@@ -0,0 +1,357 @@
+// Copyright (c) 2018, Open Systems AG. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style license
+// that can be found in the LICENSE file in the root of the source
+// tree.
+
+package ja3
+
+import (
+	"encoding/binary"
+	"strconv"
+)
+
+const (
+	// Constants used for parsing
+	recordLayerHeaderLen                  int    = 5
+	handshakeHeaderLen                    int    = 6
+	randomDataLen                         int    = 32
+	sessionIDHeaderLen                    int    = 1
+	cipherSuiteHeaderLen                  int    = 2
+	compressMethodHeaderLen               int    = 1
+	extensionsHeaderLen                   int    = 2
+	extensionHeaderLen                    int    = 4
+	sniExtensionHeaderLen                 int    = 5
+	ecExtensionHeaderLen                  int    = 2
+	ecpfExtensionHeaderLen                int    = 1
+	versionExtensionHeaderLen             int    = 1
+	signatureAlgorithmsExtensionHeaderLen int    = 2
+	contentType                           uint8  = 22
+	handshakeType                         uint8  = 1
+	sniExtensionType                      uint16 = 0
+	sniNameDNSHostnameType                uint8  = 0
+	ecExtensionType                       uint16 = 10
+	ecpfExtensionType                     uint16 = 11
+	versionExtensionType                  uint16 = 43
+	signatureAlgorithmsExtensionType      uint16 = 13
+
+	// Versions
+	// The bitmask covers the versions SSL3.0 to TLS1.2
+	tlsVersionBitmask uint16 = 0xFFFC
+	tls13             uint16 = 0x0304
+
+	// GREASE values
+	// The bitmask covers all GREASE values
+	GreaseBitmask uint16 = 0x0F0F
+
+	// Constants used for marshalling
+	dashByte  = byte(45)
+	commaByte = byte(44)
+)
+
+// parseSegment to populate the corresponding ClientHello object or return an error
+func (j *ClientHello) parseSegment(segment []byte) error {
+	// Check if we can decode the next fields
+	if len(segment) < recordLayerHeaderLen {
+		return &ParseError{LengthErr, 1}
+	}
+
+	// Check if we have "Content Type: Handshake (22)"
+	contType := uint8(segment[0])
+	if contType != contentType {
+		return &ParseError{errType: ContentTypeErr}
+	}
+
+	// Check if TLS record layer version is supported
+	tlsRecordVersion := uint16(segment[1])<<8 | uint16(segment[2])
+	if tlsRecordVersion&tlsVersionBitmask != 0x0300 && tlsRecordVersion != tls13 {
+		return &ParseError{VersionErr, 1}
+	}
+
+	// Check that the Handshake is as long as expected from the length field
+	segmentLen := uint16(segment[3])<<8 | uint16(segment[4])
+	if len(segment[recordLayerHeaderLen:]) < int(segmentLen) {
+		return &ParseError{LengthErr, 2}
+	}
+	// Keep the Handshake messege, ignore any additional following record types
+	hs := segment[recordLayerHeaderLen : recordLayerHeaderLen+int(segmentLen)]
+
+	err := j.parseHandshake(hs)
+
+	return err
+}
+
+// parseHandshake body
+func (j *ClientHello) parseHandshake(hs []byte) error {
+	// Check if we can decode the next fields
+	if len(hs) < handshakeHeaderLen+randomDataLen+sessionIDHeaderLen {
+		return &ParseError{LengthErr, 3}
+	}
+
+	// Check if we have "Handshake Type: Client Hello (1)"
+	handshType := uint8(hs[0])
+	if handshType != handshakeType {
+		return &ParseError{errType: HandshakeTypeErr}
+	}
+
+	// Check if actual length of handshake matches (this is a great exclusion criterion for false positives,
+	// as these fields have to match the actual length of the rest of the segment)
+	handshakeLen := uint32(hs[1])<<16 | uint32(hs[2])<<8 | uint32(hs[3])
+	if len(hs[4:]) != int(handshakeLen) {
+		return &ParseError{LengthErr, 4}
+	}
+
+	// Check if Client Hello version is supported
+	tlsVersion := uint16(hs[4])<<8 | uint16(hs[5])
+	if tlsVersion&tlsVersionBitmask != 0x0300 && tlsVersion != tls13 {
+		return &ParseError{VersionErr, 2}
+	}
+	j.Version = tlsVersion
+
+	// Check if we can decode the next fields
+	sessionIDLen := uint8(hs[38])
+	if len(hs) < handshakeHeaderLen+randomDataLen+sessionIDHeaderLen+int(sessionIDLen) {
+		return &ParseError{LengthErr, 5}
+	}
+
+	// Cipher Suites
+	cs := hs[handshakeHeaderLen+randomDataLen+sessionIDHeaderLen+int(sessionIDLen):]
+
+	// Check if we can decode the next fields
+	if len(cs) < cipherSuiteHeaderLen {
+		return &ParseError{LengthErr, 6}
+	}
+
+	csLen := uint16(cs[0])<<8 | uint16(cs[1])
+	numCiphers := int(csLen / 2)
+	cipherSuites := make([]uint16, 0, numCiphers)
+
+	// Check if we can decode the next fields
+	if len(cs) < cipherSuiteHeaderLen+int(csLen)+compressMethodHeaderLen {
+		return &ParseError{LengthErr, 7}
+	}
+
+	for i := 0; i < numCiphers; i++ {
+		cipherSuite := uint16(cs[2+i<<1])<<8 | uint16(cs[3+i<<1])
+		cipherSuites = append(cipherSuites, cipherSuite)
+	}
+	j.CipherSuites = cipherSuites
+
+	// Check if we can decode the next fields
+	compressMethodLen := uint16(cs[cipherSuiteHeaderLen+int(csLen)])
+	if len(cs) < cipherSuiteHeaderLen+int(csLen)+compressMethodHeaderLen+int(compressMethodLen) {
+		return &ParseError{LengthErr, 8}
+	}
+
+	// Extensions
+	exs := cs[cipherSuiteHeaderLen+int(csLen)+compressMethodHeaderLen+int(compressMethodLen):]
+
+	err := j.parseExtensions(exs)
+
+	return err
+}
+
+// parseExtensions of the handshake
+func (j *ClientHello) parseExtensions(exs []byte) error {
+	// Check for no extensions, this fields header is nonexistent if no body is used
+	if len(exs) == 0 {
+		return nil
+	}
+
+	// Check if we can decode the next fields
+	if len(exs) < extensionsHeaderLen {
+		return &ParseError{LengthErr, 9}
+	}
+
+	exsLen := uint16(exs[0])<<8 | uint16(exs[1])
+	exs = exs[extensionsHeaderLen:]
+
+	// Check if we can decode the next fields
+	if len(exs) < int(exsLen) {
+		return &ParseError{LengthErr, 10}
+	}
+
+	var sni []byte
+	var extensions, ellipticCurves []uint16
+	var ellipticCurvePF []uint8
+	var versions []uint16
+	var signatureAlgorithms []uint16
+	for len(exs) > 0 {
+
+		// Check if we can decode the next fields
+		if len(exs) < extensionHeaderLen {
+			return &ParseError{LengthErr, 11}
+		}
+
+		exType := uint16(exs[0])<<8 | uint16(exs[1])
+		exLen := uint16(exs[2])<<8 | uint16(exs[3])
+		// Ignore any GREASE extensions
+		extensions = append(extensions, exType)
+		// Check if we can decode the next fields
+		if len(exs) < extensionHeaderLen+int(exLen) {
+			return &ParseError{LengthErr, 12}
+		}
+
+		sex := exs[extensionHeaderLen : extensionHeaderLen+int(exLen)]
+
+		switch exType {
+		case sniExtensionType: // Extensions: server_name
+
+			// Check if we can decode the next fields
+			if len(sex) < sniExtensionHeaderLen {
+				return &ParseError{LengthErr, 13}
+			}
+
+			sniType := uint8(sex[2])
+			sniLen := uint16(sex[3])<<8 | uint16(sex[4])
+			sex = sex[sniExtensionHeaderLen:]
+
+			// Check if we can decode the next fields
+			if len(sex) != int(sniLen) {
+				return &ParseError{LengthErr, 14}
+			}
+
+			switch sniType {
+			case sniNameDNSHostnameType:
+				sni = sex
+			default:
+				return &ParseError{errType: SNITypeErr}
+			}
+		case ecExtensionType: // Extensions: supported_groups
+
+			// Check if we can decode the next fields
+			if len(sex) < ecExtensionHeaderLen {
+				return &ParseError{LengthErr, 15}
+			}
+
+			ecsLen := uint16(sex[0])<<8 | uint16(sex[1])
+			numCurves := int(ecsLen / 2)
+			ellipticCurves = make([]uint16, 0, numCurves)
+			sex = sex[ecExtensionHeaderLen:]
+
+			// Check if we can decode the next fields
+			if len(sex) != int(ecsLen) {
+				return &ParseError{LengthErr, 16}
+			}
+
+			for i := 0; i < numCurves; i++ {
+				ecType := uint16(sex[i*2])<<8 | uint16(sex[1+i*2])
+				ellipticCurves = append(ellipticCurves, ecType)
+			}
+
+		case ecpfExtensionType: // Extensions: ec_point_formats
+
+			// Check if we can decode the next fields
+			if len(sex) < ecpfExtensionHeaderLen {
+				return &ParseError{LengthErr, 17}
+			}
+
+			ecpfsLen := uint8(sex[0])
+			numPF := int(ecpfsLen)
+			ellipticCurvePF = make([]uint8, numPF)
+			sex = sex[ecpfExtensionHeaderLen:]
+
+			// Check if we can decode the next fields
+			if len(sex) != numPF {
+				return &ParseError{LengthErr, 18}
+			}
+
+			for i := 0; i < numPF; i++ {
+				ellipticCurvePF[i] = uint8(sex[i])
+			}
+		case versionExtensionType:
+			if len(sex) < versionExtensionHeaderLen {
+				return &ParseError{LengthErr, 19}
+			}
+			versionsLen := int(sex[0])
+			for i := 0; i < versionsLen; i += 2 {
+				versions = append(versions, binary.BigEndian.Uint16(sex[1:][i:]))
+			}
+		case signatureAlgorithmsExtensionType:
+			if len(sex) < signatureAlgorithmsExtensionHeaderLen {
+				return &ParseError{LengthErr, 20}
+			}
+			ssaLen := binary.BigEndian.Uint16(sex)
+			for i := 0; i < int(ssaLen); i += 2 {
+				signatureAlgorithms = append(signatureAlgorithms, binary.BigEndian.Uint16(sex[2:][i:]))
+			}
+		}
+		exs = exs[4+exLen:]
+	}
+	j.ServerName = string(sni)
+	j.Extensions = extensions
+	j.EllipticCurves = ellipticCurves
+	j.EllipticCurvePF = ellipticCurvePF
+	j.Versions = versions
+	j.SignatureAlgorithms = signatureAlgorithms
+	return nil
+}
+
+// marshalJA3 into a byte string
+func (j *ClientHello) marshalJA3() {
+	// An uint16 can contain numbers with up to 5 digits and an uint8 can contain numbers with up to 3 digits, but we
+	// also need a byte for each separating character, except at the end.
+	byteStringLen := 6*(1+len(j.CipherSuites)+len(j.Extensions)+len(j.EllipticCurves)) + 4*len(j.EllipticCurvePF) - 1
+	byteString := make([]byte, 0, byteStringLen)
+
+	// Version
+	byteString = strconv.AppendUint(byteString, uint64(j.Version), 10)
+	byteString = append(byteString, commaByte)
+
+	// Cipher Suites
+	if len(j.CipherSuites) != 0 {
+		for _, val := range j.CipherSuites {
+			if val&GreaseBitmask != 0x0A0A {
+				continue
+			}
+			byteString = strconv.AppendUint(byteString, uint64(val), 10)
+			byteString = append(byteString, dashByte)
+		}
+		// Replace last dash with a comma
+		byteString[len(byteString)-1] = commaByte
+	} else {
+		byteString = append(byteString, commaByte)
+	}
+
+	// Extensions
+	if len(j.Extensions) != 0 {
+		for _, val := range j.Extensions {
+			if val&GreaseBitmask != 0x0A0A {
+				continue
+			}
+			byteString = strconv.AppendUint(byteString, uint64(val), 10)
+			byteString = append(byteString, dashByte)
+		}
+		// Replace last dash with a comma
+		byteString[len(byteString)-1] = commaByte
+	} else {
+		byteString = append(byteString, commaByte)
+	}
+
+	// Elliptic curves
+	if len(j.EllipticCurves) != 0 {
+		for _, val := range j.EllipticCurves {
+			if val&GreaseBitmask != 0x0A0A {
+				continue
+			}
+			byteString = strconv.AppendUint(byteString, uint64(val), 10)
+			byteString = append(byteString, dashByte)
+		}
+		// Replace last dash with a comma
+		byteString[len(byteString)-1] = commaByte
+	} else {
+		byteString = append(byteString, commaByte)
+	}
+
+	// ECPF
+	if len(j.EllipticCurvePF) != 0 {
+		for _, val := range j.EllipticCurvePF {
+			byteString = strconv.AppendUint(byteString, uint64(val), 10)
+			byteString = append(byteString, dashByte)
+		}
+		// Remove last dash
+		byteString = byteString[:len(byteString)-1]
+	}
+
+	j.ja3ByteString = byteString
+}

+ 21 - 23
common/sniff/bittorrent.go

@@ -19,45 +19,44 @@ const (
 
 // BitTorrent detects if the stream is a BitTorrent connection.
 // For the BitTorrent protocol specification, see https://www.bittorrent.org/beps/bep_0003.html
-func BitTorrent(_ context.Context, reader io.Reader) (*adapter.InboundContext, error) {
+func BitTorrent(_ context.Context, metadata *adapter.InboundContext, reader io.Reader) error {
 	var first byte
 	err := binary.Read(reader, binary.BigEndian, &first)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	if first != 19 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 
 	var protocol [19]byte
 	_, err = reader.Read(protocol[:])
 	if err != nil {
-		return nil, err
+		return err
 	}
 	if string(protocol[:]) != "BitTorrent protocol" {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 
-	return &adapter.InboundContext{
-		Protocol: C.ProtocolBitTorrent,
-	}, nil
+	metadata.Protocol = C.ProtocolBitTorrent
+	return nil
 }
 
 // UTP detects if the packet is a uTP connection packet.
 // For the uTP protocol specification, see
 //  1. https://www.bittorrent.org/beps/bep_0029.html
 //  2. https://github.com/bittorrent/libutp/blob/2b364cbb0650bdab64a5de2abb4518f9f228ec44/utp_internal.cpp#L112
-func UTP(_ context.Context, packet []byte) (*adapter.InboundContext, error) {
+func UTP(_ context.Context, metadata *adapter.InboundContext, packet []byte) error {
 	// A valid uTP packet must be at least 20 bytes long.
 	if len(packet) < 20 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 
 	version := packet[0] & 0x0F
 	ty := packet[0] >> 4
 	if version != 1 || ty > 4 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 
 	// Validate the extensions
@@ -66,36 +65,35 @@ func UTP(_ context.Context, packet []byte) (*adapter.InboundContext, error) {
 	for extension != 0 {
 		err := binary.Read(reader, binary.BigEndian, &extension)
 		if err != nil {
-			return nil, err
+			return err
 		}
 
 		var length byte
 		err = binary.Read(reader, binary.BigEndian, &length)
 		if err != nil {
-			return nil, err
+			return err
 		}
 		_, err = reader.Seek(int64(length), io.SeekCurrent)
 		if err != nil {
-			return nil, err
+			return err
 		}
 	}
-
-	return &adapter.InboundContext{
-		Protocol: C.ProtocolBitTorrent,
-	}, nil
+	metadata.Protocol = C.ProtocolBitTorrent
+	return nil
 }
 
 // UDPTracker detects if the packet is a UDP Tracker Protocol packet.
 // For the UDP Tracker Protocol specification, see https://www.bittorrent.org/beps/bep_0015.html
-func UDPTracker(_ context.Context, packet []byte) (*adapter.InboundContext, error) {
+func UDPTracker(_ context.Context, metadata *adapter.InboundContext, packet []byte) error {
 	if len(packet) < trackerConnectMinSize {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	if binary.BigEndian.Uint64(packet[:8]) != trackerProtocolID {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	if binary.BigEndian.Uint32(packet[8:12]) != trackerConnectFlag {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
-	return &adapter.InboundContext{Protocol: C.ProtocolBitTorrent}, nil
+	metadata.Protocol = C.ProtocolBitTorrent
+	return nil
 }

+ 7 - 4
common/sniff/bittorrent_test.go

@@ -6,6 +6,7 @@ import (
 	"encoding/hex"
 	"testing"
 
+	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/sniff"
 	C "github.com/sagernet/sing-box/constant"
 
@@ -24,7 +25,8 @@ func TestSniffBittorrent(t *testing.T) {
 	for _, pkt := range packets {
 		pkt, err := hex.DecodeString(pkt)
 		require.NoError(t, err)
-		metadata, err := sniff.BitTorrent(context.TODO(), bytes.NewReader(pkt))
+		var metadata adapter.InboundContext
+		err = sniff.BitTorrent(context.TODO(), &metadata, bytes.NewReader(pkt))
 		require.NoError(t, err)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 	}
@@ -43,8 +45,8 @@ func TestSniffUTP(t *testing.T) {
 	for _, pkt := range packets {
 		pkt, err := hex.DecodeString(pkt)
 		require.NoError(t, err)
-
-		metadata, err := sniff.UTP(context.TODO(), pkt)
+		var metadata adapter.InboundContext
+		err = sniff.UTP(context.TODO(), &metadata, pkt)
 		require.NoError(t, err)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 	}
@@ -63,7 +65,8 @@ func TestSniffUDPTracker(t *testing.T) {
 		pkt, err := hex.DecodeString(pkt)
 		require.NoError(t, err)
 
-		metadata, err := sniff.UDPTracker(context.TODO(), pkt)
+		var metadata adapter.InboundContext
+		err = sniff.UDPTracker(context.TODO(), &metadata, pkt)
 		require.NoError(t, err)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 	}

+ 10 - 10
common/sniff/dns.go

@@ -17,18 +17,17 @@ import (
 	mDNS "github.com/miekg/dns"
 )
 
-func StreamDomainNameQuery(readCtx context.Context, reader io.Reader) (*adapter.InboundContext, error) {
+func StreamDomainNameQuery(readCtx context.Context, metadata *adapter.InboundContext, reader io.Reader) error {
 	var length uint16
 	err := binary.Read(reader, binary.BigEndian, &length)
 	if err != nil {
-		return nil, err
+		return os.ErrInvalid
 	}
 	if length == 0 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	buffer := buf.NewSize(int(length))
 	defer buffer.Release()
-
 	readCtx, cancel := context.WithTimeout(readCtx, time.Millisecond*100)
 	var readTask task.Group
 	readTask.Append0(func(ctx context.Context) error {
@@ -37,19 +36,20 @@ func StreamDomainNameQuery(readCtx context.Context, reader io.Reader) (*adapter.
 	err = readTask.Run(readCtx)
 	cancel()
 	if err != nil {
-		return nil, err
+		return err
 	}
-	return DomainNameQuery(readCtx, buffer.Bytes())
+	return DomainNameQuery(readCtx, metadata, buffer.Bytes())
 }
 
-func DomainNameQuery(ctx context.Context, packet []byte) (*adapter.InboundContext, error) {
+func DomainNameQuery(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error {
 	var msg mDNS.Msg
 	err := msg.Unpack(packet)
 	if err != nil {
-		return nil, err
+		return err
 	}
 	if len(msg.Question) == 0 || msg.Question[0].Qclass != mDNS.ClassINET || !M.IsDomainName(msg.Question[0].Name) {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
-	return &adapter.InboundContext{Protocol: C.ProtocolDNS}, nil
+	metadata.Protocol = C.ProtocolDNS
+	return nil
 }

+ 7 - 6
common/sniff/dtls.go

@@ -8,24 +8,25 @@ import (
 	C "github.com/sagernet/sing-box/constant"
 )
 
-func DTLSRecord(ctx context.Context, packet []byte) (*adapter.InboundContext, error) {
+func DTLSRecord(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error {
 	const fixedHeaderSize = 13
 	if len(packet) < fixedHeaderSize {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	contentType := packet[0]
 	switch contentType {
 	case 20, 21, 22, 23, 25:
 	default:
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	versionMajor := packet[1]
 	if versionMajor != 0xfe {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	versionMinor := packet[2]
 	if versionMinor != 0xff && versionMinor != 0xfd {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
-	return &adapter.InboundContext{Protocol: C.ProtocolDTLS}, nil
+	metadata.Protocol = C.ProtocolDTLS
+	return nil
 }

+ 5 - 2
common/sniff/dtls_test.go

@@ -5,6 +5,7 @@ import (
 	"encoding/hex"
 	"testing"
 
+	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/sniff"
 	C "github.com/sagernet/sing-box/constant"
 
@@ -15,7 +16,8 @@ func TestSniffDTLSClientHello(t *testing.T) {
 	t.Parallel()
 	packet, err := hex.DecodeString("16fefd0000000000000000007e010000720000000000000072fefd668a43523798e064bd806d0c87660de9c611a59bbdfc3892c4e072d94f2cafc40000000cc02bc02fc00ac014c02cc0300100003c000d0010000e0403050306030401050106010807ff01000100000a00080006001d00170018000b00020100000e000900060008000700010000170000")
 	require.NoError(t, err)
-	metadata, err := sniff.DTLSRecord(context.Background(), packet)
+	var metadata adapter.InboundContext
+	err = sniff.DTLSRecord(context.Background(), &metadata, packet)
 	require.NoError(t, err)
 	require.Equal(t, metadata.Protocol, C.ProtocolDTLS)
 }
@@ -24,7 +26,8 @@ func TestSniffDTLSClientApplicationData(t *testing.T) {
 	t.Parallel()
 	packet, err := hex.DecodeString("17fefd000100000000000100440001000000000001a4f682b77ecadd10f3f3a2f78d90566212366ff8209fd77314f5a49352f9bb9bd12f4daba0b4736ae29e46b9714d3b424b3e6d0234736619b5aa0d3f")
 	require.NoError(t, err)
-	metadata, err := sniff.DTLSRecord(context.Background(), packet)
+	var metadata adapter.InboundContext
+	err = sniff.DTLSRecord(context.Background(), &metadata, packet)
 	require.NoError(t, err)
 	require.Equal(t, metadata.Protocol, C.ProtocolDTLS)
 }

+ 5 - 3
common/sniff/http.go

@@ -11,10 +11,12 @@ import (
 	"github.com/sagernet/sing/protocol/http"
 )
 
-func HTTPHost(ctx context.Context, reader io.Reader) (*adapter.InboundContext, error) {
+func HTTPHost(_ context.Context, metadata *adapter.InboundContext, reader io.Reader) error {
 	request, err := http.ReadRequest(std_bufio.NewReader(reader))
 	if err != nil {
-		return nil, err
+		return err
 	}
-	return &adapter.InboundContext{Protocol: C.ProtocolHTTP, Domain: M.ParseSocksaddr(request.Host).AddrString()}, nil
+	metadata.Protocol = C.ProtocolHTTP
+	metadata.Domain = M.ParseSocksaddr(request.Host).AddrString()
+	return nil
 }

+ 5 - 2
common/sniff/http_test.go

@@ -5,6 +5,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/sniff"
 
 	"github.com/stretchr/testify/require"
@@ -13,7 +14,8 @@ import (
 func TestSniffHTTP1(t *testing.T) {
 	t.Parallel()
 	pkt := "GET / HTTP/1.1\r\nHost: www.google.com\r\nAccept: */*\r\n\r\n"
-	metadata, err := sniff.HTTPHost(context.Background(), strings.NewReader(pkt))
+	var metadata adapter.InboundContext
+	err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader(pkt))
 	require.NoError(t, err)
 	require.Equal(t, metadata.Domain, "www.google.com")
 }
@@ -21,7 +23,8 @@ func TestSniffHTTP1(t *testing.T) {
 func TestSniffHTTP1WithPort(t *testing.T) {
 	t.Parallel()
 	pkt := "GET / HTTP/1.1\r\nHost: www.gov.cn:8080\r\nAccept: */*\r\n\r\n"
-	metadata, err := sniff.HTTPHost(context.Background(), strings.NewReader(pkt))
+	var metadata adapter.InboundContext
+	err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader(pkt))
 	require.NoError(t, err)
 	require.Equal(t, metadata.Domain, "www.gov.cn")
 }

+ 144 - 70
common/sniff/quic.go

@@ -5,95 +5,99 @@ import (
 	"context"
 	"crypto"
 	"crypto/aes"
+	"crypto/tls"
 	"encoding/binary"
 	"io"
 	"os"
 
 	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/ja3"
 	"github.com/sagernet/sing-box/common/sniff/internal/qtls"
 	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing/common/buf"
 	E "github.com/sagernet/sing/common/exceptions"
 
 	"golang.org/x/crypto/hkdf"
 )
 
-func QUICClientHello(ctx context.Context, packet []byte) (*adapter.InboundContext, error) {
-	reader := bytes.NewReader(packet)
+var ErrClientHelloFragmented = E.New("need more packet for chromium QUIC connection")
 
+func QUICClientHello(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error {
+	reader := bytes.NewReader(packet)
 	typeByte, err := reader.ReadByte()
 	if err != nil {
-		return nil, err
+		return err
 	}
 	if typeByte&0x40 == 0 {
-		return nil, E.New("bad type byte")
+		return E.New("bad type byte")
 	}
 	var versionNumber uint32
 	err = binary.Read(reader, binary.BigEndian, &versionNumber)
 	if err != nil {
-		return nil, err
+		return err
 	}
 	if versionNumber != qtls.VersionDraft29 && versionNumber != qtls.Version1 && versionNumber != qtls.Version2 {
-		return nil, E.New("bad version")
+		return E.New("bad version")
 	}
 	packetType := (typeByte & 0x30) >> 4
 	if packetType == 0 && versionNumber == qtls.Version2 || packetType == 2 && versionNumber != qtls.Version2 || packetType > 2 {
-		return nil, E.New("bad packet type")
+		return E.New("bad packet type")
 	}
 
 	destConnIDLen, err := reader.ReadByte()
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	if destConnIDLen == 0 || destConnIDLen > 20 {
-		return nil, E.New("bad destination connection id length")
+		return E.New("bad destination connection id length")
 	}
 
 	destConnID := make([]byte, destConnIDLen)
 	_, err = io.ReadFull(reader, destConnID)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	srcConnIDLen, err := reader.ReadByte()
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	_, err = io.CopyN(io.Discard, reader, int64(srcConnIDLen))
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	tokenLen, err := qtls.ReadUvarint(reader)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	_, err = io.CopyN(io.Discard, reader, int64(tokenLen))
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	packetLen, err := qtls.ReadUvarint(reader)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	hdrLen := int(reader.Size()) - reader.Len()
 	if hdrLen+int(packetLen) > len(packet) {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 
 	_, err = io.CopyN(io.Discard, reader, 4)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	pnBytes := make([]byte, aes.BlockSize)
 	_, err = io.ReadFull(reader, pnBytes)
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	var salt []byte
@@ -117,7 +121,7 @@ func QUICClientHello(ctx context.Context, packet []byte) (*adapter.InboundContex
 	hpKey := qtls.HKDFExpandLabel(crypto.SHA256, secret, []byte{}, hkdfHeaderProtectionLabel, 16)
 	block, err := aes.NewCipher(hpKey)
 	if err != nil {
-		return nil, err
+		return err
 	}
 	mask := make([]byte, aes.BlockSize)
 	block.Encrypt(mask, pnBytes)
@@ -129,7 +133,7 @@ func QUICClientHello(ctx context.Context, packet []byte) (*adapter.InboundContex
 	}
 	packetNumberLength := newPacket[0]&0x3 + 1
 	if hdrLen+int(packetNumberLength) > int(packetLen)+hdrLen {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	var packetNumber uint32
 	switch packetNumberLength {
@@ -142,7 +146,7 @@ func QUICClientHello(ctx context.Context, packet []byte) (*adapter.InboundContex
 	case 4:
 		packetNumber = binary.BigEndian.Uint32(newPacket[hdrLen:])
 	default:
-		return nil, E.New("bad packet number length")
+		return E.New("bad packet number length")
 	}
 	extHdrLen := hdrLen + int(packetNumberLength)
 	copy(newPacket[extHdrLen:hdrLen+4], packet[extHdrLen:])
@@ -166,138 +170,208 @@ func QUICClientHello(ctx context.Context, packet []byte) (*adapter.InboundContex
 	binary.BigEndian.PutUint64(nonce[len(nonce)-8:], uint64(packetNumber))
 	decrypted, err := cipher.Open(newPacket[extHdrLen:extHdrLen], nonce, data, newPacket[:extHdrLen])
 	if err != nil {
-		return nil, err
+		return err
 	}
 	var frameType byte
-	var frameLen uint64
-	var fragments []struct {
-		offset  uint64
-		length  uint64
-		payload []byte
-	}
+	var fragments []qCryptoFragment
 	decryptedReader := bytes.NewReader(decrypted)
+	const (
+		frameTypePadding         = 0x00
+		frameTypePing            = 0x01
+		frameTypeAck             = 0x02
+		frameTypeAck2            = 0x03
+		frameTypeCrypto          = 0x06
+		frameTypeConnectionClose = 0x1c
+	)
+	var frameTypeList []uint8
 	for {
 		frameType, err = decryptedReader.ReadByte()
 		if err == io.EOF {
 			break
 		}
+		frameTypeList = append(frameTypeList, frameType)
 		switch frameType {
-		case 0x00: // PADDING
+		case frameTypePadding:
 			continue
-		case 0x01: // PING
+		case frameTypePing:
 			continue
-		case 0x02, 0x03: // ACK
+		case frameTypeAck, frameTypeAck2:
 			_, err = qtls.ReadUvarint(decryptedReader) // Largest Acknowledged
 			if err != nil {
-				return nil, err
+				return err
 			}
 			_, err = qtls.ReadUvarint(decryptedReader) // ACK Delay
 			if err != nil {
-				return nil, err
+				return err
 			}
 			ackRangeCount, err := qtls.ReadUvarint(decryptedReader) // ACK Range Count
 			if err != nil {
-				return nil, err
+				return err
 			}
 			_, err = qtls.ReadUvarint(decryptedReader) // First ACK Range
 			if err != nil {
-				return nil, err
+				return err
 			}
 			for i := 0; i < int(ackRangeCount); i++ {
 				_, err = qtls.ReadUvarint(decryptedReader) // Gap
 				if err != nil {
-					return nil, err
+					return err
 				}
 				_, err = qtls.ReadUvarint(decryptedReader) // ACK Range Length
 				if err != nil {
-					return nil, err
+					return err
 				}
 			}
 			if frameType == 0x03 {
 				_, err = qtls.ReadUvarint(decryptedReader) // ECT0 Count
 				if err != nil {
-					return nil, err
+					return err
 				}
 				_, err = qtls.ReadUvarint(decryptedReader) // ECT1 Count
 				if err != nil {
-					return nil, err
+					return err
 				}
 				_, err = qtls.ReadUvarint(decryptedReader) // ECN-CE Count
 				if err != nil {
-					return nil, err
+					return err
 				}
 			}
-		case 0x06: // CRYPTO
+		case frameTypeCrypto:
 			var offset uint64
 			offset, err = qtls.ReadUvarint(decryptedReader)
 			if err != nil {
-				return &adapter.InboundContext{Protocol: C.ProtocolQUIC}, err
+				return err
 			}
 			var length uint64
 			length, err = qtls.ReadUvarint(decryptedReader)
 			if err != nil {
-				return &adapter.InboundContext{Protocol: C.ProtocolQUIC}, err
+				return err
 			}
 			index := len(decrypted) - decryptedReader.Len()
-			fragments = append(fragments, struct {
-				offset  uint64
-				length  uint64
-				payload []byte
-			}{offset, length, decrypted[index : index+int(length)]})
-			frameLen += length
+			fragments = append(fragments, qCryptoFragment{offset, length, decrypted[index : index+int(length)]})
 			_, err = decryptedReader.Seek(int64(length), io.SeekCurrent)
 			if err != nil {
-				return nil, err
+				return err
 			}
-		case 0x1c: // CONNECTION_CLOSE
+		case frameTypeConnectionClose:
 			_, err = qtls.ReadUvarint(decryptedReader) // Error Code
 			if err != nil {
-				return nil, err
+				return err
 			}
 			_, err = qtls.ReadUvarint(decryptedReader) // Frame Type
 			if err != nil {
-				return nil, err
+				return err
 			}
 			var length uint64
 			length, err = qtls.ReadUvarint(decryptedReader) // Reason Phrase Length
 			if err != nil {
-				return nil, err
+				return err
 			}
 			_, err = decryptedReader.Seek(int64(length), io.SeekCurrent) // Reason Phrase
 			if err != nil {
-				return nil, err
+				return err
 			}
 		default:
-			return nil, os.ErrInvalid
+			return os.ErrInvalid
 		}
 	}
-	tlsHdr := make([]byte, 5)
-	tlsHdr[0] = 0x16
-	binary.BigEndian.PutUint16(tlsHdr[1:], uint16(0x0303))
-	binary.BigEndian.PutUint16(tlsHdr[3:], uint16(frameLen))
+	if metadata.SniffContext != nil {
+		fragments = append(fragments, metadata.SniffContext.([]qCryptoFragment)...)
+		metadata.SniffContext = nil
+	}
+	var frameLen uint64
+	for _, fragment := range fragments {
+		frameLen += fragment.length
+	}
+	buffer := buf.NewSize(5 + int(frameLen))
+	defer buffer.Release()
+	buffer.WriteByte(0x16)
+	binary.Write(buffer, binary.BigEndian, uint16(0x0303))
+	binary.Write(buffer, binary.BigEndian, uint16(frameLen))
 	var index uint64
 	var length int
-	var readers []io.Reader
-	readers = append(readers, bytes.NewReader(tlsHdr))
 find:
 	for {
 		for _, fragment := range fragments {
 			if fragment.offset == index {
-				readers = append(readers, bytes.NewReader(fragment.payload))
+				buffer.Write(fragment.payload)
 				index = fragment.offset + fragment.length
 				length++
 				continue find
 			}
 		}
-		if length == len(fragments) {
+		break
+	}
+	metadata.Protocol = C.ProtocolQUIC
+	fingerprint, err := ja3.Compute(buffer.Bytes())
+	if err != nil {
+		metadata.Protocol = C.ProtocolQUIC
+		metadata.Client = C.ClientChromium
+		metadata.SniffContext = fragments
+		return ErrClientHelloFragmented
+	}
+	metadata.Domain = fingerprint.ServerName
+	for metadata.Client == "" {
+		if len(frameTypeList) == 1 {
+			metadata.Client = C.ClientFirefox
+			break
+		}
+		if frameTypeList[0] == frameTypeCrypto && isZero(frameTypeList[1:]) {
+			if len(fingerprint.Versions) == 2 && fingerprint.Versions[0]&ja3.GreaseBitmask == 0x0A0A &&
+				len(fingerprint.EllipticCurves) == 5 && fingerprint.EllipticCurves[0]&ja3.GreaseBitmask == 0x0A0A {
+				metadata.Client = C.ClientSafari
+				break
+			}
+			if len(fingerprint.CipherSuites) == 1 && fingerprint.CipherSuites[0] == tls.TLS_AES_256_GCM_SHA384 &&
+				len(fingerprint.EllipticCurves) == 1 && fingerprint.EllipticCurves[0] == uint16(tls.X25519) &&
+				len(fingerprint.SignatureAlgorithms) == 1 && fingerprint.SignatureAlgorithms[0] == uint16(tls.ECDSAWithP256AndSHA256) {
+				metadata.Client = C.ClientSafari
+				break
+			}
+		}
+
+		if frameTypeList[len(frameTypeList)-1] == frameTypeCrypto && isZero(frameTypeList[:len(frameTypeList)-1]) {
+			metadata.Client = C.ClientQUICGo
 			break
 		}
-		return &adapter.InboundContext{Protocol: C.ProtocolQUIC}, E.New("bad fragments")
+
+		if count(frameTypeList, frameTypeCrypto) > 1 || count(frameTypeList, frameTypePing) > 0 {
+			if maybeUQUIC(fingerprint) {
+				metadata.Client = C.ClientQUICGo
+			} else {
+				metadata.Client = C.ClientChromium
+			}
+			break
+		}
+
+		metadata.Client = C.ClientUnknown
+		//nolint:staticcheck
+		break
 	}
-	metadata, err := TLSClientHello(ctx, io.MultiReader(readers...))
-	if err != nil {
-		return &adapter.InboundContext{Protocol: C.ProtocolQUIC}, err
+	return nil
+}
+
+func isZero(slices []uint8) bool {
+	for _, slice := range slices {
+		if slice != 0 {
+			return false
+		}
 	}
-	metadata.Protocol = C.ProtocolQUIC
-	return metadata, nil
+	return true
+}
+
+func count(slices []uint8, value uint8) int {
+	var times int
+	for _, slice := range slices {
+		if slice == value {
+			times++
+		}
+	}
+	return times
+}
+
+type qCryptoFragment struct {
+	offset  uint64
+	length  uint64
+	payload []byte
 }

+ 24 - 0
common/sniff/quic_blacklist.go

@@ -0,0 +1,24 @@
+package sniff
+
+import (
+	"crypto/tls"
+
+	"github.com/sagernet/sing-box/common/ja3"
+)
+
+// Chromium sends separate client hello packets, but UQUIC has not yet implemented this behavior
+// The cronet without this behavior does not have version 115
+var uQUICChrome115 = &ja3.ClientHello{
+	Version:             tls.VersionTLS12,
+	CipherSuites:        []uint16{4865, 4866, 4867},
+	Extensions:          []uint16{0, 10, 13, 16, 27, 43, 45, 51, 57, 17513},
+	EllipticCurves:      []uint16{29, 23, 24},
+	SignatureAlgorithms: []uint16{1027, 2052, 1025, 1283, 2053, 1281, 2054, 1537, 513},
+}
+
+func maybeUQUIC(fingerprint *ja3.ClientHello) bool {
+	if uQUICChrome115.Equals(fingerprint, true) {
+		return true
+	}
+	return false
+}

Файловите разлики са ограничени, защото са твърде много
+ 3 - 2
common/sniff/quic_test.go


+ 14 - 15
common/sniff/sniff.go

@@ -14,8 +14,8 @@ import (
 )
 
 type (
-	StreamSniffer = func(ctx context.Context, reader io.Reader) (*adapter.InboundContext, error)
-	PacketSniffer = func(ctx context.Context, packet []byte) (*adapter.InboundContext, error)
+	StreamSniffer = func(ctx context.Context, metadata *adapter.InboundContext, reader io.Reader) error
+	PacketSniffer = func(ctx context.Context, metadata *adapter.InboundContext, packet []byte) error
 )
 
 func Skip(metadata adapter.InboundContext) bool {
@@ -34,7 +34,7 @@ func Skip(metadata adapter.InboundContext) bool {
 	return false
 }
 
-func PeekStream(ctx context.Context, conn net.Conn, buffer *buf.Buffer, timeout time.Duration, sniffers ...StreamSniffer) (*adapter.InboundContext, error) {
+func PeekStream(ctx context.Context, metadata *adapter.InboundContext, conn net.Conn, buffer *buf.Buffer, timeout time.Duration, sniffers ...StreamSniffer) error {
 	if timeout == 0 {
 		timeout = C.ReadPayloadTimeout
 	}
@@ -43,7 +43,7 @@ func PeekStream(ctx context.Context, conn net.Conn, buffer *buf.Buffer, timeout
 	for i := 0; ; i++ {
 		err := conn.SetReadDeadline(deadline)
 		if err != nil {
-			return nil, E.Cause(err, "set read deadline")
+			return E.Cause(err, "set read deadline")
 		}
 		_, err = buffer.ReadOnceFrom(conn)
 		_ = conn.SetReadDeadline(time.Time{})
@@ -51,29 +51,28 @@ func PeekStream(ctx context.Context, conn net.Conn, buffer *buf.Buffer, timeout
 			if i > 0 {
 				break
 			}
-			return nil, E.Cause(err, "read payload")
+			return E.Cause(err, "read payload")
 		}
 		errors = nil
-		var metadata *adapter.InboundContext
 		for _, sniffer := range sniffers {
-			metadata, err = sniffer(ctx, bytes.NewReader(buffer.Bytes()))
-			if metadata != nil {
-				return metadata, nil
+			err = sniffer(ctx, metadata, bytes.NewReader(buffer.Bytes()))
+			if err == nil {
+				return nil
 			}
 			errors = append(errors, err)
 		}
 	}
-	return nil, E.Errors(errors...)
+	return E.Errors(errors...)
 }
 
-func PeekPacket(ctx context.Context, packet []byte, sniffers ...PacketSniffer) (*adapter.InboundContext, error) {
+func PeekPacket(ctx context.Context, metadata *adapter.InboundContext, packet []byte, sniffers ...PacketSniffer) error {
 	var errors []error
 	for _, sniffer := range sniffers {
-		metadata, err := sniffer(ctx, packet)
-		if metadata != nil {
-			return metadata, nil
+		err := sniffer(ctx, metadata, packet)
+		if err == nil {
+			return nil
 		}
 		errors = append(errors, err)
 	}
-	return nil, E.Errors(errors...)
+	return E.Errors(errors...)
 }

+ 6 - 5
common/sniff/stun.go

@@ -9,16 +9,17 @@ import (
 	C "github.com/sagernet/sing-box/constant"
 )
 
-func STUNMessage(ctx context.Context, packet []byte) (*adapter.InboundContext, error) {
+func STUNMessage(_ context.Context, metadata *adapter.InboundContext, packet []byte) error {
 	pLen := len(packet)
 	if pLen < 20 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	if binary.BigEndian.Uint32(packet[4:8]) != 0x2112A442 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	if len(packet) < 20+int(binary.BigEndian.Uint16(packet[2:4])) {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
-	return &adapter.InboundContext{Protocol: C.ProtocolSTUN}, nil
+	metadata.Protocol = C.ProtocolSTUN
+	return nil
 }

+ 5 - 2
common/sniff/stun_test.go

@@ -5,6 +5,7 @@ import (
 	"encoding/hex"
 	"testing"
 
+	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/sniff"
 	C "github.com/sagernet/sing-box/constant"
 
@@ -15,14 +16,16 @@ func TestSniffSTUN(t *testing.T) {
 	t.Parallel()
 	packet, err := hex.DecodeString("000100002112a44224b1a025d0c180c484341306")
 	require.NoError(t, err)
-	metadata, err := sniff.STUNMessage(context.Background(), packet)
+	var metadata adapter.InboundContext
+	err = sniff.STUNMessage(context.Background(), &metadata, packet)
 	require.NoError(t, err)
 	require.Equal(t, metadata.Protocol, C.ProtocolSTUN)
 }
 
 func FuzzSniffSTUN(f *testing.F) {
 	f.Fuzz(func(t *testing.T, data []byte) {
-		if _, err := sniff.STUNMessage(context.Background(), data); err == nil {
+		var metadata adapter.InboundContext
+		if err := sniff.STUNMessage(context.Background(), &metadata, data); err == nil {
 			t.Fail()
 		}
 	})

+ 5 - 3
common/sniff/tls.go

@@ -10,7 +10,7 @@ import (
 	"github.com/sagernet/sing/common/bufio"
 )
 
-func TLSClientHello(ctx context.Context, reader io.Reader) (*adapter.InboundContext, error) {
+func TLSClientHello(ctx context.Context, metadata *adapter.InboundContext, reader io.Reader) error {
 	var clientHello *tls.ClientHelloInfo
 	err := tls.Server(bufio.NewReadOnlyConn(reader), &tls.Config{
 		GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) {
@@ -19,7 +19,9 @@ func TLSClientHello(ctx context.Context, reader io.Reader) (*adapter.InboundCont
 		},
 	}).HandshakeContext(ctx)
 	if clientHello != nil {
-		return &adapter.InboundContext{Protocol: C.ProtocolTLS, Domain: clientHello.ServerName}, nil
+		metadata.Protocol = C.ProtocolTLS
+		metadata.Domain = clientHello.ServerName
+		return nil
 	}
-	return nil, err
+	return err
 }

+ 8 - 0
constant/protocol.go

@@ -9,3 +9,11 @@ const (
 	ProtocolBitTorrent = "bittorrent"
 	ProtocolDTLS       = "dtls"
 )
+
+const (
+	ClientChromium = "chromium"
+	ClientSafari   = "safari"
+	ClientFirefox  = "firefox"
+	ClientQUICGo   = "quic-go"
+	ClientUnknown  = "unknown"
+)

+ 14 - 1
docs/configuration/route/rule.md

@@ -4,6 +4,7 @@ icon: material/alert-decagram
 
 !!! quote "Changes in sing-box 1.10.0"
 
+    :material-plus: [client](#client)
     :material-delete-clock: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source)  
     :material-plus: [rule_set_ip_cidr_match_source](#rule_set_ip_cidr_match_source)  
 
@@ -40,6 +41,12 @@ icon: material/alert-decagram
           "http",
           "quic"
         ],
+        "client": [
+          "chromium",
+          "safari",
+          "firefox",
+          "quic-go"
+        ],
         "domain": [
           "test.com"
         ],
@@ -166,7 +173,13 @@ Username, see each inbound for details.
 
 #### protocol
 
-Sniffed protocol, see [Sniff](/configuration/route/sniff/) for details.
+Sniffed protocol, see [Protocol Sniff](/configuration/route/sniff/) for details.
+
+#### client
+
+!!! question "Since sing-box 1.10.0"
+
+Sniffed client type, see [Protocol Sniff](/configuration/route/sniff/) for details.
 
 #### network
 

+ 14 - 1
docs/configuration/route/rule.zh.md

@@ -4,8 +4,9 @@ icon: material/alert-decagram
 
 !!! quote "sing-box 1.10.0 中的更改"
 
+    :material-plus: [client](#client)  
     :material-delete-clock: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source)  
-    :material-plus: [rule_set_ip_cidr_match_source](#rule_set_ip_cidr_match_source)  
+    :material-plus: [rule_set_ip_cidr_match_source](#rule_set_ip_cidr_match_source)
 
 !!! quote "sing-box 1.8.0 中的更改"
 
@@ -40,6 +41,12 @@ icon: material/alert-decagram
           "http",
           "quic"
         ],
+        "client": [
+          "chromium",
+          "safari",
+          "firefox",
+          "quic-go"
+        ],
         "domain": [
           "test.com"
         ],
@@ -166,6 +173,12 @@ icon: material/alert-decagram
 
 探测到的协议, 参阅 [协议探测](/zh/configuration/route/sniff/)。
 
+#### client
+
+!!! question "自 sing-box 1.10.0 起"
+
+探测到的客户端类型, 参阅 [协议探测](/zh/configuration/route/sniff/)。
+
 #### network
 
 `tcp` 或 `udp`。

+ 19 - 10
docs/configuration/route/sniff.md

@@ -4,19 +4,28 @@ icon: material/new-box
 
 !!! quote "Changes in sing-box 1.10.0"
 
-    :material-plus: BitTorrent support
+    :material-plus: QUIC client type detect support for QUIC  
+    :material-plus: Chromium support for QUIC  
+    :material-plus: BitTorrent support  
     :material-plus: DTLS support
 
 If enabled in the inbound, the protocol and domain name (if present) of by the connection can be sniffed.
 
 #### Supported Protocols
 
-| Network |   Protocol   | Domain Name |
-|:-------:|:------------:|:-----------:|
-|   TCP   |    `http`    |    Host     |
-|   TCP   |    `tls`     | Server Name |
-|   UDP   |    `quic`    | Server Name |
-|   UDP   |    `stun`    |      /      |
-| TCP/UDP |    `dns`     |      /      |
-| TCP/UDP | `bittorrent` |      /      |
-|   UDP   |    `dtls`    |      /      |
+| Network |   Protocol   | Domain Name |      Client      |
+|:-------:|:------------:|:-----------:|:----------------:|
+|   TCP   |    `http`    |    Host     |        /         |
+|   TCP   |    `tls`     | Server Name |        /         |
+|   UDP   |    `quic`    | Server Name | QUIC Client Type |
+|   UDP   |    `stun`    |      /      |        /         |
+| TCP/UDP |    `dns`     |      /      |        /         |
+| TCP/UDP | `bittorrent` |      /      |        /         |
+|   UDP   |    `dtls`    |      /      |        /         |
+
+|       QUIC Client        |    Type    |
+|:------------------------:|:----------:|
+|     Chromium/Cronet      | `chrimium` |
+| Safari/Apple Network API |  `safari`  |
+| Firefox / uquic firefox  | `firefox`  |
+|  quic-go / uquic chrome  | `quic-go`  |

+ 19 - 10
docs/configuration/route/sniff.zh.md

@@ -4,19 +4,28 @@ icon: material/new-box
 
 !!! quote "sing-box 1.10.0 中的更改"
 
-    :material-plus: BitTorrent 支持
+    :material-plus: QUIC 的 客户端类型探测支持  
+    :material-plus: QUIC 的 Chromium 支持  
+    :material-plus: BitTorrent 支持  
     :material-plus: DTLS 支持
 
 如果在入站中启用,则可以嗅探连接的协议和域名(如果存在)。
 
 #### 支持的协议
 
-|   网络    |      协议      |     域名      |
-|:-------:|:------------:|:-----------:|
-|   TCP   |    `http`    |    Host     |
-|   TCP   |    `tls`     | Server Name |
-|   UDP   |    `quic`    | Server Name |
-|   UDP   |    `stun`    |      /      |
-| TCP/UDP |    `dns`     |      /      |
-| TCP/UDP | `bittorrent` |      /      |
-|   UDP   |    `dtls`    |      /      |
+|   网络    |      协议      |     域名      |    客户端     |
+|:-------:|:------------:|:-----------:|:----------:|
+|   TCP   |    `http`    |    Host     |     /      |
+|   TCP   |    `tls`     | Server Name |     /      |
+|   UDP   |    `quic`    | Server Name | QUIC 客户端类型 |
+|   UDP   |    `stun`    |      /      |     /      |
+| TCP/UDP |    `dns`     |      /      |     /      |
+| TCP/UDP | `bittorrent` |      /      |     /      |
+|   UDP   |    `dtls`    |      /      |     /      |
+
+|         QUIC 客户端         |     类型     |
+|:------------------------:|:----------:|
+|     Chromium/Cronet      | `chrimium` |
+| Safari/Apple Network API |  `safari`  |
+| Firefox / uquic firefox  | `firefox`  |
+|  quic-go / uquic chrome  | `quic-go`  |

+ 1 - 1
go.mod

@@ -45,6 +45,7 @@ require (
 	go.uber.org/zap v1.27.0
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
 	golang.org/x/crypto v0.25.0
+	golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
 	golang.org/x/net v0.27.0
 	golang.org/x/sys v0.25.0
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
@@ -89,7 +90,6 @@ require (
 	github.com/vishvananda/netns v0.0.4 // indirect
 	github.com/zeebo/blake3 v0.2.3 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
 	golang.org/x/mod v0.19.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
 	golang.org/x/text v0.18.0 // indirect

+ 1 - 0
option/rule.go

@@ -70,6 +70,7 @@ type _DefaultRule struct {
 	Network                  Listable[string] `json:"network,omitempty"`
 	AuthUser                 Listable[string] `json:"auth_user,omitempty"`
 	Protocol                 Listable[string] `json:"protocol,omitempty"`
+	Client                   Listable[string] `json:"client,omitempty"`
 	Domain                   Listable[string] `json:"domain,omitempty"`
 	DomainSuffix             Listable[string] `json:"domain_suffix,omitempty"`
 	DomainKeyword            Listable[string] `json:"domain_keyword,omitempty"`

+ 79 - 60
route/router.go

@@ -854,8 +854,9 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad
 
 	if metadata.InboundOptions.SniffEnabled && !sniff.Skip(metadata) {
 		buffer := buf.NewPacket()
-		sniffMetadata, err := sniff.PeekStream(
+		err := sniff.PeekStream(
 			ctx,
+			&metadata,
 			conn,
 			buffer,
 			time.Duration(metadata.InboundOptions.SniffTimeout),
@@ -864,9 +865,7 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad
 			sniff.HTTPHost,
 			sniff.BitTorrent,
 		)
-		if sniffMetadata != nil {
-			metadata.Protocol = sniffMetadata.Protocol
-			metadata.Domain = sniffMetadata.Domain
+		if err == nil {
 			if metadata.InboundOptions.SniffOverrideDestination && M.IsDomainName(metadata.Domain) {
 				metadata.Destination = M.Socksaddr{
 					Fqdn: metadata.Domain,
@@ -878,8 +877,6 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad
 			} else {
 				r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol)
 			}
-		} else if err != nil {
-			r.logger.TraceContext(ctx, "sniffed no protocol: ", err)
 		}
 		if !buffer.IsEmpty() {
 			conn = bufio.NewCachedConn(conn, buffer)
@@ -980,65 +977,87 @@ func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, m
 	}*/
 
 	if metadata.InboundOptions.SniffEnabled || metadata.Destination.Addr.IsUnspecified() {
-		var (
-			buffer      = buf.NewPacket()
-			destination M.Socksaddr
-			done        = make(chan struct{})
-			err         error
-		)
-		go func() {
-			sniffTimeout := C.ReadPayloadTimeout
-			if metadata.InboundOptions.SniffTimeout > 0 {
-				sniffTimeout = time.Duration(metadata.InboundOptions.SniffTimeout)
-			}
-			conn.SetReadDeadline(time.Now().Add(sniffTimeout))
-			destination, err = conn.ReadPacket(buffer)
-			conn.SetReadDeadline(time.Time{})
-			close(done)
-		}()
-		select {
-		case <-done:
-		case <-ctx.Done():
-			conn.Close()
-			return ctx.Err()
-		}
-		if err != nil {
-			buffer.Release()
-			if !errors.Is(err, os.ErrDeadlineExceeded) {
-				return err
-			}
-		} else {
-			if metadata.Destination.Addr.IsUnspecified() {
-				metadata.Destination = destination
+		var bufferList []*buf.Buffer
+		for {
+			var (
+				buffer      = buf.NewPacket()
+				destination M.Socksaddr
+				done        = make(chan struct{})
+				err         error
+			)
+			go func() {
+				sniffTimeout := C.ReadPayloadTimeout
+				if metadata.InboundOptions.SniffTimeout > 0 {
+					sniffTimeout = time.Duration(metadata.InboundOptions.SniffTimeout)
+				}
+				conn.SetReadDeadline(time.Now().Add(sniffTimeout))
+				destination, err = conn.ReadPacket(buffer)
+				conn.SetReadDeadline(time.Time{})
+				close(done)
+			}()
+			select {
+			case <-done:
+			case <-ctx.Done():
+				conn.Close()
+				return ctx.Err()
 			}
-			if metadata.InboundOptions.SniffEnabled {
-				sniffMetadata, _ := sniff.PeekPacket(
-					ctx,
-					buffer.Bytes(),
-					sniff.DomainNameQuery,
-					sniff.QUICClientHello,
-					sniff.STUNMessage,
-					sniff.UTP,
-					sniff.UDPTracker,
-					sniff.DTLSRecord,
-				)
-				if sniffMetadata != nil {
-					metadata.Protocol = sniffMetadata.Protocol
-					metadata.Domain = sniffMetadata.Domain
-					if metadata.InboundOptions.SniffOverrideDestination && M.IsDomainName(metadata.Domain) {
-						metadata.Destination = M.Socksaddr{
-							Fqdn: metadata.Domain,
-							Port: metadata.Destination.Port,
-						}
-					}
-					if metadata.Domain != "" {
-						r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain)
+			if err != nil {
+				buffer.Release()
+				if !errors.Is(err, os.ErrDeadlineExceeded) {
+					return err
+				}
+			} else {
+				if metadata.Destination.Addr.IsUnspecified() {
+					metadata.Destination = destination
+				}
+				if metadata.InboundOptions.SniffEnabled {
+					if len(bufferList) > 0 {
+						err = sniff.PeekPacket(
+							ctx,
+							&metadata,
+							buffer.Bytes(),
+							sniff.QUICClientHello,
+						)
 					} else {
-						r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol)
+						err = sniff.PeekPacket(
+							ctx, &metadata,
+							buffer.Bytes(),
+							sniff.DomainNameQuery,
+							sniff.QUICClientHello,
+							sniff.STUNMessage,
+							sniff.UTP,
+							sniff.UDPTracker,
+							sniff.DTLSRecord)
+					}
+					if E.IsMulti(err, sniff.ErrClientHelloFragmented) && len(bufferList) == 0 {
+						bufferList = append(bufferList, buffer)
+						r.logger.DebugContext(ctx, "attempt to sniff fragmented QUIC client hello")
+						continue
+					}
+					if metadata.Protocol != "" {
+						if metadata.InboundOptions.SniffOverrideDestination && M.IsDomainName(metadata.Domain) {
+							metadata.Destination = M.Socksaddr{
+								Fqdn: metadata.Domain,
+								Port: metadata.Destination.Port,
+							}
+						}
+						if metadata.Domain != "" && metadata.Client != "" {
+							r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain, ", client: ", metadata.Client)
+						} else if metadata.Domain != "" {
+							r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain)
+						} else if metadata.Client != "" {
+							r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", client: ", metadata.Client)
+						} else {
+							r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol)
+						}
 					}
 				}
+				conn = bufio.NewCachedPacketConn(conn, buffer, destination)
 			}
-			conn = bufio.NewCachedPacketConn(conn, buffer, destination)
+			for _, cachedBuffer := range common.Reverse(bufferList) {
+				conn = bufio.NewCachedPacketConn(conn, cachedBuffer, destination)
+			}
+			break
 		}
 	}
 	if r.dnsReverseMapping != nil && metadata.Domain == "" {

+ 5 - 0
route/rule_default.go

@@ -79,6 +79,11 @@ func NewDefaultRule(router adapter.Router, logger log.ContextLogger, options opt
 		rule.items = append(rule.items, item)
 		rule.allItems = append(rule.allItems, item)
 	}
+	if len(options.Client) > 0 {
+		item := NewClientItem(options.Client)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
 	if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 {
 		item := NewDomainItem(options.Domain, options.DomainSuffix)
 		rule.destinationAddressItems = append(rule.destinationAddressItems, item)

+ 37 - 0
route/rule_item_client.go

@@ -0,0 +1,37 @@
+package route
+
+import (
+	"strings"
+
+	"github.com/sagernet/sing-box/adapter"
+	F "github.com/sagernet/sing/common/format"
+)
+
+var _ RuleItem = (*ClientItem)(nil)
+
+type ClientItem struct {
+	clients   []string
+	clientMap map[string]bool
+}
+
+func NewClientItem(clients []string) *ClientItem {
+	clientMap := make(map[string]bool)
+	for _, client := range clients {
+		clientMap[client] = true
+	}
+	return &ClientItem{
+		clients:   clients,
+		clientMap: clientMap,
+	}
+}
+
+func (r *ClientItem) Match(metadata *adapter.InboundContext) bool {
+	return r.clientMap[metadata.Client]
+}
+
+func (r *ClientItem) String() string {
+	if len(r.clients) == 1 {
+		return F.ToString("client=", r.clients[0])
+	}
+	return F.ToString("client=[", strings.Join(r.clients, " "), "]")
+}

Някои файлове не бяха показани, защото твърде много файлове са промени