feat: DirectRoute support for UDP/TCP traceroute

Add UDP and TCP DirectRoute handling to enable mtr --udp and mtr --tcp
through WireGuard and other tunnels.

- Add UDP DirectRoute with ICMPForwarder integration in direct outbound
- Add TCP DirectRoute for traceroute in route matching
- Pass ICMPForwarder to UDP/TCP forwarders in WireGuard stack
- Fix safe type assertion for DirectRouteOutbound in PreMatch
- Intercept ICMP errors in stackDevice before gVisor delivery
- Use import aliases for sing-tun packages
This commit is contained in:
Sheldon Qi 2026-05-04 12:29:22 +08:00
parent 246d6a5f8f
commit 793b81c190
No known key found for this signature in database
6 changed files with 87 additions and 26 deletions

View file

@ -13,7 +13,7 @@ import (
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-tun"
tun "github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/ping"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
@ -110,7 +110,19 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
func (h *Outbound) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
ctx := log.ContextWithNewID(h.ctx)
destination, err := ping.ConnectDestination(ctx, h.logger, common.MustCast[*dialer.DefaultDialer](h.dialer).DialerForICMPDestination(metadata.Destination.Addr).Control, metadata.Destination.Addr, routeContext, timeout)
controlFunc := common.MustCast[*dialer.DefaultDialer](h.dialer).DialerForICMPDestination(metadata.Destination.Addr).Control
var (
destination tun.DirectRouteDestination
err error
)
switch metadata.Network {
case N.NetworkUDP:
destination, err = ping.ConnectUDPDestination(ctx, h.logger, controlFunc, metadata.Destination.Addr, routeContext, timeout)
case N.NetworkTCP:
destination, err = ping.ConnectTCPDestination(ctx, h.logger, controlFunc, metadata.Destination.Addr, routeContext, timeout)
default:
destination, err = ping.ConnectDestination(ctx, h.logger, controlFunc, metadata.Destination.Addr, routeContext, timeout)
}
if err != nil {
return nil, err
}

View file

@ -181,7 +181,11 @@ func (s *Selector) NewDirectRouteConnection(metadata adapter.InboundContext, rou
if !common.Contains(selected.Network(), metadata.Network) {
return nil, E.New(metadata.Network, " is not supported by outbound: ", selected.Tag())
}
return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout)
directRoute, ok := selected.(adapter.DirectRouteOutbound)
if !ok {
return nil, nil
}
return directRoute.NewDirectRouteConnection(metadata, routeContext, timeout)
}
func RealTag(detour adapter.Outbound) string {

View file

@ -183,7 +183,11 @@ func (s *URLTest) NewDirectRouteConnection(metadata adapter.InboundContext, rout
if !common.Contains(selected.Network(), metadata.Network) {
return nil, E.New(metadata.Network, " is not supported by outbound: ", selected.Tag())
}
return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout)
directRoute, ok := selected.(adapter.DirectRouteOutbound)
if !ok {
return nil, nil
}
return directRoute.NewDirectRouteConnection(metadata, routeContext, timeout)
}
type URLTestGroup struct {

View file

@ -12,10 +12,10 @@ import (
"github.com/sagernet/sing-box/common/sniff"
C "github.com/sagernet/sing-box/constant"
R "github.com/sagernet/sing-box/route/rule"
"github.com/sagernet/sing-mux"
"github.com/sagernet/sing-tun"
mux "github.com/sagernet/sing-mux"
tun "github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/ping"
"github.com/sagernet/sing-vmess"
vmess "github.com/sagernet/sing-vmess"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/buf"
"github.com/sagernet/sing/common/bufio"
@ -329,7 +329,11 @@ func (r *Router) PreMatch(metadata adapter.InboundContext, routeContext tun.Dire
if !common.Contains(outbound.Network(), metadata.Network) {
return nil, E.New(metadata.Network, " is not supported by outbound: ", action.Outbound)
}
directRouteOutbound = outbound.(adapter.DirectRouteOutbound)
var ok bool
directRouteOutbound, ok = outbound.(adapter.DirectRouteOutbound)
if !ok {
return nil, nil
}
case *R.RuleActionRoute:
if routeContext == nil {
return nil, nil
@ -341,18 +345,26 @@ func (r *Router) PreMatch(metadata adapter.InboundContext, routeContext tun.Dire
if !common.Contains(outbound.Network(), metadata.Network) {
return nil, E.New(metadata.Network, " is not supported by outbound: ", action.Outbound)
}
directRouteOutbound = outbound.(adapter.DirectRouteOutbound)
var ok bool
directRouteOutbound, ok = outbound.(adapter.DirectRouteOutbound)
if !ok {
return nil, nil
}
}
}
if directRouteOutbound == nil {
if selectedRule != nil || metadata.Network != N.NetworkICMP {
if selectedRule != nil || (metadata.Network != N.NetworkICMP && metadata.Network != N.NetworkUDP && metadata.Network != N.NetworkTCP) {
return nil, nil
}
defaultOutbound := r.outbound.Default()
if !common.Contains(defaultOutbound.Network(), metadata.Network) {
return nil, E.New(metadata.Network, " is not supported by default outbound: ", defaultOutbound.Tag())
}
directRouteOutbound = defaultOutbound.(adapter.DirectRouteOutbound)
var ok bool
directRouteOutbound, ok = defaultOutbound.(adapter.DirectRouteOutbound)
if !ok {
return nil, nil
}
}
if metadata.Destination.IsDomain() {
if len(metadata.DestinationAddresses) == 0 {

View file

@ -7,7 +7,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-tun"
tun "github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/ping"
"github.com/sagernet/sing/common/buf"
"github.com/sagernet/sing/common/logger"

View file

@ -7,6 +7,7 @@ import (
"net"
"net/netip"
"os"
"sync/atomic"
"time"
"github.com/sagernet/gvisor/pkg/buffer"
@ -21,7 +22,7 @@ import (
"github.com/sagernet/gvisor/pkg/tcpip/transport/udp"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-tun"
tun "github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/ping"
"github.com/sagernet/sing/common/buf"
E "github.com/sagernet/sing/common/exceptions"
@ -45,6 +46,7 @@ type stackDevice struct {
dispatcher stack.NetworkDispatcher
inet4Address netip.Addr
inet6Address netip.Addr
rewriter *ping.SourceRewriter
}
func newStackDevice(options DeviceOptions) (*stackDevice, error) {
@ -88,11 +90,15 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) {
}
}
tunDevice.stack = ipStack
tunDevice.rewriter = ping.NewSourceRewriter(options.Context, options.Logger, inet4Address, inet6Address)
if options.Handler != nil {
ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket)
ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket)
icmpForwarder := tun.NewICMPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout)
icmpForwarder.SetLocalAddresses(inet4Address, inet6Address)
icmpForwarder.SetTTLDecrement(inet4Address, inet6Address, 0)
tcpHandler := tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket
udpHandler := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket
ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.WrapTCPHandlerWithDirectRoute(ipStack, options.Handler, icmpForwarder, options.UDPTimeout, 0, inet4Address, inet6Address, tcpHandler))
ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.WrapUDPHandlerWithDirectRoute(ipStack, options.Handler, icmpForwarder, options.UDPTimeout, 0, inet4Address, inet6Address, udpHandler))
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket)
}
@ -210,6 +216,11 @@ func (w *stackDevice) Write(bufs [][]byte, offset int) (count int, err error) {
if len(b) == 0 {
continue
}
handled, _ := w.rewriter.WriteBack(b)
if handled {
count++
continue
}
var networkProtocol tcpip.NetworkProtocolNumber
switch header.IPVersion(b) {
case header.IPv4Version:
@ -260,19 +271,37 @@ func (w *stackDevice) BatchSize() int {
func (w *stackDevice) CreateDestination(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
ctx := log.ContextWithNewID(w.ctx)
destination, err := ping.ConnectGVisor(
ctx, w.logger,
metadata.Source.Addr, metadata.Destination.Addr,
routeContext,
w.stack,
w.inet4Address, w.inet6Address,
timeout,
)
if err != nil {
return nil, err
session := tun.DirectRouteSession{
Source: metadata.Source.Addr,
Destination: metadata.Destination.Addr,
}
w.rewriter.CreateSession(session, routeContext)
w.logger.InfoContext(ctx, "linked ", metadata.Network, " connection from ", metadata.Source.AddrString(), " to ", metadata.Destination.AddrString())
return destination, nil
return &stackNatDestination{device: w, session: session}, nil
}
var _ tun.DirectRouteDestination = (*stackNatDestination)(nil)
type stackNatDestination struct {
device *stackDevice
session tun.DirectRouteSession
closed atomic.Bool
}
func (d *stackNatDestination) WritePacket(buffer *buf.Buffer) error {
d.device.rewriter.RewritePacket(buffer.Bytes())
d.device.packetOutbound <- buffer
return nil
}
func (d *stackNatDestination) Close() error {
d.closed.Store(true)
d.device.rewriter.DeleteSession(d.session)
return nil
}
func (d *stackNatDestination) IsClosed() bool {
return d.closed.Load()
}
var _ stack.LinkEndpoint = (*wireEndpoint)(nil)