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>
919 lines
32 KiB
Go
919 lines
32 KiB
Go
package route
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sagernet/sing-box/adapter"
|
|
"github.com/sagernet/sing-box/common/sniff"
|
|
C "github.com/sagernet/sing-box/constant"
|
|
"github.com/sagernet/sing-box/log"
|
|
R "github.com/sagernet/sing-box/route/rule"
|
|
"github.com/sagernet/sing-mux"
|
|
"github.com/sagernet/sing-tun"
|
|
"github.com/sagernet/sing-vmess"
|
|
"github.com/sagernet/sing/common"
|
|
"github.com/sagernet/sing/common/buf"
|
|
"github.com/sagernet/sing/common/bufio"
|
|
"github.com/sagernet/sing/common/bufio/deadline"
|
|
E "github.com/sagernet/sing/common/exceptions"
|
|
F "github.com/sagernet/sing/common/format"
|
|
M "github.com/sagernet/sing/common/metadata"
|
|
N "github.com/sagernet/sing/common/network"
|
|
"github.com/sagernet/sing/common/uot"
|
|
|
|
"golang.org/x/exp/slices"
|
|
)
|
|
|
|
var defaultPacketSniffers = []sniff.PacketSniffer{
|
|
sniff.DomainNameQuery,
|
|
sniff.QUICClientHello,
|
|
sniff.STUNMessage,
|
|
sniff.UTP,
|
|
sniff.UDPTracker,
|
|
sniff.DTLSRecord,
|
|
sniff.NTP,
|
|
}
|
|
|
|
// Deprecated: use RouteConnectionEx instead.
|
|
func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
|
|
done := make(chan any)
|
|
err := r.routeConnection(ctx, conn, metadata, N.OnceClose(func(it error) {
|
|
close(done)
|
|
}))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-r.ctx.Done():
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
|
err := r.routeConnection(ctx, conn, metadata, onClose)
|
|
if err != nil {
|
|
N.CloseOnHandshakeFailure(conn, onClose, err)
|
|
if E.IsClosedOrCanceled(err) || R.IsRejected(err) {
|
|
r.logger.DebugContext(ctx, "connection closed: ", err)
|
|
} else {
|
|
r.logger.ErrorContext(ctx, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Router) routeConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error {
|
|
//nolint:staticcheck
|
|
if metadata.InboundDetour != "" {
|
|
if metadata.LastInbound == metadata.InboundDetour {
|
|
return E.New("routing loop on detour: ", metadata.InboundDetour)
|
|
}
|
|
detour, loaded := r.inbound.Get(metadata.InboundDetour)
|
|
if !loaded {
|
|
return E.New("inbound detour not found: ", metadata.InboundDetour)
|
|
}
|
|
injectable, isInjectable := detour.(adapter.TCPInjectableInbound)
|
|
if !isInjectable {
|
|
return E.New("inbound detour is not TCP injectable: ", metadata.InboundDetour)
|
|
}
|
|
metadata.LastInbound = metadata.Inbound
|
|
metadata.Inbound = metadata.InboundDetour
|
|
metadata.InboundDetour = ""
|
|
injectable.NewConnection(ctx, conn, metadata, onClose)
|
|
return nil
|
|
}
|
|
metadata.Network = N.NetworkTCP
|
|
switch metadata.Destination.Fqdn {
|
|
case mux.Destination.Fqdn:
|
|
return E.New("global multiplex is deprecated since sing-box v1.7.0, enable multiplex in Inbound fields instead.")
|
|
case vmess.MuxDestination.Fqdn:
|
|
return E.New("global multiplex (v2ray legacy) not supported since sing-box v1.7.0.")
|
|
case uot.MagicAddress:
|
|
return E.New("global UoT not supported since sing-box v1.7.0.")
|
|
case uot.LegacyMagicAddress:
|
|
return E.New("global UoT (legacy) not supported since sing-box v1.7.0.")
|
|
}
|
|
if metadata.InboundType == C.TypeTun && metadata.Protocol == C.ProtocolDNS {
|
|
// lx: SPEC 018 — attribute the DNS query to its process BEFORE hijacking. This
|
|
// fast-path returns before matchRule (where searchProcessInfo normally runs), so
|
|
// without this every TUN-hijacked DNS query reached the resolver with a nil
|
|
// ProcessInfo and the SubscribeDNSQueries stream emitted it unattributed (§180-2).
|
|
// Idempotent + cached (findProcessInfoCached), so the cost is one lookup per flow.
|
|
r.searchProcessInfo(ctx, &metadata)
|
|
N.CloseOnHandshakeFailure(conn, onClose, r.hijackDNSStream(ctx, conn, metadata))
|
|
return nil
|
|
}
|
|
if deadline.NeedAdditionalReadDeadline(conn) {
|
|
conn = deadline.NewConn(conn)
|
|
}
|
|
selectedRule, _, buffers, _, err := r.matchRule(ctx, &metadata, conn, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var selectedOutbound adapter.Outbound
|
|
if selectedRule != nil {
|
|
switch action := selectedRule.Action().(type) {
|
|
case *R.RuleActionRoute:
|
|
var loaded bool
|
|
selectedOutbound, loaded = r.outbound.Outbound(action.Outbound)
|
|
if !loaded {
|
|
buf.ReleaseMulti(buffers)
|
|
return E.New("outbound not found: ", action.Outbound)
|
|
}
|
|
if !common.Contains(selectedOutbound.Network(), N.NetworkTCP) {
|
|
buf.ReleaseMulti(buffers)
|
|
return E.New("TCP is not supported by outbound: ", selectedOutbound.Tag())
|
|
}
|
|
case *R.RuleActionBypass:
|
|
if action.Outbound == "" {
|
|
break
|
|
}
|
|
var loaded bool
|
|
selectedOutbound, loaded = r.outbound.Outbound(action.Outbound)
|
|
if !loaded {
|
|
buf.ReleaseMulti(buffers)
|
|
return E.New("outbound not found: ", action.Outbound)
|
|
}
|
|
if !common.Contains(selectedOutbound.Network(), N.NetworkTCP) {
|
|
buf.ReleaseMulti(buffers)
|
|
return E.New("TCP is not supported by outbound: ", selectedOutbound.Tag())
|
|
}
|
|
case *R.RuleActionReject:
|
|
buf.ReleaseMulti(buffers)
|
|
if action.Method == C.RuleActionRejectMethodReply {
|
|
return E.New("reject method `reply` is not supported for TCP connections")
|
|
}
|
|
return action.Error(ctx)
|
|
case *R.RuleActionHijackDNS:
|
|
for _, buffer := range buffers {
|
|
conn = bufio.NewCachedConn(conn, buffer)
|
|
}
|
|
N.CloseOnHandshakeFailure(conn, onClose, r.hijackDNSStream(ctx, conn, metadata))
|
|
return nil
|
|
}
|
|
}
|
|
if selectedRule == nil {
|
|
defaultOutbound := r.outbound.Default()
|
|
if !common.Contains(defaultOutbound.Network(), N.NetworkTCP) {
|
|
buf.ReleaseMulti(buffers)
|
|
return E.New("TCP is not supported by default outbound: ", defaultOutbound.Tag())
|
|
}
|
|
selectedOutbound = defaultOutbound
|
|
}
|
|
|
|
for _, buffer := range buffers {
|
|
conn = bufio.NewCachedConn(conn, buffer)
|
|
}
|
|
for _, tracker := range r.trackers {
|
|
conn = tracker.RoutedConnection(ctx, conn, metadata, selectedRule, selectedOutbound)
|
|
}
|
|
if outboundHandler, isHandler := selectedOutbound.(adapter.ConnectionHandler); isHandler {
|
|
outboundHandler.NewConnection(ctx, conn, metadata, onClose)
|
|
} else {
|
|
r.connection.NewConnection(ctx, selectedOutbound, conn, metadata, onClose)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error {
|
|
done := make(chan any)
|
|
err := r.routePacketConnection(ctx, conn, metadata, N.OnceClose(func(it error) {
|
|
close(done)
|
|
}))
|
|
if err != nil {
|
|
conn.Close()
|
|
if E.IsClosedOrCanceled(err) || R.IsRejected(err) {
|
|
r.logger.DebugContext(ctx, "connection closed: ", err)
|
|
} else {
|
|
r.logger.ErrorContext(ctx, err)
|
|
}
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-r.ctx.Done():
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
|
|
err := r.routePacketConnection(ctx, conn, metadata, onClose)
|
|
if err != nil {
|
|
N.CloseOnHandshakeFailure(conn, onClose, err)
|
|
if E.IsClosedOrCanceled(err) || R.IsRejected(err) {
|
|
r.logger.DebugContext(ctx, "connection closed: ", err)
|
|
} else {
|
|
r.logger.ErrorContext(ctx, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error {
|
|
//nolint:staticcheck
|
|
if metadata.InboundDetour != "" {
|
|
if metadata.LastInbound == metadata.InboundDetour {
|
|
return E.New("routing loop on detour: ", metadata.InboundDetour)
|
|
}
|
|
detour, loaded := r.inbound.Get(metadata.InboundDetour)
|
|
if !loaded {
|
|
return E.New("inbound detour not found: ", metadata.InboundDetour)
|
|
}
|
|
injectable, isInjectable := detour.(adapter.UDPInjectableInbound)
|
|
if !isInjectable {
|
|
return E.New("inbound detour is not UDP injectable: ", metadata.InboundDetour)
|
|
}
|
|
metadata.LastInbound = metadata.Inbound
|
|
metadata.Inbound = metadata.InboundDetour
|
|
metadata.InboundDetour = ""
|
|
injectable.NewPacketConnection(ctx, conn, metadata, onClose)
|
|
return nil
|
|
}
|
|
// TODO: move to UoT
|
|
metadata.Network = N.NetworkUDP
|
|
|
|
// Currently we don't have deadline usages for UDP connections
|
|
/*if deadline.NeedAdditionalReadDeadline(conn) {
|
|
conn = deadline.NewPacketConn(bufio.NewNetPacketConn(conn))
|
|
}*/
|
|
if metadata.InboundType == C.TypeTun && metadata.Protocol == C.ProtocolDNS {
|
|
// lx: SPEC 018 — attribute before hijack (same reason as the stream path above);
|
|
// UDP DNS is the bulk of DNS on an Android VPN, so this is the main attribution gap.
|
|
r.searchProcessInfo(ctx, &metadata)
|
|
return r.hijackDNSPacket(ctx, conn, nil, metadata, onClose)
|
|
}
|
|
selectedRule, _, _, packetBuffers, err := r.matchRule(ctx, &metadata, nil, conn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var selectedOutbound adapter.Outbound
|
|
var selectReturn bool
|
|
if selectedRule != nil {
|
|
switch action := selectedRule.Action().(type) {
|
|
case *R.RuleActionRoute:
|
|
var loaded bool
|
|
selectedOutbound, loaded = r.outbound.Outbound(action.Outbound)
|
|
if !loaded {
|
|
N.ReleaseMultiPacketBuffer(packetBuffers)
|
|
return E.New("outbound not found: ", action.Outbound)
|
|
}
|
|
if !common.Contains(selectedOutbound.Network(), N.NetworkUDP) {
|
|
N.ReleaseMultiPacketBuffer(packetBuffers)
|
|
return E.New("UDP is not supported by outbound: ", selectedOutbound.Tag())
|
|
}
|
|
case *R.RuleActionBypass:
|
|
if action.Outbound == "" {
|
|
break
|
|
}
|
|
var loaded bool
|
|
selectedOutbound, loaded = r.outbound.Outbound(action.Outbound)
|
|
if !loaded {
|
|
N.ReleaseMultiPacketBuffer(packetBuffers)
|
|
return E.New("outbound not found: ", action.Outbound)
|
|
}
|
|
if !common.Contains(selectedOutbound.Network(), N.NetworkUDP) {
|
|
N.ReleaseMultiPacketBuffer(packetBuffers)
|
|
return E.New("UDP is not supported by outbound: ", selectedOutbound.Tag())
|
|
}
|
|
case *R.RuleActionReject:
|
|
N.ReleaseMultiPacketBuffer(packetBuffers)
|
|
if action.Method == C.RuleActionRejectMethodReply {
|
|
return E.New("reject method `reply` is not supported for UDP connections")
|
|
}
|
|
return action.Error(ctx)
|
|
case *R.RuleActionHijackDNS:
|
|
return r.hijackDNSPacket(ctx, conn, packetBuffers, metadata, onClose)
|
|
}
|
|
}
|
|
if selectedRule == nil || selectReturn {
|
|
defaultOutbound := r.outbound.Default()
|
|
if !common.Contains(defaultOutbound.Network(), N.NetworkUDP) {
|
|
N.ReleaseMultiPacketBuffer(packetBuffers)
|
|
return E.New("UDP is not supported by outbound: ", defaultOutbound.Tag())
|
|
}
|
|
selectedOutbound = defaultOutbound
|
|
}
|
|
for _, buffer := range packetBuffers {
|
|
conn = bufio.NewCachedPacketConn(conn, buffer.Buffer, buffer.Destination)
|
|
N.PutPacketBuffer(buffer)
|
|
}
|
|
for _, tracker := range r.trackers {
|
|
conn = tracker.RoutedPacketConnection(ctx, conn, metadata, selectedRule, selectedOutbound)
|
|
}
|
|
if metadata.FakeIP {
|
|
conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, metadata.Destination)
|
|
}
|
|
if outboundHandler, isHandler := selectedOutbound.(adapter.PacketConnectionHandler); isHandler {
|
|
outboundHandler.NewPacketConnection(ctx, conn, metadata, onClose)
|
|
} else {
|
|
r.connection.NewPacketConnection(ctx, selectedOutbound, conn, metadata, onClose)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Router) PreMatch(metadata adapter.InboundContext, firstPacket []byte) adapter.PreMatchResult {
|
|
ctx := log.ContextWithNewID(r.ctx)
|
|
metadata.PreMatch = true
|
|
continueResult := adapter.PreMatchResult{Action: adapter.PreMatchContinue}
|
|
packetDestination := metadata.Destination
|
|
// lx: pre-match used to prepare only fakeip + IP version, so process/neighbor
|
|
// rule items (process_name, source_mac_address, source_hostname, …) never had
|
|
// their metadata filled here and silently failed to match — they were resolved
|
|
// in matchRule only. Both paths now share prepareMatchMetadata (upstream
|
|
// b911fb078).
|
|
err := r.prepareMatchMetadata(ctx, &metadata)
|
|
if err != nil {
|
|
return continueResult
|
|
}
|
|
for currentRuleIndex, currentRule := range r.rules {
|
|
metadata.ResetRuleCache()
|
|
if !currentRule.Match(&metadata) {
|
|
continue
|
|
}
|
|
ruleDescription := currentRule.String()
|
|
if ruleDescription != "" {
|
|
r.logger.DebugContext(ctx, "pre-match[", currentRuleIndex, "] ", currentRule, " => ", currentRule.Action())
|
|
} else {
|
|
r.logger.DebugContext(ctx, "pre-match[", currentRuleIndex, "] => ", currentRule.Action())
|
|
}
|
|
switch action := currentRule.Action().(type) {
|
|
case *R.RuleActionSniff:
|
|
if metadata.Network == N.NetworkICMP {
|
|
continue
|
|
}
|
|
if metadata.Network != N.NetworkUDP || len(firstPacket) == 0 {
|
|
return continueResult
|
|
}
|
|
if sniff.Skip(&metadata) || metadata.Protocol != "" {
|
|
continue
|
|
}
|
|
if len(action.PacketSniffers) == 0 && len(action.StreamSniffers) > 0 {
|
|
continue
|
|
}
|
|
if slices.Equal(metadata.SnifferNames, action.SnifferNames) && metadata.SniffError != nil {
|
|
continue
|
|
}
|
|
packetSniffers := action.PacketSniffers
|
|
if len(packetSniffers) == 0 {
|
|
packetSniffers = defaultPacketSniffers
|
|
}
|
|
sniffErr := sniff.PeekPacket(ctx, &metadata, firstPacket, packetSniffers...)
|
|
metadata.SnifferNames = action.SnifferNames
|
|
metadata.SniffError = sniffErr
|
|
if sniffErr != nil {
|
|
if errors.Is(sniffErr, sniff.ErrNeedMoreData) {
|
|
return continueResult
|
|
}
|
|
continue
|
|
}
|
|
//goland:noinspection GoDeprecation
|
|
if action.OverrideDestination && 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)
|
|
}
|
|
case *R.RuleActionRouteOptions:
|
|
applyRouteOptionsOverride(&metadata, action)
|
|
case *R.RuleActionRoute:
|
|
applyRouteOptionsOverride(&metadata, &action.RuleActionRouteOptions)
|
|
return r.preMatchFlow(ctx, &metadata, packetDestination, currentRule, action.Outbound)
|
|
case *R.RuleActionBypass:
|
|
applyRouteOptionsOverride(&metadata, &action.RuleActionRouteOptions)
|
|
if action.Outbound == "" {
|
|
if metadata.Destination.IsDomain() || metadata.Destination != packetDestination {
|
|
return continueResult
|
|
}
|
|
return adapter.PreMatchResult{Action: adapter.PreMatchBypass}
|
|
}
|
|
return r.preMatchFlow(ctx, &metadata, packetDestination, currentRule, action.Outbound)
|
|
case *R.RuleActionReject:
|
|
rejectErr := action.Error(r.ctx)
|
|
if errors.Is(rejectErr, R.ErrDrop) {
|
|
return adapter.PreMatchResult{Action: adapter.PreMatchDrop}
|
|
}
|
|
return adapter.PreMatchResult{Action: adapter.PreMatchReject}
|
|
case *R.RuleActionResolve:
|
|
resolveErr := r.actionResolve(adapter.WithContext(ctx, &metadata), &metadata, action)
|
|
if resolveErr != nil {
|
|
r.logger.DebugContext(ctx, "pre-match[", currentRuleIndex, "] ", currentRule, " => ", action, ": ", resolveErr)
|
|
return adapter.PreMatchResult{Action: adapter.PreMatchReject}
|
|
}
|
|
default:
|
|
return continueResult
|
|
}
|
|
}
|
|
return r.preMatchFlow(ctx, &metadata, packetDestination, nil, "")
|
|
}
|
|
|
|
func applyRouteOptionsOverride(metadata *adapter.InboundContext, routeOptions *R.RuleActionRouteOptions) {
|
|
if routeOptions.OverrideAddress.IsValid() {
|
|
metadata.Destination = M.Socksaddr{
|
|
Addr: routeOptions.OverrideAddress.Addr,
|
|
Port: metadata.Destination.Port,
|
|
Fqdn: routeOptions.OverrideAddress.Fqdn,
|
|
}
|
|
}
|
|
if routeOptions.OverridePort > 0 {
|
|
metadata.Destination = M.Socksaddr{
|
|
Addr: metadata.Destination.Addr,
|
|
Port: routeOptions.OverridePort,
|
|
Fqdn: metadata.Destination.Fqdn,
|
|
}
|
|
}
|
|
if routeOptions.UDPTimeout > 0 {
|
|
metadata.UDPTimeout = routeOptions.UDPTimeout
|
|
}
|
|
}
|
|
|
|
func (r *Router) preMatchFlow(ctx context.Context, metadata *adapter.InboundContext, packetDestination M.Socksaddr, matchedRule adapter.Rule, outboundTag string) adapter.PreMatchResult {
|
|
continueResult := adapter.PreMatchResult{Action: adapter.PreMatchContinue}
|
|
var outbound adapter.Outbound
|
|
if outboundTag == "" {
|
|
outbound = r.outbound.Default()
|
|
} else {
|
|
var loaded bool
|
|
outbound, loaded = r.outbound.Outbound(outboundTag)
|
|
if !loaded {
|
|
return continueResult
|
|
}
|
|
}
|
|
for range 8 {
|
|
group, isGroup := outbound.(adapter.OutboundGroup)
|
|
if !isGroup {
|
|
break
|
|
}
|
|
selectedOutbound, selectedLoaded := r.outbound.Outbound(group.Now())
|
|
if !selectedLoaded {
|
|
return continueResult
|
|
}
|
|
outbound = selectedOutbound
|
|
}
|
|
if !common.Contains(outbound.Network(), metadata.Network) {
|
|
return continueResult
|
|
}
|
|
flowOutbound, isFlowOutbound := outbound.(adapter.FlowOutbound)
|
|
if !isFlowOutbound {
|
|
return continueResult
|
|
}
|
|
flowAction := flowOutbound.PreMatchFlow(metadata.Network, metadata.Destination.Addr)
|
|
if flowAction != adapter.PreMatchFlow {
|
|
return adapter.PreMatchResult{Action: flowAction, Outbound: outbound}
|
|
}
|
|
result := adapter.PreMatchResult{Action: adapter.PreMatchFlow, Outbound: outbound}
|
|
if metadata.Network == N.NetworkUDP {
|
|
if metadata.UDPTimeout > 0 {
|
|
result.UDPTimeout = metadata.UDPTimeout
|
|
} else {
|
|
protocol := metadata.Protocol
|
|
if protocol == "" {
|
|
protocol = C.PortProtocols[metadata.Destination.Port]
|
|
}
|
|
if protocol != "" {
|
|
result.UDPTimeout = C.ProtocolTimeouts[protocol]
|
|
}
|
|
}
|
|
}
|
|
if metadata.Destination.IsDomain() {
|
|
if !metadata.FakeIP {
|
|
return continueResult
|
|
}
|
|
var newDestination netip.Addr
|
|
for _, address := range metadata.DestinationAddresses {
|
|
if address.Is4() == packetDestination.IsIPv4() {
|
|
newDestination = address
|
|
break
|
|
}
|
|
}
|
|
if !newDestination.IsValid() {
|
|
if len(metadata.DestinationAddresses) == 0 {
|
|
r.logger.WarnContext(ctx, "pre-match: reject ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to fake destination ", metadata.Destination.Fqdn, ": a resolve action is required before routing to outbound/", outbound.Type(), "[", outbound.Tag(), "]")
|
|
} else {
|
|
r.logger.DebugContext(ctx, "pre-match: reject ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to fake destination ", metadata.Destination.Fqdn, ": no resolved address for this address family")
|
|
}
|
|
return adapter.PreMatchResult{Action: adapter.PreMatchReject}
|
|
}
|
|
flowAction = flowOutbound.PreMatchFlow(metadata.Network, newDestination)
|
|
if flowAction != adapter.PreMatchFlow {
|
|
return adapter.PreMatchResult{Action: flowAction, Outbound: outbound}
|
|
}
|
|
result.Destination = netip.AddrPortFrom(newDestination, metadata.Destination.Port)
|
|
} else if metadata.Destination != packetDestination {
|
|
result.Destination = metadata.Destination.AddrPort()
|
|
}
|
|
r.logger.InfoContext(ctx, "pre-match: forward ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString(), " via outbound/", outbound.Type(), "[", outbound.Tag(), "]")
|
|
metadataCopy := *metadata
|
|
result.NewTracker = func() tun.FlowTracker {
|
|
flowTrackers := make([]tun.FlowTracker, 0, len(r.trackers)+1)
|
|
flowTrackers = append(flowTrackers, newFlowLogger(ctx, r.logger, metadataCopy, outbound))
|
|
for _, tracker := range r.trackers {
|
|
flowTracker := tracker.RoutedFlow(ctx, metadataCopy, matchedRule, outbound)
|
|
if flowTracker != nil {
|
|
flowTrackers = append(flowTrackers, flowTracker)
|
|
}
|
|
}
|
|
if len(flowTrackers) == 1 {
|
|
return flowTrackers[0]
|
|
}
|
|
return multiFlowTracker(flowTrackers)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// prepareMatchMetadata fills in everything a rule may match on but the inbound
|
|
// cannot know: the connection owner, the neighbor (MAC/hostname) behind the
|
|
// source address, the fakeip / reverse-mapped domain and the IP version. Shared
|
|
// by matchRule and PreMatch — see the note at the PreMatch call site.
|
|
func (r *Router) prepareMatchMetadata(ctx context.Context, metadata *adapter.InboundContext) error {
|
|
r.searchProcessInfo(ctx, metadata)
|
|
if r.neighborResolver != nil && metadata.SourceMACAddress == nil && metadata.Source.Addr.IsValid() {
|
|
mac, macFound := r.neighborResolver.LookupMAC(metadata.Source.Addr)
|
|
if macFound {
|
|
metadata.SourceMACAddress = mac
|
|
}
|
|
hostname, hostnameFound := r.neighborResolver.LookupHostname(metadata.Source.Addr)
|
|
if hostnameFound {
|
|
metadata.SourceHostname = hostname
|
|
if macFound {
|
|
r.logger.InfoContext(ctx, "found neighbor: ", mac, ", hostname: ", hostname)
|
|
} else {
|
|
r.logger.InfoContext(ctx, "found neighbor hostname: ", hostname)
|
|
}
|
|
} else if macFound {
|
|
r.logger.InfoContext(ctx, "found neighbor: ", mac)
|
|
}
|
|
}
|
|
if metadata.Destination.Addr.IsValid() && r.dnsTransport.FakeIP() != nil && r.dnsTransport.FakeIP().Store().Contains(metadata.Destination.Addr) {
|
|
domain, loaded := r.dnsTransport.FakeIP().Store().Lookup(metadata.Destination.Addr)
|
|
if !loaded {
|
|
return E.New("missing fakeip record, try enable `experimental.cache_file`")
|
|
}
|
|
if domain != "" {
|
|
metadata.OriginDestination = metadata.Destination
|
|
metadata.Destination = M.Socksaddr{
|
|
Fqdn: domain,
|
|
Port: metadata.Destination.Port,
|
|
}
|
|
metadata.FakeIP = true
|
|
r.logger.DebugContext(ctx, "found fakeip domain: ", domain)
|
|
}
|
|
} else if metadata.Domain == "" {
|
|
domain, loaded := r.dns.LookupReverseMapping(metadata.Destination.Addr)
|
|
if loaded {
|
|
metadata.Domain = domain
|
|
r.logger.DebugContext(ctx, "found reserve mapped domain: ", metadata.Domain)
|
|
}
|
|
}
|
|
if metadata.Destination.IsIPv4() {
|
|
metadata.IPVersion = 4
|
|
} else if metadata.Destination.IsIPv6() {
|
|
metadata.IPVersion = 6
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Router) matchRule(
|
|
ctx context.Context, metadata *adapter.InboundContext,
|
|
inputConn net.Conn, inputPacketConn N.PacketConn,
|
|
) (
|
|
selectedRule adapter.Rule, selectedRuleIndex int,
|
|
buffers []*buf.Buffer, packetBuffers []*N.PacketBuffer, fatalErr error,
|
|
) {
|
|
fatalErr = r.prepareMatchMetadata(ctx, metadata)
|
|
if fatalErr != nil {
|
|
return
|
|
}
|
|
|
|
match:
|
|
for currentRuleIndex, currentRule := range r.rules {
|
|
metadata.ResetRuleCache()
|
|
if !currentRule.Match(metadata) {
|
|
continue
|
|
}
|
|
ruleDescription := currentRule.String()
|
|
if ruleDescription != "" {
|
|
r.logger.DebugContext(ctx, "match[", currentRuleIndex, "] ", currentRule, " => ", currentRule.Action())
|
|
} else {
|
|
r.logger.DebugContext(ctx, "match[", currentRuleIndex, "] => ", currentRule.Action())
|
|
}
|
|
var routeOptions *R.RuleActionRouteOptions
|
|
switch action := currentRule.Action().(type) {
|
|
case *R.RuleActionRoute:
|
|
routeOptions = &action.RuleActionRouteOptions
|
|
case *R.RuleActionRouteOptions:
|
|
routeOptions = action
|
|
case *R.RuleActionBypass:
|
|
if action.Outbound != "" {
|
|
routeOptions = &action.RuleActionRouteOptions
|
|
}
|
|
}
|
|
if routeOptions != nil {
|
|
// TODO: add nat
|
|
if (routeOptions.OverrideAddress.IsValid() || routeOptions.OverridePort > 0) && !metadata.RouteOriginalDestination.IsValid() {
|
|
metadata.RouteOriginalDestination = metadata.Destination
|
|
}
|
|
if routeOptions.OverrideAddress.IsValid() {
|
|
metadata.DestinationAddresses = nil
|
|
}
|
|
applyRouteOptionsOverride(metadata, routeOptions)
|
|
if routeOptions.NetworkStrategy != nil {
|
|
metadata.NetworkStrategy = routeOptions.NetworkStrategy
|
|
}
|
|
if len(routeOptions.NetworkType) > 0 {
|
|
metadata.NetworkType = routeOptions.NetworkType
|
|
}
|
|
if len(routeOptions.FallbackNetworkType) > 0 {
|
|
metadata.FallbackNetworkType = routeOptions.FallbackNetworkType
|
|
}
|
|
if routeOptions.FallbackDelay != 0 {
|
|
metadata.FallbackDelay = routeOptions.FallbackDelay
|
|
}
|
|
if routeOptions.UDPDisableDomainUnmapping {
|
|
metadata.UDPDisableDomainUnmapping = true
|
|
}
|
|
if routeOptions.UDPConnect {
|
|
metadata.UDPConnect = true
|
|
}
|
|
if routeOptions.UDPTimeout > 0 {
|
|
metadata.UDPTimeout = routeOptions.UDPTimeout
|
|
}
|
|
if routeOptions.TLSFragment {
|
|
metadata.TLSFragment = true
|
|
metadata.TLSFragmentFallbackDelay = routeOptions.TLSFragmentFallbackDelay
|
|
}
|
|
if routeOptions.TLSRecordFragment {
|
|
metadata.TLSRecordFragment = true
|
|
}
|
|
if routeOptions.TLSSpoof != "" {
|
|
metadata.TLSSpoof = routeOptions.TLSSpoof
|
|
metadata.TLSSpoofMethod = routeOptions.TLSSpoofMethod
|
|
}
|
|
}
|
|
switch action := currentRule.Action().(type) {
|
|
case *R.RuleActionSniff:
|
|
newBuffer, newPacketBuffers, newErr := r.actionSniff(ctx, metadata, action, inputConn, inputPacketConn, buffers, packetBuffers)
|
|
if newBuffer != nil {
|
|
buffers = append(buffers, newBuffer)
|
|
} else if len(newPacketBuffers) > 0 {
|
|
packetBuffers = append(packetBuffers, newPacketBuffers...)
|
|
}
|
|
if newErr != nil {
|
|
fatalErr = newErr
|
|
return
|
|
}
|
|
case *R.RuleActionResolve:
|
|
fatalErr = r.actionResolve(ctx, metadata, action)
|
|
if fatalErr != nil {
|
|
return
|
|
}
|
|
}
|
|
actionType := currentRule.Action().Type()
|
|
if actionType == C.RuleActionTypeRoute ||
|
|
actionType == C.RuleActionTypeReject ||
|
|
actionType == C.RuleActionTypeHijackDNS {
|
|
selectedRule = currentRule
|
|
selectedRuleIndex = currentRuleIndex
|
|
break match
|
|
}
|
|
if actionType == C.RuleActionTypeBypass {
|
|
bypassAction := currentRule.Action().(*R.RuleActionBypass)
|
|
if bypassAction.Outbound == "" {
|
|
continue match
|
|
}
|
|
selectedRule = currentRule
|
|
selectedRuleIndex = currentRuleIndex
|
|
break match
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (r *Router) actionSniff(
|
|
ctx context.Context, metadata *adapter.InboundContext, action *R.RuleActionSniff,
|
|
inputConn net.Conn, inputPacketConn N.PacketConn, inputBuffers []*buf.Buffer, inputPacketBuffers []*N.PacketBuffer,
|
|
) (buffer *buf.Buffer, packetBuffers []*N.PacketBuffer, fatalErr error) {
|
|
if sniff.Skip(metadata) {
|
|
r.logger.DebugContext(ctx, "sniff skipped due to port considered as server-first")
|
|
return
|
|
} else if metadata.Protocol != "" {
|
|
r.logger.DebugContext(ctx, "duplicate sniff skipped")
|
|
return
|
|
}
|
|
if inputConn != nil {
|
|
if len(action.StreamSniffers) == 0 && len(action.PacketSniffers) > 0 {
|
|
return
|
|
} else if slices.Equal(metadata.SnifferNames, action.SnifferNames) && metadata.SniffError != nil && !errors.Is(metadata.SniffError, sniff.ErrNeedMoreData) {
|
|
r.logger.DebugContext(ctx, "packet sniff skipped due to previous error: ", metadata.SniffError)
|
|
return
|
|
}
|
|
var streamSniffers []sniff.StreamSniffer
|
|
if len(action.StreamSniffers) > 0 {
|
|
streamSniffers = action.StreamSniffers
|
|
} else {
|
|
streamSniffers = []sniff.StreamSniffer{
|
|
sniff.TLSClientHello,
|
|
sniff.HTTPHost,
|
|
sniff.StreamDomainNameQuery,
|
|
sniff.BitTorrent,
|
|
sniff.SSH,
|
|
sniff.RDP,
|
|
}
|
|
}
|
|
sniffBuffer := buf.NewPacket()
|
|
err := sniff.PeekStream(
|
|
ctx,
|
|
metadata,
|
|
inputConn,
|
|
inputBuffers,
|
|
sniffBuffer,
|
|
action.Timeout,
|
|
streamSniffers...,
|
|
)
|
|
metadata.SnifferNames = action.SnifferNames
|
|
metadata.SniffError = err
|
|
if err == nil {
|
|
//goland:noinspection GoDeprecation
|
|
if action.OverrideDestination && 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 protocol: ", metadata.Protocol, ", domain: ", metadata.Domain, ", client: ", metadata.Client)
|
|
} else if metadata.Domain != "" {
|
|
r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.Domain)
|
|
} else {
|
|
r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol)
|
|
}
|
|
}
|
|
if !sniffBuffer.IsEmpty() {
|
|
buffer = sniffBuffer
|
|
} else {
|
|
sniffBuffer.Release()
|
|
}
|
|
} else if inputPacketConn != nil {
|
|
if len(action.PacketSniffers) == 0 && len(action.StreamSniffers) > 0 {
|
|
return
|
|
} else if slices.Equal(metadata.SnifferNames, action.SnifferNames) && metadata.SniffError != nil && !errors.Is(metadata.SniffError, sniff.ErrNeedMoreData) {
|
|
r.logger.DebugContext(ctx, "packet sniff skipped due to previous error: ", metadata.SniffError)
|
|
return
|
|
}
|
|
quicMoreData := func() bool {
|
|
return slices.Equal(metadata.SnifferNames, action.SnifferNames) && errors.Is(metadata.SniffError, sniff.ErrNeedMoreData)
|
|
}
|
|
var packetSniffers []sniff.PacketSniffer
|
|
if len(action.PacketSniffers) > 0 {
|
|
packetSniffers = action.PacketSniffers
|
|
} else {
|
|
packetSniffers = defaultPacketSniffers
|
|
}
|
|
var err error
|
|
for _, packetBuffer := range inputPacketBuffers {
|
|
if quicMoreData() {
|
|
err = sniff.PeekPacket(
|
|
ctx,
|
|
metadata,
|
|
packetBuffer.Buffer.Bytes(),
|
|
sniff.QUICClientHello,
|
|
)
|
|
} else {
|
|
err = sniff.PeekPacket(
|
|
ctx, metadata,
|
|
packetBuffer.Buffer.Bytes(),
|
|
packetSniffers...,
|
|
)
|
|
}
|
|
metadata.SnifferNames = action.SnifferNames
|
|
metadata.SniffError = err
|
|
if errors.Is(err, sniff.ErrNeedMoreData) {
|
|
// TODO: replace with generic message when there are more multi-packet protocols
|
|
r.logger.DebugContext(ctx, "attempt to sniff fragmented QUIC client hello")
|
|
continue
|
|
}
|
|
goto finally
|
|
}
|
|
packetBuffers = inputPacketBuffers
|
|
for {
|
|
var (
|
|
sniffBuffer = buf.NewPacket()
|
|
destination M.Socksaddr
|
|
done = make(chan struct{})
|
|
)
|
|
go func() {
|
|
sniffTimeout := C.ReadPayloadTimeout
|
|
if action.Timeout > 0 {
|
|
sniffTimeout = action.Timeout
|
|
}
|
|
inputPacketConn.SetReadDeadline(time.Now().Add(sniffTimeout))
|
|
destination, err = inputPacketConn.ReadPacket(sniffBuffer)
|
|
inputPacketConn.SetReadDeadline(time.Time{})
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-ctx.Done():
|
|
inputPacketConn.Close()
|
|
fatalErr = ctx.Err()
|
|
return
|
|
}
|
|
if err != nil {
|
|
sniffBuffer.Release()
|
|
if !E.IsTimeout(err) {
|
|
fatalErr = err
|
|
return
|
|
}
|
|
} else {
|
|
if quicMoreData() {
|
|
err = sniff.PeekPacket(
|
|
ctx,
|
|
metadata,
|
|
sniffBuffer.Bytes(),
|
|
sniff.QUICClientHello,
|
|
)
|
|
} else {
|
|
err = sniff.PeekPacket(
|
|
ctx, metadata,
|
|
sniffBuffer.Bytes(),
|
|
packetSniffers...,
|
|
)
|
|
}
|
|
packetBuffer := N.NewPacketBuffer()
|
|
*packetBuffer = N.PacketBuffer{
|
|
Buffer: sniffBuffer,
|
|
Destination: destination,
|
|
}
|
|
packetBuffers = append(packetBuffers, packetBuffer)
|
|
metadata.SnifferNames = action.SnifferNames
|
|
metadata.SniffError = err
|
|
if errors.Is(err, sniff.ErrNeedMoreData) {
|
|
// TODO: replace with generic message when there are more multi-packet protocols
|
|
r.logger.DebugContext(ctx, "attempt to sniff fragmented QUIC client hello")
|
|
continue
|
|
}
|
|
}
|
|
goto finally
|
|
}
|
|
finally:
|
|
if err == nil {
|
|
//goland:noinspection GoDeprecation
|
|
if action.OverrideDestination && 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)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (r *Router) actionResolve(ctx context.Context, metadata *adapter.InboundContext, action *R.RuleActionResolve) error {
|
|
if metadata.Destination.IsDomain() {
|
|
var transport adapter.DNSTransport
|
|
if action.Server != "" {
|
|
var loaded bool
|
|
transport, loaded = r.dnsTransport.Transport(action.Server)
|
|
if !loaded {
|
|
return E.New("DNS server not found: ", action.Server)
|
|
}
|
|
}
|
|
addresses, err := r.dns.Lookup(adapter.WithContext(ctx, metadata), metadata.Destination.Fqdn, adapter.DNSQueryOptions{
|
|
Transport: transport,
|
|
Strategy: action.Strategy,
|
|
DisableCache: action.DisableCache,
|
|
DisableOptimisticCache: action.DisableOptimisticCache,
|
|
RewriteTTL: action.RewriteTTL,
|
|
Timeout: action.Timeout,
|
|
ClientSubnet: action.ClientSubnet,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
metadata.DestinationAddresses = addresses
|
|
r.logger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.DestinationAddresses), " "), "]")
|
|
}
|
|
return nil
|
|
}
|