Browse Source

Improve QUIC sniffer

世界 1 year ago
parent
commit
5a2c7037fe

+ 7 - 2
adapter/inbound.go

@@ -31,11 +31,16 @@ type InboundContext struct {
 	Network     string
 	Network     string
 	Source      M.Socksaddr
 	Source      M.Socksaddr
 	Destination M.Socksaddr
 	Destination M.Socksaddr
-	Domain      string
-	Protocol    string
 	User        string
 	User        string
 	Outbound    string
 	Outbound    string
 
 
+	// sniffer
+
+	Protocol     string
+	Domain       string
+	Client       string
+	SniffContext any
+
 	// cache
 	// cache
 
 
 	InboundDetour        string
 	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.
 // BitTorrent detects if the stream is a BitTorrent connection.
 // For the BitTorrent protocol specification, see https://www.bittorrent.org/beps/bep_0003.html
 // 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
 	var first byte
 	err := binary.Read(reader, binary.BigEndian, &first)
 	err := binary.Read(reader, binary.BigEndian, &first)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	if first != 19 {
 	if first != 19 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 
 
 	var protocol [19]byte
 	var protocol [19]byte
 	_, err = reader.Read(protocol[:])
 	_, err = reader.Read(protocol[:])
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 	if string(protocol[:]) != "BitTorrent protocol" {
 	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.
 // UTP detects if the packet is a uTP connection packet.
 // For the uTP protocol specification, see
 // For the uTP protocol specification, see
 //  1. https://www.bittorrent.org/beps/bep_0029.html
 //  1. https://www.bittorrent.org/beps/bep_0029.html
 //  2. https://github.com/bittorrent/libutp/blob/2b364cbb0650bdab64a5de2abb4518f9f228ec44/utp_internal.cpp#L112
 //  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.
 	// A valid uTP packet must be at least 20 bytes long.
 	if len(packet) < 20 {
 	if len(packet) < 20 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 
 
 	version := packet[0] & 0x0F
 	version := packet[0] & 0x0F
 	ty := packet[0] >> 4
 	ty := packet[0] >> 4
 	if version != 1 || ty > 4 {
 	if version != 1 || ty > 4 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 
 
 	// Validate the extensions
 	// Validate the extensions
@@ -66,36 +65,35 @@ func UTP(_ context.Context, packet []byte) (*adapter.InboundContext, error) {
 	for extension != 0 {
 	for extension != 0 {
 		err := binary.Read(reader, binary.BigEndian, &extension)
 		err := binary.Read(reader, binary.BigEndian, &extension)
 		if err != nil {
 		if err != nil {
-			return nil, err
+			return err
 		}
 		}
 
 
 		var length byte
 		var length byte
 		err = binary.Read(reader, binary.BigEndian, &length)
 		err = binary.Read(reader, binary.BigEndian, &length)
 		if err != nil {
 		if err != nil {
-			return nil, err
+			return err
 		}
 		}
 		_, err = reader.Seek(int64(length), io.SeekCurrent)
 		_, err = reader.Seek(int64(length), io.SeekCurrent)
 		if err != nil {
 		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.
 // 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
 // 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 {
 	if len(packet) < trackerConnectMinSize {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 	if binary.BigEndian.Uint64(packet[:8]) != trackerProtocolID {
 	if binary.BigEndian.Uint64(packet[:8]) != trackerProtocolID {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 	if binary.BigEndian.Uint32(packet[8:12]) != trackerConnectFlag {
 	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"
 	"encoding/hex"
 	"testing"
 	"testing"
 
 
+	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/sniff"
 	"github.com/sagernet/sing-box/common/sniff"
 	C "github.com/sagernet/sing-box/constant"
 	C "github.com/sagernet/sing-box/constant"
 
 
@@ -24,7 +25,8 @@ func TestSniffBittorrent(t *testing.T) {
 	for _, pkt := range packets {
 	for _, pkt := range packets {
 		pkt, err := hex.DecodeString(pkt)
 		pkt, err := hex.DecodeString(pkt)
 		require.NoError(t, err)
 		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.NoError(t, err)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 	}
 	}
@@ -43,8 +45,8 @@ func TestSniffUTP(t *testing.T) {
 	for _, pkt := range packets {
 	for _, pkt := range packets {
 		pkt, err := hex.DecodeString(pkt)
 		pkt, err := hex.DecodeString(pkt)
 		require.NoError(t, err)
 		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.NoError(t, err)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 	}
 	}
@@ -63,7 +65,8 @@ func TestSniffUDPTracker(t *testing.T) {
 		pkt, err := hex.DecodeString(pkt)
 		pkt, err := hex.DecodeString(pkt)
 		require.NoError(t, err)
 		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.NoError(t, err)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 		require.Equal(t, C.ProtocolBitTorrent, metadata.Protocol)
 	}
 	}

+ 10 - 10
common/sniff/dns.go

@@ -17,18 +17,17 @@ import (
 	mDNS "github.com/miekg/dns"
 	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
 	var length uint16
 	err := binary.Read(reader, binary.BigEndian, &length)
 	err := binary.Read(reader, binary.BigEndian, &length)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return os.ErrInvalid
 	}
 	}
 	if length == 0 {
 	if length == 0 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 	buffer := buf.NewSize(int(length))
 	buffer := buf.NewSize(int(length))
 	defer buffer.Release()
 	defer buffer.Release()
-
 	readCtx, cancel := context.WithTimeout(readCtx, time.Millisecond*100)
 	readCtx, cancel := context.WithTimeout(readCtx, time.Millisecond*100)
 	var readTask task.Group
 	var readTask task.Group
 	readTask.Append0(func(ctx context.Context) error {
 	readTask.Append0(func(ctx context.Context) error {
@@ -37,19 +36,20 @@ func StreamDomainNameQuery(readCtx context.Context, reader io.Reader) (*adapter.
 	err = readTask.Run(readCtx)
 	err = readTask.Run(readCtx)
 	cancel()
 	cancel()
 	if err != nil {
 	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
 	var msg mDNS.Msg
 	err := msg.Unpack(packet)
 	err := msg.Unpack(packet)
 	if err != nil {
 	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) {
 	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"
 	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
 	const fixedHeaderSize = 13
 	if len(packet) < fixedHeaderSize {
 	if len(packet) < fixedHeaderSize {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 	contentType := packet[0]
 	contentType := packet[0]
 	switch contentType {
 	switch contentType {
 	case 20, 21, 22, 23, 25:
 	case 20, 21, 22, 23, 25:
 	default:
 	default:
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 	versionMajor := packet[1]
 	versionMajor := packet[1]
 	if versionMajor != 0xfe {
 	if versionMajor != 0xfe {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 	versionMinor := packet[2]
 	versionMinor := packet[2]
 	if versionMinor != 0xff && versionMinor != 0xfd {
 	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"
 	"encoding/hex"
 	"testing"
 	"testing"
 
 
+	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/sniff"
 	"github.com/sagernet/sing-box/common/sniff"
 	C "github.com/sagernet/sing-box/constant"
 	C "github.com/sagernet/sing-box/constant"
 
 
@@ -15,7 +16,8 @@ func TestSniffDTLSClientHello(t *testing.T) {
 	t.Parallel()
 	t.Parallel()
 	packet, err := hex.DecodeString("16fefd0000000000000000007e010000720000000000000072fefd668a43523798e064bd806d0c87660de9c611a59bbdfc3892c4e072d94f2cafc40000000cc02bc02fc00ac014c02cc0300100003c000d0010000e0403050306030401050106010807ff01000100000a00080006001d00170018000b00020100000e000900060008000700010000170000")
 	packet, err := hex.DecodeString("16fefd0000000000000000007e010000720000000000000072fefd668a43523798e064bd806d0c87660de9c611a59bbdfc3892c4e072d94f2cafc40000000cc02bc02fc00ac014c02cc0300100003c000d0010000e0403050306030401050106010807ff01000100000a00080006001d00170018000b00020100000e000900060008000700010000170000")
 	require.NoError(t, err)
 	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.NoError(t, err)
 	require.Equal(t, metadata.Protocol, C.ProtocolDTLS)
 	require.Equal(t, metadata.Protocol, C.ProtocolDTLS)
 }
 }
@@ -24,7 +26,8 @@ func TestSniffDTLSClientApplicationData(t *testing.T) {
 	t.Parallel()
 	t.Parallel()
 	packet, err := hex.DecodeString("17fefd000100000000000100440001000000000001a4f682b77ecadd10f3f3a2f78d90566212366ff8209fd77314f5a49352f9bb9bd12f4daba0b4736ae29e46b9714d3b424b3e6d0234736619b5aa0d3f")
 	packet, err := hex.DecodeString("17fefd000100000000000100440001000000000001a4f682b77ecadd10f3f3a2f78d90566212366ff8209fd77314f5a49352f9bb9bd12f4daba0b4736ae29e46b9714d3b424b3e6d0234736619b5aa0d3f")
 	require.NoError(t, err)
 	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.NoError(t, err)
 	require.Equal(t, metadata.Protocol, C.ProtocolDTLS)
 	require.Equal(t, metadata.Protocol, C.ProtocolDTLS)
 }
 }

+ 5 - 3
common/sniff/http.go

@@ -11,10 +11,12 @@ import (
 	"github.com/sagernet/sing/protocol/http"
 	"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))
 	request, err := http.ReadRequest(std_bufio.NewReader(reader))
 	if err != nil {
 	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"
 	"strings"
 	"testing"
 	"testing"
 
 
+	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/sniff"
 	"github.com/sagernet/sing-box/common/sniff"
 
 
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
@@ -13,7 +14,8 @@ import (
 func TestSniffHTTP1(t *testing.T) {
 func TestSniffHTTP1(t *testing.T) {
 	t.Parallel()
 	t.Parallel()
 	pkt := "GET / HTTP/1.1\r\nHost: www.google.com\r\nAccept: */*\r\n\r\n"
 	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.NoError(t, err)
 	require.Equal(t, metadata.Domain, "www.google.com")
 	require.Equal(t, metadata.Domain, "www.google.com")
 }
 }
@@ -21,7 +23,8 @@ func TestSniffHTTP1(t *testing.T) {
 func TestSniffHTTP1WithPort(t *testing.T) {
 func TestSniffHTTP1WithPort(t *testing.T) {
 	t.Parallel()
 	t.Parallel()
 	pkt := "GET / HTTP/1.1\r\nHost: www.gov.cn:8080\r\nAccept: */*\r\n\r\n"
 	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.NoError(t, err)
 	require.Equal(t, metadata.Domain, "www.gov.cn")
 	require.Equal(t, metadata.Domain, "www.gov.cn")
 }
 }

+ 144 - 70
common/sniff/quic.go

@@ -5,95 +5,99 @@ import (
 	"context"
 	"context"
 	"crypto"
 	"crypto"
 	"crypto/aes"
 	"crypto/aes"
+	"crypto/tls"
 	"encoding/binary"
 	"encoding/binary"
 	"io"
 	"io"
 	"os"
 	"os"
 
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/ja3"
 	"github.com/sagernet/sing-box/common/sniff/internal/qtls"
 	"github.com/sagernet/sing-box/common/sniff/internal/qtls"
 	C "github.com/sagernet/sing-box/constant"
 	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing/common/buf"
 	E "github.com/sagernet/sing/common/exceptions"
 	E "github.com/sagernet/sing/common/exceptions"
 
 
 	"golang.org/x/crypto/hkdf"
 	"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()
 	typeByte, err := reader.ReadByte()
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 	if typeByte&0x40 == 0 {
 	if typeByte&0x40 == 0 {
-		return nil, E.New("bad type byte")
+		return E.New("bad type byte")
 	}
 	}
 	var versionNumber uint32
 	var versionNumber uint32
 	err = binary.Read(reader, binary.BigEndian, &versionNumber)
 	err = binary.Read(reader, binary.BigEndian, &versionNumber)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 	if versionNumber != qtls.VersionDraft29 && versionNumber != qtls.Version1 && versionNumber != qtls.Version2 {
 	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
 	packetType := (typeByte & 0x30) >> 4
 	if packetType == 0 && versionNumber == qtls.Version2 || packetType == 2 && versionNumber != qtls.Version2 || packetType > 2 {
 	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()
 	destConnIDLen, err := reader.ReadByte()
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	if destConnIDLen == 0 || destConnIDLen > 20 {
 	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)
 	destConnID := make([]byte, destConnIDLen)
 	_, err = io.ReadFull(reader, destConnID)
 	_, err = io.ReadFull(reader, destConnID)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	srcConnIDLen, err := reader.ReadByte()
 	srcConnIDLen, err := reader.ReadByte()
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	_, err = io.CopyN(io.Discard, reader, int64(srcConnIDLen))
 	_, err = io.CopyN(io.Discard, reader, int64(srcConnIDLen))
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	tokenLen, err := qtls.ReadUvarint(reader)
 	tokenLen, err := qtls.ReadUvarint(reader)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	_, err = io.CopyN(io.Discard, reader, int64(tokenLen))
 	_, err = io.CopyN(io.Discard, reader, int64(tokenLen))
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	packetLen, err := qtls.ReadUvarint(reader)
 	packetLen, err := qtls.ReadUvarint(reader)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	hdrLen := int(reader.Size()) - reader.Len()
 	hdrLen := int(reader.Size()) - reader.Len()
 	if hdrLen+int(packetLen) > len(packet) {
 	if hdrLen+int(packetLen) > len(packet) {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 
 
 	_, err = io.CopyN(io.Discard, reader, 4)
 	_, err = io.CopyN(io.Discard, reader, 4)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	pnBytes := make([]byte, aes.BlockSize)
 	pnBytes := make([]byte, aes.BlockSize)
 	_, err = io.ReadFull(reader, pnBytes)
 	_, err = io.ReadFull(reader, pnBytes)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	var salt []byte
 	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)
 	hpKey := qtls.HKDFExpandLabel(crypto.SHA256, secret, []byte{}, hkdfHeaderProtectionLabel, 16)
 	block, err := aes.NewCipher(hpKey)
 	block, err := aes.NewCipher(hpKey)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 	mask := make([]byte, aes.BlockSize)
 	mask := make([]byte, aes.BlockSize)
 	block.Encrypt(mask, pnBytes)
 	block.Encrypt(mask, pnBytes)
@@ -129,7 +133,7 @@ func QUICClientHello(ctx context.Context, packet []byte) (*adapter.InboundContex
 	}
 	}
 	packetNumberLength := newPacket[0]&0x3 + 1
 	packetNumberLength := newPacket[0]&0x3 + 1
 	if hdrLen+int(packetNumberLength) > int(packetLen)+hdrLen {
 	if hdrLen+int(packetNumberLength) > int(packetLen)+hdrLen {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 	var packetNumber uint32
 	var packetNumber uint32
 	switch packetNumberLength {
 	switch packetNumberLength {
@@ -142,7 +146,7 @@ func QUICClientHello(ctx context.Context, packet []byte) (*adapter.InboundContex
 	case 4:
 	case 4:
 		packetNumber = binary.BigEndian.Uint32(newPacket[hdrLen:])
 		packetNumber = binary.BigEndian.Uint32(newPacket[hdrLen:])
 	default:
 	default:
-		return nil, E.New("bad packet number length")
+		return E.New("bad packet number length")
 	}
 	}
 	extHdrLen := hdrLen + int(packetNumberLength)
 	extHdrLen := hdrLen + int(packetNumberLength)
 	copy(newPacket[extHdrLen:hdrLen+4], packet[extHdrLen:])
 	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))
 	binary.BigEndian.PutUint64(nonce[len(nonce)-8:], uint64(packetNumber))
 	decrypted, err := cipher.Open(newPacket[extHdrLen:extHdrLen], nonce, data, newPacket[:extHdrLen])
 	decrypted, err := cipher.Open(newPacket[extHdrLen:extHdrLen], nonce, data, newPacket[:extHdrLen])
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 	var frameType byte
 	var frameType byte
-	var frameLen uint64
-	var fragments []struct {
-		offset  uint64
-		length  uint64
-		payload []byte
-	}
+	var fragments []qCryptoFragment
 	decryptedReader := bytes.NewReader(decrypted)
 	decryptedReader := bytes.NewReader(decrypted)
+	const (
+		frameTypePadding         = 0x00
+		frameTypePing            = 0x01
+		frameTypeAck             = 0x02
+		frameTypeAck2            = 0x03
+		frameTypeCrypto          = 0x06
+		frameTypeConnectionClose = 0x1c
+	)
+	var frameTypeList []uint8
 	for {
 	for {
 		frameType, err = decryptedReader.ReadByte()
 		frameType, err = decryptedReader.ReadByte()
 		if err == io.EOF {
 		if err == io.EOF {
 			break
 			break
 		}
 		}
+		frameTypeList = append(frameTypeList, frameType)
 		switch frameType {
 		switch frameType {
-		case 0x00: // PADDING
+		case frameTypePadding:
 			continue
 			continue
-		case 0x01: // PING
+		case frameTypePing:
 			continue
 			continue
-		case 0x02, 0x03: // ACK
+		case frameTypeAck, frameTypeAck2:
 			_, err = qtls.ReadUvarint(decryptedReader) // Largest Acknowledged
 			_, err = qtls.ReadUvarint(decryptedReader) // Largest Acknowledged
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
 			_, err = qtls.ReadUvarint(decryptedReader) // ACK Delay
 			_, err = qtls.ReadUvarint(decryptedReader) // ACK Delay
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
 			ackRangeCount, err := qtls.ReadUvarint(decryptedReader) // ACK Range Count
 			ackRangeCount, err := qtls.ReadUvarint(decryptedReader) // ACK Range Count
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
 			_, err = qtls.ReadUvarint(decryptedReader) // First ACK Range
 			_, err = qtls.ReadUvarint(decryptedReader) // First ACK Range
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
 			for i := 0; i < int(ackRangeCount); i++ {
 			for i := 0; i < int(ackRangeCount); i++ {
 				_, err = qtls.ReadUvarint(decryptedReader) // Gap
 				_, err = qtls.ReadUvarint(decryptedReader) // Gap
 				if err != nil {
 				if err != nil {
-					return nil, err
+					return err
 				}
 				}
 				_, err = qtls.ReadUvarint(decryptedReader) // ACK Range Length
 				_, err = qtls.ReadUvarint(decryptedReader) // ACK Range Length
 				if err != nil {
 				if err != nil {
-					return nil, err
+					return err
 				}
 				}
 			}
 			}
 			if frameType == 0x03 {
 			if frameType == 0x03 {
 				_, err = qtls.ReadUvarint(decryptedReader) // ECT0 Count
 				_, err = qtls.ReadUvarint(decryptedReader) // ECT0 Count
 				if err != nil {
 				if err != nil {
-					return nil, err
+					return err
 				}
 				}
 				_, err = qtls.ReadUvarint(decryptedReader) // ECT1 Count
 				_, err = qtls.ReadUvarint(decryptedReader) // ECT1 Count
 				if err != nil {
 				if err != nil {
-					return nil, err
+					return err
 				}
 				}
 				_, err = qtls.ReadUvarint(decryptedReader) // ECN-CE Count
 				_, err = qtls.ReadUvarint(decryptedReader) // ECN-CE Count
 				if err != nil {
 				if err != nil {
-					return nil, err
+					return err
 				}
 				}
 			}
 			}
-		case 0x06: // CRYPTO
+		case frameTypeCrypto:
 			var offset uint64
 			var offset uint64
 			offset, err = qtls.ReadUvarint(decryptedReader)
 			offset, err = qtls.ReadUvarint(decryptedReader)
 			if err != nil {
 			if err != nil {
-				return &adapter.InboundContext{Protocol: C.ProtocolQUIC}, err
+				return err
 			}
 			}
 			var length uint64
 			var length uint64
 			length, err = qtls.ReadUvarint(decryptedReader)
 			length, err = qtls.ReadUvarint(decryptedReader)
 			if err != nil {
 			if err != nil {
-				return &adapter.InboundContext{Protocol: C.ProtocolQUIC}, err
+				return err
 			}
 			}
 			index := len(decrypted) - decryptedReader.Len()
 			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)
 			_, err = decryptedReader.Seek(int64(length), io.SeekCurrent)
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
-		case 0x1c: // CONNECTION_CLOSE
+		case frameTypeConnectionClose:
 			_, err = qtls.ReadUvarint(decryptedReader) // Error Code
 			_, err = qtls.ReadUvarint(decryptedReader) // Error Code
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
 			_, err = qtls.ReadUvarint(decryptedReader) // Frame Type
 			_, err = qtls.ReadUvarint(decryptedReader) // Frame Type
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
 			var length uint64
 			var length uint64
 			length, err = qtls.ReadUvarint(decryptedReader) // Reason Phrase Length
 			length, err = qtls.ReadUvarint(decryptedReader) // Reason Phrase Length
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
 			_, err = decryptedReader.Seek(int64(length), io.SeekCurrent) // Reason Phrase
 			_, err = decryptedReader.Seek(int64(length), io.SeekCurrent) // Reason Phrase
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return err
 			}
 			}
 		default:
 		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 index uint64
 	var length int
 	var length int
-	var readers []io.Reader
-	readers = append(readers, bytes.NewReader(tlsHdr))
 find:
 find:
 	for {
 	for {
 		for _, fragment := range fragments {
 		for _, fragment := range fragments {
 			if fragment.offset == index {
 			if fragment.offset == index {
-				readers = append(readers, bytes.NewReader(fragment.payload))
+				buffer.Write(fragment.payload)
 				index = fragment.offset + fragment.length
 				index = fragment.offset + fragment.length
 				length++
 				length++
 				continue find
 				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
 			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
+}

File diff suppressed because it is too large
+ 3 - 2
common/sniff/quic_test.go


+ 14 - 15
common/sniff/sniff.go

@@ -14,8 +14,8 @@ import (
 )
 )
 
 
 type (
 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 {
 func Skip(metadata adapter.InboundContext) bool {
@@ -34,7 +34,7 @@ func Skip(metadata adapter.InboundContext) bool {
 	return false
 	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 {
 	if timeout == 0 {
 		timeout = C.ReadPayloadTimeout
 		timeout = C.ReadPayloadTimeout
 	}
 	}
@@ -43,7 +43,7 @@ func PeekStream(ctx context.Context, conn net.Conn, buffer *buf.Buffer, timeout
 	for i := 0; ; i++ {
 	for i := 0; ; i++ {
 		err := conn.SetReadDeadline(deadline)
 		err := conn.SetReadDeadline(deadline)
 		if err != nil {
 		if err != nil {
-			return nil, E.Cause(err, "set read deadline")
+			return E.Cause(err, "set read deadline")
 		}
 		}
 		_, err = buffer.ReadOnceFrom(conn)
 		_, err = buffer.ReadOnceFrom(conn)
 		_ = conn.SetReadDeadline(time.Time{})
 		_ = conn.SetReadDeadline(time.Time{})
@@ -51,29 +51,28 @@ func PeekStream(ctx context.Context, conn net.Conn, buffer *buf.Buffer, timeout
 			if i > 0 {
 			if i > 0 {
 				break
 				break
 			}
 			}
-			return nil, E.Cause(err, "read payload")
+			return E.Cause(err, "read payload")
 		}
 		}
 		errors = nil
 		errors = nil
-		var metadata *adapter.InboundContext
 		for _, sniffer := range sniffers {
 		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)
 			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
 	var errors []error
 	for _, sniffer := range sniffers {
 	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)
 		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"
 	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)
 	pLen := len(packet)
 	if pLen < 20 {
 	if pLen < 20 {
-		return nil, os.ErrInvalid
+		return os.ErrInvalid
 	}
 	}
 	if binary.BigEndian.Uint32(packet[4:8]) != 0x2112A442 {
 	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])) {
 	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"
 	"encoding/hex"
 	"testing"
 	"testing"
 
 
+	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/sniff"
 	"github.com/sagernet/sing-box/common/sniff"
 	C "github.com/sagernet/sing-box/constant"
 	C "github.com/sagernet/sing-box/constant"
 
 
@@ -15,14 +16,16 @@ func TestSniffSTUN(t *testing.T) {
 	t.Parallel()
 	t.Parallel()
 	packet, err := hex.DecodeString("000100002112a44224b1a025d0c180c484341306")
 	packet, err := hex.DecodeString("000100002112a44224b1a025d0c180c484341306")
 	require.NoError(t, err)
 	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.NoError(t, err)
 	require.Equal(t, metadata.Protocol, C.ProtocolSTUN)
 	require.Equal(t, metadata.Protocol, C.ProtocolSTUN)
 }
 }
 
 
 func FuzzSniffSTUN(f *testing.F) {
 func FuzzSniffSTUN(f *testing.F) {
 	f.Fuzz(func(t *testing.T, data []byte) {
 	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()
 			t.Fail()
 		}
 		}
 	})
 	})

+ 5 - 3
common/sniff/tls.go

@@ -10,7 +10,7 @@ import (
 	"github.com/sagernet/sing/common/bufio"
 	"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
 	var clientHello *tls.ClientHelloInfo
 	err := tls.Server(bufio.NewReadOnlyConn(reader), &tls.Config{
 	err := tls.Server(bufio.NewReadOnlyConn(reader), &tls.Config{
 		GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) {
 		GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) {
@@ -19,7 +19,9 @@ func TLSClientHello(ctx context.Context, reader io.Reader) (*adapter.InboundCont
 		},
 		},
 	}).HandshakeContext(ctx)
 	}).HandshakeContext(ctx)
 	if clientHello != nil {
 	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"
 	ProtocolBitTorrent = "bittorrent"
 	ProtocolDTLS       = "dtls"
 	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"
 !!! 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-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)  
 
 
@@ -40,6 +41,12 @@ icon: material/alert-decagram
           "http",
           "http",
           "quic"
           "quic"
         ],
         ],
+        "client": [
+          "chromium",
+          "safari",
+          "firefox",
+          "quic-go"
+        ],
         "domain": [
         "domain": [
           "test.com"
           "test.com"
         ],
         ],
@@ -166,7 +173,13 @@ Username, see each inbound for details.
 
 
 #### protocol
 #### 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
 #### network
 
 

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

@@ -4,8 +4,9 @@ icon: material/alert-decagram
 
 
 !!! quote "sing-box 1.10.0 中的更改"
 !!! quote "sing-box 1.10.0 中的更改"
 
 
+    :material-plus: [client](#client)  
     :material-delete-clock: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source)  
     :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 中的更改"
 !!! quote "sing-box 1.8.0 中的更改"
 
 
@@ -40,6 +41,12 @@ icon: material/alert-decagram
           "http",
           "http",
           "quic"
           "quic"
         ],
         ],
+        "client": [
+          "chromium",
+          "safari",
+          "firefox",
+          "quic-go"
+        ],
         "domain": [
         "domain": [
           "test.com"
           "test.com"
         ],
         ],
@@ -166,6 +173,12 @@ icon: material/alert-decagram
 
 
 探测到的协议, 参阅 [协议探测](/zh/configuration/route/sniff/)。
 探测到的协议, 参阅 [协议探测](/zh/configuration/route/sniff/)。
 
 
+#### client
+
+!!! question "自 sing-box 1.10.0 起"
+
+探测到的客户端类型, 参阅 [协议探测](/zh/configuration/route/sniff/)。
+
 #### network
 #### network
 
 
 `tcp` 或 `udp`。
 `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"
 !!! 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
     :material-plus: DTLS support
 
 
 If enabled in the inbound, the protocol and domain name (if present) of by the connection can be sniffed.
 If enabled in the inbound, the protocol and domain name (if present) of by the connection can be sniffed.
 
 
 #### Supported Protocols
 #### 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 中的更改"
 !!! quote "sing-box 1.10.0 中的更改"
 
 
-    :material-plus: BitTorrent 支持
+    :material-plus: QUIC 的 客户端类型探测支持  
+    :material-plus: QUIC 的 Chromium 支持  
+    :material-plus: BitTorrent 支持  
     :material-plus: DTLS 支持
     :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
 	go.uber.org/zap v1.27.0
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
 	golang.org/x/crypto v0.25.0
 	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/net v0.27.0
 	golang.org/x/sys v0.25.0
 	golang.org/x/sys v0.25.0
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
 	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/vishvananda/netns v0.0.4 // indirect
 	github.com/zeebo/blake3 v0.2.3 // indirect
 	github.com/zeebo/blake3 v0.2.3 // indirect
 	go.uber.org/multierr v1.11.0 // 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/mod v0.19.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
 	golang.org/x/text v0.18.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"`
 	Network                  Listable[string] `json:"network,omitempty"`
 	AuthUser                 Listable[string] `json:"auth_user,omitempty"`
 	AuthUser                 Listable[string] `json:"auth_user,omitempty"`
 	Protocol                 Listable[string] `json:"protocol,omitempty"`
 	Protocol                 Listable[string] `json:"protocol,omitempty"`
+	Client                   Listable[string] `json:"client,omitempty"`
 	Domain                   Listable[string] `json:"domain,omitempty"`
 	Domain                   Listable[string] `json:"domain,omitempty"`
 	DomainSuffix             Listable[string] `json:"domain_suffix,omitempty"`
 	DomainSuffix             Listable[string] `json:"domain_suffix,omitempty"`
 	DomainKeyword            Listable[string] `json:"domain_keyword,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) {
 	if metadata.InboundOptions.SniffEnabled && !sniff.Skip(metadata) {
 		buffer := buf.NewPacket()
 		buffer := buf.NewPacket()
-		sniffMetadata, err := sniff.PeekStream(
+		err := sniff.PeekStream(
 			ctx,
 			ctx,
+			&metadata,
 			conn,
 			conn,
 			buffer,
 			buffer,
 			time.Duration(metadata.InboundOptions.SniffTimeout),
 			time.Duration(metadata.InboundOptions.SniffTimeout),
@@ -864,9 +865,7 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad
 			sniff.HTTPHost,
 			sniff.HTTPHost,
 			sniff.BitTorrent,
 			sniff.BitTorrent,
 		)
 		)
-		if sniffMetadata != nil {
-			metadata.Protocol = sniffMetadata.Protocol
-			metadata.Domain = sniffMetadata.Domain
+		if err == nil {
 			if metadata.InboundOptions.SniffOverrideDestination && M.IsDomainName(metadata.Domain) {
 			if metadata.InboundOptions.SniffOverrideDestination && M.IsDomainName(metadata.Domain) {
 				metadata.Destination = M.Socksaddr{
 				metadata.Destination = M.Socksaddr{
 					Fqdn: metadata.Domain,
 					Fqdn: metadata.Domain,
@@ -878,8 +877,6 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad
 			} else {
 			} else {
 				r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol)
 				r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol)
 			}
 			}
-		} else if err != nil {
-			r.logger.TraceContext(ctx, "sniffed no protocol: ", err)
 		}
 		}
 		if !buffer.IsEmpty() {
 		if !buffer.IsEmpty() {
 			conn = bufio.NewCachedConn(conn, buffer)
 			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() {
 	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 {
 					} 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 == "" {
 	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.items = append(rule.items, item)
 		rule.allItems = append(rule.allItems, 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 {
 	if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 {
 		item := NewDomainItem(options.Domain, options.DomainSuffix)
 		item := NewDomainItem(options.Domain, options.DomainSuffix)
 		rule.destinationAddressItems = append(rule.destinationAddressItems, item)
 		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, " "), "]")
+}

Some files were not shown because too many files changed in this diff