Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions component/sniffer/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ func (sd *Dispatcher) UDPSniff(packet C.PacketAdapter, packetSender C.PacketSend
continue
}

// Protocol detected but no domain extracted (e.g. STUN)
if host == "" {
metadata.SniffProtocol = current.Protocol()
log.Debugln("[Sniffer] Sniff %s [%s]-->[%s] protocol [%s] detected (no domain)",
metadata.NetWork,
metadata.SourceDetail(),
metadata.RemoteAddress(),
current.Protocol())
return packetSender
}

replaceDomain(metadata, host)
return packetSender
}
Expand Down Expand Up @@ -285,6 +296,17 @@ func NewDispatcher(snifferConfig *Config) (*Dispatcher, error) {
dispatcher.sniffers[s] = config
}

// Auto-register STUN sniffer when sniffer is enabled but STUN was not explicitly configured.
if snifferConfig.Enable {
if _, exists := snifferConfig.Sniffers[sniffer.STUN]; !exists {
s, err := NewSTUNSniffer(SnifferConfig{})
if err == nil {
dispatcher.sniffers[s] = SnifferConfig{}
log.Infoln("[Sniffer] STUN sniffer auto-enabled")
}
}
}

return &dispatcher, nil
}

Expand All @@ -296,6 +318,8 @@ func NewSniffer(name sniffer.Type, snifferConfig SnifferConfig) (sniffer.Sniffer
return NewHTTPSniffer(snifferConfig)
case sniffer.QUIC:
return NewQuicSniffer(snifferConfig)
case sniffer.STUN:
return NewSTUNSniffer(snifferConfig)
default:
return nil, ErrorUnsupportedSniffer
}
Expand Down
113 changes: 113 additions & 0 deletions component/sniffer/sniff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,119 @@ func TestQuicHeaders(t *testing.T) {
}
}

func TestSTUNSniffer(t *testing.T) {
sniffer, err := NewSTUNSniffer(SnifferConfig{})
assert.NoError(t, err)
assert.Equal(t, "stun", sniffer.Protocol())
assert.Equal(t, constant.UDP, sniffer.SupportNetwork())

cases := []struct {
name string
input []byte
isSTUN bool
}{
{
name: "valid STUN Binding Request",
input: []byte{
0x00, 0x01,
0x00, 0x00,
0x21, 0x12, 0xA4, 0x42,
0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c,
},
isSTUN: true,
},
{
name: "valid STUN Binding Response with attributes",
input: []byte{
0x01, 0x01,
0x00, 0x0c,
0x21, 0x12, 0xA4, 0x42,
0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff, 0x00, 0x11,
0x22, 0x33, 0x44, 0x55,
0x00, 0x20,
0x00, 0x08,
0x00, 0x01,
0x63, 0x46,
0x73, 0x92, 0xa5, 0x46,
},
isSTUN: true,
},
{
name: "too short packet",
input: []byte{0x00, 0x01, 0x00, 0x00},
isSTUN: false,
},
{
name: "wrong magic cookie",
input: []byte{
0x00, 0x01,
0x00, 0x00,
0xDE, 0xAD, 0xBE, 0xEF,
0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c,
},
isSTUN: false,
},
{
name: "first 2 bits not zero (DTLS or other)",
input: []byte{
0x80, 0x01,
0x00, 0x00,
0x21, 0x12, 0xA4, 0x42,
0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c,
},
isSTUN: false,
},
{
name: "message length not multiple of 4",
input: []byte{
0x00, 0x01,
0x00, 0x03,
0x21, 0x12, 0xA4, 0x42,
0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c,
},
isSTUN: false,
},
{
name: "TLS handshake packet (not STUN)",
input: []byte{
0x16, 0x03, 0x01, 0x00, 0xc8, 0x01, 0x00, 0x00,
0xc4, 0x03, 0x03, 0x1a, 0xac, 0xb2, 0xa8, 0xfe,
0xb4, 0x96, 0x04, 0x5b,
},
isSTUN: false,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
host, err := sniffer.SniffData(tc.input)
if tc.isSTUN {
assert.NoError(t, err)
assert.Equal(t, "", host)
} else {
assert.Error(t, err)
}
})
}

// Default: no port restriction (all ports match)
assert.True(t, sniffer.SupportPort(3478))
assert.True(t, sniffer.SupportPort(5349))
assert.True(t, sniffer.SupportPort(19302))
assert.True(t, sniffer.SupportPort(443))
assert.True(t, sniffer.SupportPort(80))
assert.True(t, sniffer.SupportPort(12345))
}

func TestTLSHeaders(t *testing.T) {
cases := []struct {
input []byte
Expand Down
68 changes: 68 additions & 0 deletions component/sniffer/stun_sniffer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package sniffer

import (
"encoding/binary"
"errors"

C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/constant/sniffer"
)

// https://datatracker.ietf.org/doc/html/rfc8489
const (
stunHeaderSize = 20
stunMagicCookie = 0x2112A442
)

var (
errNotSTUN = errors.New("not STUN message")
)

var _ sniffer.Sniffer = (*STUNSniffer)(nil)

type STUNSniffer struct {
*BaseSniffer
}

func NewSTUNSniffer(snifferConfig SnifferConfig) (*STUNSniffer, error) {
return &STUNSniffer{
BaseSniffer: NewBaseSniffer(snifferConfig.Ports, C.UDP),
}, nil
}

func (s *STUNSniffer) Protocol() string {
return "stun"
}

func (s *STUNSniffer) SupportNetwork() C.NetWork {
return C.UDP
}

func (s *STUNSniffer) SniffData(b []byte) (string, error) {
if err := detectSTUN(b); err != nil {
return "", err
}

return "", nil
}

func detectSTUN(b []byte) error {
if len(b) < stunHeaderSize {
return errNotSTUN
}

if b[0]&0xC0 != 0x00 {
return errNotSTUN
}

if binary.BigEndian.Uint32(b[4:8]) != stunMagicCookie {
return errNotSTUN
}

msgLen := binary.BigEndian.Uint16(b[2:4])
if msgLen%4 != 0 {
return errNotSTUN
}

return nil
}
3 changes: 2 additions & 1 deletion constant/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ type Metadata struct {
RawSrcAddr net.Addr `json:"-"`
RawDstAddr net.Addr `json:"-"`
// Only domain rule
SniffHost string `json:"sniffHost"`
SniffHost string `json:"sniffHost"`
SniffProtocol string `json:"sniffProtocol,omitempty"`
}

func (m *Metadata) RemoteAddress() string {
Expand Down
3 changes: 3 additions & 0 deletions constant/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
ProcessPathWildcard
RuleSet
Network
SniffProtocol
Uid
SubRules
MATCH
Expand Down Expand Up @@ -103,6 +104,8 @@ func (rt RuleType) String() string {
return "RuleSet"
case Network:
return "Network"
case SniffProtocol:
return "SniffProtocol"
case DSCP:
return "DSCP"
case Uid:
Expand Down
5 changes: 4 additions & 1 deletion constant/sniffer/sniffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ const (
TLS Type = iota
HTTP
QUIC
STUN
)

var (
List = []Type{TLS, HTTP, QUIC}
List = []Type{TLS, HTTP, QUIC, STUN}
)

type Type int
Expand All @@ -36,6 +37,8 @@ func (rt Type) String() string {
return "HTTP"
case QUIC:
return "QUIC"
case STUN:
return "STUN"
default:
return "Unknown"
}
Expand Down
39 changes: 39 additions & 0 deletions rules/common/sniff_protocol.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package common

import (
"strings"

C "github.com/metacubex/mihomo/constant"
)

type SniffProtocolRule struct {
Base
protocol string
adapter string
}

func (s *SniffProtocolRule) RuleType() C.RuleType {
return C.SniffProtocol
}

func (s *SniffProtocolRule) Match(metadata *C.Metadata, helper C.RuleMatchHelper) (bool, string) {
return strings.EqualFold(metadata.SniffProtocol, s.protocol), s.adapter
}

func (s *SniffProtocolRule) Adapter() string {
return s.adapter
}

func (s *SniffProtocolRule) Payload() string {
return s.protocol
}

func NewSniffProtocol(protocol, adapter string) (*SniffProtocolRule, error) {
return &SniffProtocolRule{
Base: Base{},
protocol: strings.ToLower(protocol),
adapter: adapter,
}, nil
}

var _ C.Rule = (*SniffProtocolRule)(nil)
2 changes: 2 additions & 0 deletions rules/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ func ParseRule(tp, payload, target string, params []string, subRules map[string]
parsed, parseErr = RC.NewProcess(payload, target, C.ProcessPathWildcard)
case "NETWORK":
parsed, parseErr = RC.NewNetworkType(payload, target)
case "SNIFF-PROTOCOL":
parsed, parseErr = RC.NewSniffProtocol(payload, target)
case "UID":
parsed, parseErr = RC.NewUid(payload, target)
case "IN-TYPE":
Expand Down
Loading