Context
The mobile app wants .dmsg/.skynet names to resolve in a browser while the VPN client is active — the same capability the Linux vpn-router already ships via pkg/vpnrouter/meshgw. On Android (VpnService) / iOS (NEPacketTunnelProvider) the app reads the TUN itself, so there is no iptables REDIRECT and no kernel *net.TCPConn, and SO_ORIGINAL_DST does not apply.
What already works portably (no change needed)
meshgw splits cleanly; the transport-agnostic half is directly reusable in-process on mobile:
New(dial, cidr, aliases, log) — synthetic-IP pool + byIP: synthetic-IP → {scheme, dest PK} reverse map.
InstallDNS(mux *dns.ServeMux) — dmsg./skynet. zone handlers on a miekg/dns mux; resolve() leases from the pool. Pure Go.
EnableTLSMITM(...), PoolCIDR(), and the whole dial + splice + TLS-MITM path.
One Gateway instance backs both the DNS resolver and the packet path (so resolve() writes the byIP entry the packet path reads) — already the design.
The one Linux coupling
ServeTransparent → handleConn (pkg/vpnrouter/meshgw/meshgw.go) hard-codes destination recovery via originalDst() (SO_ORIGINAL_DST) and requires conn.(*net.TCPConn). That is the only thing blocking reuse from a userspace-TUN path; skipping origdst is correct on mobile (the original dst IP+port are in the packet), but the dial+splice+MITM logic is currently private behind that origdst-coupled entry point.
Proposed change
Extract a destination-explicit entry point:
// dstIP/dstPort come from SO_ORIGINAL_DST on Linux, or from the TUN packet on mobile.
func (g *Gateway) HandleMeshConn(ctx context.Context, client net.Conn, dstIP net.IP, dstPort uint16) error
- Linux
handleConn becomes origIP, origPort := originalDst(tc); g.HandleMeshConn(ctx, conn, origIP, origPort) — byte-for-byte unchanged behaviour; redirect_netns_linux_test.go stays green.
- Mobile calls
HandleMeshConn directly with the dst it read from the TUN; client is the netstack net.Conn.
lookup stays internal.
Small, non-breaking refactor that unblocks Android/iOS while keeping the Linux path identical.
Out of meshgw scope — the mobile app must supply around it
- A userspace TCP/IP stack (gVisor
netstack / wireguard-go tun+netstack) to turn TUN packets into net.Conn for HandleMeshConn.
VpnService.Builder.addRoute(PoolCIDR()) so synthetic-IP packets reach the fd.
- A default DNS handler that forwards non-
.dmsg/.skynet queries upstream (InstallDNS only handles the two zones).
- TLS-MITM CA trust:
https://…dmsg requires the minted leaf CA to be user-trusted on the device (Android/iOS can’t install a system CA silently). Plaintext http://…dmsg needs none.
- iOS parity via
NEPacketTunnelProvider (same refactor applies).
Alternative worth weighing
tun2socks → in-process pkg/dmsgweb SOCKS5 resolver. dmsgweb already does .dmsg resolution + browser TLS-MITM; feeding the TUN into it with an off-the-shelf tun2socks bridge may be less code than embedding a netstack + refactoring meshgw, and reuses the exact proxy a real browser already works against (CA-trust caveat still applies).
Context
The mobile app wants
.dmsg/.skynetnames to resolve in a browser while the VPN client is active — the same capability the Linux vpn-router already ships viapkg/vpnrouter/meshgw. On Android (VpnService) / iOS (NEPacketTunnelProvider) the app reads the TUN itself, so there is no iptables REDIRECT and no kernel*net.TCPConn, andSO_ORIGINAL_DSTdoes not apply.What already works portably (no change needed)
meshgw splits cleanly; the transport-agnostic half is directly reusable in-process on mobile:
New(dial, cidr, aliases, log)— synthetic-IP pool +byIP: synthetic-IP → {scheme, dest PK}reverse map.InstallDNS(mux *dns.ServeMux)—dmsg./skynet.zone handlers on a miekg/dns mux;resolve()leases from the pool. Pure Go.EnableTLSMITM(...),PoolCIDR(), and the whole dial + splice + TLS-MITM path.One
Gatewayinstance backs both the DNS resolver and the packet path (soresolve()writes thebyIPentry the packet path reads) — already the design.The one Linux coupling
ServeTransparent→handleConn(pkg/vpnrouter/meshgw/meshgw.go) hard-codes destination recovery viaoriginalDst()(SO_ORIGINAL_DST) and requiresconn.(*net.TCPConn). That is the only thing blocking reuse from a userspace-TUN path; skipping origdst is correct on mobile (the original dst IP+port are in the packet), but the dial+splice+MITM logic is currently private behind that origdst-coupled entry point.Proposed change
Extract a destination-explicit entry point:
handleConnbecomesorigIP, origPort := originalDst(tc); g.HandleMeshConn(ctx, conn, origIP, origPort)— byte-for-byte unchanged behaviour;redirect_netns_linux_test.gostays green.HandleMeshConndirectly with the dst it read from the TUN;clientis the netstacknet.Conn.lookupstays internal.Small, non-breaking refactor that unblocks Android/iOS while keeping the Linux path identical.
Out of meshgw scope — the mobile app must supply around it
netstack/ wireguard-gotun+netstack) to turn TUN packets intonet.ConnforHandleMeshConn.VpnService.Builder.addRoute(PoolCIDR())so synthetic-IP packets reach the fd..dmsg/.skynetqueries upstream (InstallDNSonly handles the two zones).https://…dmsgrequires the minted leaf CA to be user-trusted on the device (Android/iOS can’t install a system CA silently). Plaintexthttp://…dmsgneeds none.NEPacketTunnelProvider(same refactor applies).Alternative worth weighing
tun2socks → in-process
pkg/dmsgwebSOCKS5 resolver. dmsgweb already does.dmsgresolution + browser TLS-MITM; feeding the TUN into it with an off-the-shelf tun2socks bridge may be less code than embedding a netstack + refactoring meshgw, and reuses the exact proxy a real browser already works against (CA-trust caveat still applies).