ICMP (ping) does not work through embedded Tailscale endpoint
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 38.1k
- Forks
- 4.6k
- Avg merge
- 19d 15h
- Merged PRs (30d)
- 1
Description
ICMP (ping) does not work through embedded Tailscale endpoint
Description
ICMP echo requests routed through the Tailscale endpoint never receive replies. TCP and UDP work correctly to the same destinations. This affects both the default gVisor path and system_interface: true mode, but for different reasons.
Environment
- sing-box 1.13.3 (also reproduced on 1.12.19)
- macOS 15 (Darwin 24.6.0, arm64)
- Build tags:
with_gvisor,with_tailscale - TUN inbound with
stack: "gvisor",auto_route: true - Tailscale endpoint with
accept_routes: true - Tailscale ACLs allow all traffic between nodes
Reproduction
{
"endpoints": [{
"type": "tailscale",
"tag": "tailscale",
"auth_key": "tskey-auth-...",
"accept_routes": true
}],
"inbounds": [{
"type": "tun",
"tag": "tun-in",
"address": ["172.19.0.1/30"],
"auto_route": true,
"stack": "gvisor"
}],
"outbounds": [
{ "type": "direct", "tag": "direct" }
],
"route": {
"final": "direct",
"rules": [
{ "action": "sniff" },
{ "protocol": "dns", "action": "hijack-dns" },
{ "ip_cidr": ["10.0.0.0/8"], "outbound": "tailscale" }
]
}
}
# TCP works
$ nc -zw 3 10.x.x.x 22 → success
$ ssh 10.x.x.x hostname → host-1
# ICMP does not work
$ ping -c 3 10.x.x.x → Request timeout (or fake 0.2ms reply from gVisor)
Log output:
WARN inbound/tun[tun-in]: link icmp connection from 172.19.0.1 to 10.x.x.x: operation not permitted
Root Cause Analysis
I traced the ICMP packet path through sing-box, sing-tun, and sagernet/tailscale source code. There are two separate bugs depending on whether system_interface is enabled.
Bug 1: Missing RawFactory in Tailscale's gVisor stack (default mode)
The ICMP path is:
ICMPForwarder.HandlePacket(sing-tun/stack_gvisor_icmp.go:53)- →
PrepareConnection(NetworkICMP)(sing-box/protocol/tailscale/endpoint.go:617) - →
NewDirectRouteConnection(endpoint.go:710) - →
ping.ConnectGVisor()(sing-tun/ping/destination_gvisor.go:34) - →
stack.NewRawEndpoint(ICMPv4ProtocolNumber, ...)(destination_gvisor.go:53)
NewRawEndpoint checks s.rawFactory (gvisor stack/stack.go:839). Tailscale's tsnet server creates its stack at sagernet/tailscale/wgengine/netstack/netstack.go:336:
ipstack := stack.New(stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol4, icmp.NewProtocol6},
// RawFactory is NOT set
})
Since rawFactory is nil, NewRawEndpoint returns ErrNotPermitted → syscall.EPERM → "operation not permitted".
For comparison, the WireGuard endpoint correctly enables raw endpoints by passing allowRawEndpoint: true to NewGVisorStackWithOptions (sing-tun/stack_gvisor.go:150-151), which sets RawFactory = new(raw.EndpointFactory).
Fix: Set RawFactory when creating the Tailscale tsnet gVisor stack, or expose an option to enable it. Note: sagernet/tailscale is a fork maintained within this project, so the fix is not blocked by upstream Tailscale.
Bug 1 affects all platforms (the gVisor stack path is platform-independent).
Bug 2: Reply interception with system_interface: true
With system_interface: true, Tailscale creates a kernel TUN (e.g., utun9) and adds specific routes via userspaceBSDRouter.Set():
route add -inet 10.0.0.0/8 -iface utun9 # advertised subnet route
route add -inet 100.x.y.z/32 -iface utun9 # direct peer route
These specific routes take priority over sing-box's default route (utun10). When ping 10.x.x.x is run:
- Kernel sends ICMP directly to utun9 (bypasses sing-box TUN)
- WireGuard encrypts and sends to remote peer ✓
- Reply comes back through WireGuard
tstun.Wrapperprocesses inbound:shouldProcessInboundreturnstrue(becauseProcessLocalIPs=trueandProcessSubnets=true, set attsnet.go:641-642)- Reply is injected into gVisor netstack via
injectInboundand never written back to the kernel TUN pingcommand times out
If the ICMP packet went through sing-box's TUN (utun10) instead, the ConnectDestination() path would create a real kernel ICMP socket bound to utun9 — this path works correctly. But Tailscale's specific routes steal the traffic.
Fix: Either configure shouldProcessInbound to bypass ICMP echo replies destined for local IPs, or ensure sing-box's auto_route adds more-specific routes that win over Tailscale's.
Bug 2 is macOS-specific (userspaceBSDRouter). Linux may behave differently with its routing implementation.
Minor: Inverted IPv4/IPv6 in DialerForICMPDestination
In sing-box/common/dialer/default.go:330-336:
func (d *DefaultDialer) DialerForICMPDestination(destination netip.Addr) net.Dialer {
if !destination.Is6() {
return d.dialer6.Dialer // returns IPv6 dialer for IPv4
} else {
return d.dialer4.Dialer // returns IPv4 dialer for IPv6
}
}
No practical impact since both dialers share the same Control function, but the logic is inverted.
Expected Behavior
ping to Tailscale-routed destinations should receive real ICMP echo replies, matching TCP/UDP connectivity.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with sing-tun/stack_gvisor_icmp.go, sing-box/protocol/tailscale/endpoint.go, and sing-tun/ping/destination_gvisor.go to trace the default gVisor ICMP path, then inspect the tsnet stack construction in sagernet/tailscale. For system_interface mode, read tstun.Wrapper, shouldProcessInbound, and the macOS userspaceBSDRouter path. Done means ICMP echo replies work through both modes while TCP and UDP behavior remains unchanged; also review the IPv4/IPv6 selection in sing-box/common/dialer/default.go.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100