#2240·portmaster

Split tunnel: UDP proxy breaks games, STUN, P2P

Author: WitteShadovvCreated Aug 24, 2026Updated Sep 14, 2026
Labelsbug

Pre-Submit Checklist:

  • Check applicable sources for existing issues:

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):

  1. service/splittun/proxy/udp_proxy.go keys its session table on clientAddr.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.
  2. 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.
  3. After a session dies (ICMP unreachable, or 5 min idle per DEFAULT_READ_TIMEOUT), the permanent MarkRerouteSplitTun connmark keeps steering packets to the proxy with no userspace re-decision, so they are dropped with decider rejected connection ... "no pending request for <src>". The socket is black-holed.
  4. 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.
  5. 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:

python
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.