Split tunnel: UDP proxy breaks games, STUN, P2P
Pre-Submit Checklist:
- Check applicable sources for existing issues:
- Windows Known Issues
- Linux Known Issues
- Github Issues
- #2206 sounds similar but describes total connectivity loss. Here split tunneling works, and specific defects in the UDP session model break multi-peer traffic.
What happened:
With split tunneling enabled, an application using one UDP socket to talk to several remote hosts (every multiplayer game, STUN/ICE, QUIC) can only reach the first host that socket contacted, and even that host's replies arrive from the wrong source address. TCP is unaffected. This came out of a user report of broken game matchmaking (NixOS/nixpkgs#553152). I reproduced the problem locally.
Cause, from the code on development (present since v2.2.1):
service/splittun/proxy/udp_proxy.gokeys its session table onclientAddr.String()alone, and each session has a single upstream socket connected to the socket's first destination. Later datagrams take the fast path and are sent there, at that host's ip and port, regardless of their real destination.- The original destination comes from
pendingRequests(service/splittun/requests.go), also keyed by source ip:port and consumed once. Requests for further destinations leak until the 30 s TTL, a concurrent registration fails the connection, and a stale entry can be consumed by the wrong flow. - After a session dies (ICMP unreachable, or 5 min idle per
DEFAULT_READ_TIMEOUT), the permanentMarkRerouteSplitTunconnmark keeps steering packets to the proxy with no userspace re-decision, so they are dropped withdecider rejected connection ... "no pending request for <src>". The socket is black-holed. - One connected upstream socket per destination means a different visible source port per peer, i.e. symmetric NAT. Hole punching cannot work, and unsolicited peer packets show up as blocked "Network Noise". Allowing inbound doesn't help because the kernel discards datagrams arriving on a socket connected to another peer.
- Relayed replies reach the client from the proxy's listen endpoint (
<host-ip>:719,SplitTunPort) instead of appearing to come from the original destination. Applications that validate the sender, which includes every game networking stack and anything using connected sockets, discard even the replies that do make it back.
From the reporter's ten-minute debug capture (2.2.1, Linux/nfqueue, split tunnel correctly bound to the physical interface): 434 distinct client UDP sockets, each with exactly one session ever. 355 sessions evicted idle, 6 killed by ICMP refusal, then "no pending request" drops on the dead flows. One example socket sent 1300 bytes (an entire relay ping sweep) into a single closed port on its first relay before being black-holed. All TCP sessions worked. The proxy code is shared across platforms, so the model issue is not Linux specific.
What did you expect to happen?:
Each datagram reaches its actual destination, replies appear to come from the host the application sent to, and a client socket keeps one stable mapped source port on the outgoing interface. A possible direction: recover the original destination per datagram (TPROXY plus IP_RECVORIGDSTADDR on Linux, the Windows callout driver already knows the tuple), key sessions on (client, original destination), and use one unconnected upstream socket per client socket with sendto() per datagram, demultiplexing replies by peer address.
How did you reproduce it?:
Enable split tunneling for python3, then send STUN requests from one socket to two servers:
import os, socket
def stun(sock, server):
req = b"\x00\x01\x00\x00\x21\x12\xa4\x42" + os.urandom(12) # RFC 5389 Binding Request
sock.sendto(req, server)
try:
print("reply from", sock.recvfrom(1500)[1])
except socket.timeout:
print("TIMEOUT from", server)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(5)
stun(s, (socket.gethostbyname("stun.l.google.com"), 19302))
stun(s, (socket.gethostbyname("stun.cloudflare.com"), 3478))With split tunneling enabled:
reply from ('<host-lan-ip>', 719)
TIMEOUT from ('162.159.207.0', 3478)The second destination is unreachable, and the first reply arrives from the proxy's own listen port instead of the STUN server. With split tunneling disabled, both succeed and the replies come from the actual servers:
reply from ('74.125.250.129', 19302)
reply from ('162.159.207.0', 3478)Debug Information:
Reproduced locally on Portmaster 2.2.1, Linux (NixOS package, nfqueue interception). Code paths unchanged on current development.
Source: safing/portmaster