Files
shater/route/prematch_metadata_lx_test.go
T
omarandClaude Opus 5 0a6689b29e fix(quic,v2ray): close the sockets quic-go was never going to close
DialEarly with a packet conn the caller made sets a flag that means quic-go does
not own it: closing the transport only stops reading from the socket. Neither DNS
transport closed it. On the QUIC one it was closed on a failed handshake and
never on success, so every redial — idle timeout, retry error, engine reload —
left a UDP socket for the life of the process. On the HTTP/3 one the library
drives its own reconnects, so the leak compounds without anything in our code
looking wrong.

That is the same shape as v2rayquic's, where offerNew overwrote the raw conn on
every reconnect without closing the previous one. Both are now owned by a watcher
tied to the connection's own context, so the socket lives exactly as long as the
connection does.

This matters more than it did last week: the shipped resolvers are DoH, and DNS
is intercepted by default now, so the whole network's query stream rides this
path on a router with 512 MB.

The same upstream commit fixes both halves. We had taken the v2ray half and not
the DNS one — the third time this session a paired fix arrived half-applied, and
the first of those cost a day of debugging. These two files are now byte-identical
to upstream so a rebase cannot reopen it.

Also from that family: websocket and httpupgrade leaked their conn on failed
handshakes, and a QUIC stream's Close did not release a blocked write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:59 +03:00

195 lines
6.3 KiB
Go

package route
import (
"context"
"net"
"net/netip"
"testing"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
R "github.com/sagernet/sing-box/route/rule"
"github.com/sagernet/sing/common/json/badoption"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/stretchr/testify/require"
)
// The pre-match path (adapter.JudgeFlow -> Router.PreMatch, used by the TUN/
// WireGuard-endpoint flow dispatcher) used to prepare only fakeip and the IP
// version. Everything a rule matches on that the inbound cannot know — the
// connection owner and the neighbor behind the source address — was resolved in
// matchRule only, so a `source_mac_address` / `source_hostname` rule silently
// failed to match in pre-match and the flow fell through to the default
// outbound. Upstream b911fb078 shares one prepareMatchMetadata between both
// paths; these tests pin that.
// stubNeighborResolver answers for exactly one address.
type stubNeighborResolver struct {
address netip.Addr
mac net.HardwareAddr
hostname string
}
func (r *stubNeighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) {
if address != r.address || r.mac == nil {
return nil, false
}
return r.mac, true
}
func (r *stubNeighborResolver) LookupHostname(address netip.Addr) (string, bool) {
if address != r.address || r.hostname == "" {
return "", false
}
return r.hostname, true
}
func (r *stubNeighborResolver) LookupAddresses(hostname string) []netip.Addr {
if hostname != r.hostname {
return nil
}
return []netip.Addr{r.address}
}
func (r *stubNeighborResolver) Start() error { return nil }
func (r *stubNeighborResolver) Close() error { return nil }
// stubDNSRouter / stubDNSTransportManager implement only what
// prepareMatchMetadata reaches; every other method is left to the embedded nil
// interface and would panic if it were ever called.
type stubDNSRouter struct {
adapter.DNSRouter
}
func (s *stubDNSRouter) LookupReverseMapping(netip.Addr) (string, bool) { return "", false }
type stubDNSTransportManager struct {
adapter.DNSTransportManager
}
func (s *stubDNSTransportManager) FakeIP() adapter.FakeIPTransport { return nil }
// stubOutboundManager's default outbound supports no network at all, so a flow
// that reaches preMatchFlow bails out with PreMatchContinue instead of nil-
// dereferencing. That is exactly the pre-fix verdict we assert against.
type stubOutboundManager struct {
adapter.OutboundManager
defaultOutbound adapter.Outbound
}
func (s *stubOutboundManager) Default() adapter.Outbound { return s.defaultOutbound }
type stubNoNetworkOutbound struct {
adapter.Outbound
}
func (o *stubNoNetworkOutbound) Tag() string { return "stub" }
func (o *stubNoNetworkOutbound) Type() string { return "direct" }
func (o *stubNoNetworkOutbound) Network() []string { return nil }
func newPreMatchTestRouter(t *testing.T, resolver adapter.NeighborResolver, rules ...option.Rule) *Router {
t.Helper()
logger := log.NewNOPFactory().NewLogger("test")
router := &Router{
ctx: context.Background(),
logger: logger,
dns: &stubDNSRouter{},
dnsTransport: &stubDNSTransportManager{},
outbound: &stubOutboundManager{defaultOutbound: &stubNoNetworkOutbound{}},
neighborResolver: resolver,
needFindNeighbor: true,
}
for i, ruleOptions := range rules {
rule, err := R.NewRule(router.ctx, logger, ruleOptions, false)
require.NoError(t, err, "build rule[%d]", i)
router.rules = append(router.rules, rule)
}
return router
}
func rejectOnSourceMAC(macAddress string) option.Rule {
return option.Rule{
Type: C.RuleTypeDefault,
DefaultOptions: option.DefaultRule{
RawDefaultRule: option.RawDefaultRule{
SourceMACAddress: badoption.Listable[string]{macAddress},
},
RuleAction: option.RuleAction{
Action: C.RuleActionTypeReject,
RejectOptions: option.RejectActionOptions{Method: C.RuleActionRejectMethodDefault},
},
},
}
}
func rejectOnSourceHostname(hostname string) option.Rule {
return option.Rule{
Type: C.RuleTypeDefault,
DefaultOptions: option.DefaultRule{
RawDefaultRule: option.RawDefaultRule{
SourceHostname: badoption.Listable[string]{hostname},
},
RuleAction: option.RuleAction{
Action: C.RuleActionTypeReject,
RejectOptions: option.RejectActionOptions{Method: C.RuleActionRejectMethodDefault},
},
},
}
}
func preMatchMetadata() adapter.InboundContext {
return adapter.InboundContext{
Inbound: "tun-in",
InboundType: C.TypeTun,
Network: N.NetworkUDP,
Source: M.ParseSocksaddr("192.168.1.5:41234"),
Destination: M.ParseSocksaddr("1.1.1.1:443"),
}
}
func TestPreMatchResolvesNeighborMAC(t *testing.T) {
t.Parallel()
mac, err := net.ParseMAC("de:ad:be:ef:00:01")
require.NoError(t, err)
resolver := &stubNeighborResolver{
address: netip.MustParseAddr("192.168.1.5"),
mac: mac,
hostname: "kitchen-tv",
}
router := newPreMatchTestRouter(t, resolver, rejectOnSourceMAC("de:ad:be:ef:00:01"))
result := router.PreMatch(preMatchMetadata(), nil)
require.Equal(t, adapter.PreMatchReject, result.Action,
"source_mac_address rule must match in pre-match; the MAC has to be resolved there too")
}
func TestPreMatchResolvesNeighborHostname(t *testing.T) {
t.Parallel()
resolver := &stubNeighborResolver{
address: netip.MustParseAddr("192.168.1.5"),
hostname: "kitchen-tv",
}
router := newPreMatchTestRouter(t, resolver, rejectOnSourceHostname("kitchen-tv"))
result := router.PreMatch(preMatchMetadata(), nil)
require.Equal(t, adapter.PreMatchReject, result.Action,
"source_hostname rule must match in pre-match; the hostname has to be resolved there too")
}
// A source the neighbor resolver does not know must still fall through, not
// match on a half-filled metadata.
func TestPreMatchNeighborMissDoesNotMatch(t *testing.T) {
t.Parallel()
mac, err := net.ParseMAC("de:ad:be:ef:00:01")
require.NoError(t, err)
resolver := &stubNeighborResolver{
address: netip.MustParseAddr("192.168.1.9"),
mac: mac,
}
router := newPreMatchTestRouter(t, resolver, rejectOnSourceMAC("de:ad:be:ef:00:01"))
result := router.PreMatch(preMatchMetadata(), nil)
require.Equal(t, adapter.PreMatchContinue, result.Action)
}