experimental/windivert: P2.1+P2.2 with WinDivert NETWORK+SOCKET layers
WIP snapshot before pivot to sing-box+TUN. Reached: - TCP redirect via streamdump pattern (swap+Outbound=0+reinject) - SOCKET layer for SYN-stage flow detection (avoids FLOW Establish-too-late race) - Lazy PID→name resolution (catches Update.exe inside procscan tick) - UDP forward via SOCKS5 UDP ASSOCIATE relay + manual reinject - Result: chat works, voice times out (Discord IP discovery / RTC handshake fails) Reason for pivot: WinDivert NAT-reinject pattern has subtle layer-3 semantics issues that DLL-injection / TUN-based proxies sidestep entirely. Going with embedded sing-box + wintun as the engine — proven path for Discord voice through SOCKS5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,21 @@ func (r *Redirector) SetMapping(srcPort uint16, dstIP net.IP, dstPort uint16) {
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetMapping returns the original (dstIP, dstPort) for a recorded flow
|
||||
// keyed by src port, or ok=false if no mapping exists. Used by the
|
||||
// engine's diverterLoop on the return path to look up the original
|
||||
// target port when rewriting packets going from the listener back to
|
||||
// the client.
|
||||
func (r *Redirector) GetMapping(srcPort uint16) (net.IP, uint16, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
m, ok := r.flows[srcPort]
|
||||
if !ok {
|
||||
return nil, 0, false
|
||||
}
|
||||
return m.dstIP, m.dstPort, true
|
||||
}
|
||||
|
||||
// Close stops accepting and tears down active flows.
|
||||
func (r *Redirector) Close() error {
|
||||
r.cnl()
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/divert"
|
||||
"git.okcu.io/root/drover-go/internal/socks5"
|
||||
)
|
||||
|
||||
// UDPInjector is the minimal subset of *divert.Handle the UDPProxy
|
||||
// needs to reinject return-path packets. Defined as an interface so
|
||||
// tests can stub it out without spinning up a real WinDivert handle.
|
||||
type UDPInjector interface {
|
||||
Send(buf []byte, addr UDPInjectAddr) (int, error)
|
||||
}
|
||||
|
||||
// UDPInjectAddr describes the WinDivert addr fields that matter for
|
||||
// reinjection (we don't need the full 64-byte union here — only flags
|
||||
// determine direction + checksum status). Production code uses the
|
||||
// adapter (see DivertHandleInjector) to convert between this and the
|
||||
// real *idivert.Address.
|
||||
type UDPInjectAddr struct {
|
||||
// Outbound=false → packet will be delivered as inbound (kernel
|
||||
// rcv path), which is exactly what we want when fabricating a
|
||||
// "remote → local" reply for Discord.
|
||||
Outbound bool
|
||||
}
|
||||
|
||||
// UDPConfig configures the UDPProxy.
|
||||
type UDPConfig struct {
|
||||
SOCKS5 socks5.Config
|
||||
LocalIP net.IP // local LAN IP we use as the dst on fabricated reply packets
|
||||
|
||||
// Injector is used to reinject return-path packets back to Discord
|
||||
// via the WinDivert NETWORK handle. Required.
|
||||
Injector UDPInjector
|
||||
|
||||
// LogPrefix is prepended to all log lines emitted by the proxy.
|
||||
// Empty defaults to "udp-proxy: ".
|
||||
LogPrefix string
|
||||
}
|
||||
|
||||
// udpFlow tracks one (Discord_src → real_dst) UDP flow for the
|
||||
// purpose of routing relay responses back to Discord.
|
||||
type udpFlow struct {
|
||||
// realDst* identifies the upstream UDP target (the same key the
|
||||
// SOCKS5 relay puts in DST.ADDR/DST.PORT on the inbound envelope).
|
||||
realDstIP [4]byte
|
||||
realDstPort uint16
|
||||
|
||||
// discordSrc* identifies the Discord side of the flow — used as
|
||||
// the dst on fabricated reply packets so the kernel matches the
|
||||
// connect()-bound socket.
|
||||
discordSrcIP [4]byte
|
||||
discordSrcPort uint16
|
||||
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
// UDPProxy is the SOCKS5 UDP relay manager. The engine's diverterLoop
|
||||
// calls Forward on outbound UDP packets from target processes; the
|
||||
// proxy lazily opens a single UDP ASSOCIATE control TCP + relay UDP
|
||||
// socket on first use, and shares them across all UDP flows. Inbound
|
||||
// responses are read from the relay socket, decap'd, and reinjected
|
||||
// as fabricated IPv4+UDP packets via the WinDivert NETWORK handle.
|
||||
type UDPProxy struct {
|
||||
cfg UDPConfig
|
||||
|
||||
// Lazy-opened on first Forward call.
|
||||
ctrlMu sync.Mutex
|
||||
ctrlConn net.Conn // SOCKS5 control TCP — must stay open for relay validity
|
||||
relayAddr *net.UDPAddr // upstream relay UDP endpoint
|
||||
relayConn net.PacketConn // local UDP socket bound to talk to relay
|
||||
|
||||
flowMu sync.RWMutex
|
||||
// Keyed by realDstIP:realDstPort — the relay responds with these
|
||||
// in the SOCKS5 envelope, so this is our reverse lookup.
|
||||
flowsByDst map[flowDstKey]*udpFlow
|
||||
|
||||
// Atomic stats counters for diagnostics
|
||||
fwdPackets uint64
|
||||
fwdBytes uint64
|
||||
recvPackets uint64
|
||||
injectedPackets uint64
|
||||
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// Idle TTL for udpFlow entries (default 5 minutes per RFC 4787).
|
||||
IdleTTL time.Duration
|
||||
}
|
||||
|
||||
type flowDstKey struct {
|
||||
ip [4]byte
|
||||
port uint16
|
||||
}
|
||||
|
||||
// NewUDP constructs a UDPProxy. It does not yet open the SOCKS5 UDP
|
||||
// ASSOCIATE — that happens lazily on the first Forward call.
|
||||
func NewUDP(cfg UDPConfig) (*UDPProxy, error) {
|
||||
if cfg.Injector == nil {
|
||||
return nil, errors.New("UDPConfig.Injector is required")
|
||||
}
|
||||
if cfg.LocalIP == nil || cfg.LocalIP.To4() == nil {
|
||||
return nil, errors.New("UDPConfig.LocalIP must be IPv4")
|
||||
}
|
||||
if cfg.LogPrefix == "" {
|
||||
cfg.LogPrefix = "udp-proxy: "
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
u := &UDPProxy{
|
||||
cfg: cfg,
|
||||
flowsByDst: map[flowDstKey]*udpFlow{},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
IdleTTL: 5 * time.Minute,
|
||||
}
|
||||
u.wg.Add(1)
|
||||
go u.sweepLoop()
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Forward is called from the engine's diverterLoop on each outbound
|
||||
// UDP packet from a target process. It:
|
||||
//
|
||||
// 1. Lazy-opens the SOCKS5 UDP association on first call.
|
||||
// 2. Records the flow keyed by (dstIP,dstPort) so the relay-response
|
||||
// reader can route the reply back to the right Discord port.
|
||||
// 3. Encapsulates the payload in a SOCKS5 UDP datagram (RFC 1928 §7)
|
||||
// and forwards it to the relay endpoint.
|
||||
//
|
||||
// Returns nil on success or any error encountered (caller may log
|
||||
// but should generally drop the packet on failure — UDP loss is
|
||||
// expected at the wire).
|
||||
func (u *UDPProxy) Forward(srcIP net.IP, srcPort uint16, dstIP net.IP, dstPort uint16, payload []byte) error {
|
||||
srcV4 := srcIP.To4()
|
||||
dstV4 := dstIP.To4()
|
||||
if srcV4 == nil || dstV4 == nil {
|
||||
return errors.New("UDPProxy.Forward: src/dst must be IPv4")
|
||||
}
|
||||
|
||||
if err := u.ensureAssociated(); err != nil {
|
||||
return fmt.Errorf("ensure assoc: %w", err)
|
||||
}
|
||||
|
||||
// Record/refresh flow for the return path
|
||||
var dKey flowDstKey
|
||||
copy(dKey.ip[:], dstV4)
|
||||
dKey.port = dstPort
|
||||
|
||||
u.flowMu.Lock()
|
||||
fl, ok := u.flowsByDst[dKey]
|
||||
if !ok {
|
||||
fl = &udpFlow{}
|
||||
u.flowsByDst[dKey] = fl
|
||||
}
|
||||
copy(fl.realDstIP[:], dstV4)
|
||||
fl.realDstPort = dstPort
|
||||
copy(fl.discordSrcIP[:], srcV4)
|
||||
fl.discordSrcPort = srcPort
|
||||
fl.lastUsed = time.Now()
|
||||
u.flowMu.Unlock()
|
||||
|
||||
// Encap and send to relay
|
||||
envelope, err := socks5.EncapUDPv4(dstIP, dstPort, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encap: %w", err)
|
||||
}
|
||||
n, err := u.relayConn.WriteTo(envelope, u.relayAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write to relay: %w", err)
|
||||
}
|
||||
atomic.AddUint64(&u.fwdPackets, 1)
|
||||
atomic.AddUint64(&u.fwdBytes, uint64(n))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats returns counters for diagnostics: forwarded outbound packets,
|
||||
// inbound packets received from relay, inbound packets successfully
|
||||
// reinjected to Discord. All atomic; safe to read concurrently.
|
||||
func (u *UDPProxy) Stats() (fwdPkts, fwdBytes, recvPkts, injectedPkts uint64) {
|
||||
return atomic.LoadUint64(&u.fwdPackets),
|
||||
atomic.LoadUint64(&u.fwdBytes),
|
||||
atomic.LoadUint64(&u.recvPackets),
|
||||
atomic.LoadUint64(&u.injectedPackets)
|
||||
}
|
||||
|
||||
// ensureAssociated opens the SOCKS5 UDP association on first use and
|
||||
// reuses it forever (until Close). The relay endpoint stays valid as
|
||||
// long as the control TCP is open, per RFC 1928 §6.
|
||||
func (u *UDPProxy) ensureAssociated() error {
|
||||
u.ctrlMu.Lock()
|
||||
defer u.ctrlMu.Unlock()
|
||||
if u.ctrlConn != nil && u.relayAddr != nil && u.relayConn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
relay, ctrl, err := socks5.AssociateUDP(ctx, u.cfg.SOCKS5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bind a local UDP socket to talk to the relay. Bind on 0.0.0.0:0
|
||||
// so the kernel picks an ephemeral port; we'll use this socket as
|
||||
// both the writer (Forward) AND the reader (relayReadLoop).
|
||||
pc, err := net.ListenPacket("udp4", ":0")
|
||||
if err != nil {
|
||||
ctrl.Close()
|
||||
return fmt.Errorf("listen relay socket: %w", err)
|
||||
}
|
||||
|
||||
u.ctrlConn = ctrl
|
||||
u.relayAddr = relay
|
||||
u.relayConn = pc
|
||||
|
||||
log.Printf("%sSOCKS5 UDP ASSOCIATE relay=%s local=%s", u.cfg.LogPrefix, relay, pc.LocalAddr())
|
||||
|
||||
// Spawn the reader goroutine.
|
||||
u.wg.Add(1)
|
||||
go u.relayReadLoop()
|
||||
|
||||
// Spawn a control-conn watcher: if the proxy closes the control
|
||||
// TCP for any reason, our relay endpoint is invalidated. Mark
|
||||
// state for re-association on next Forward.
|
||||
u.wg.Add(1)
|
||||
go u.ctrlWatcher()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UDPProxy) ctrlWatcher() {
|
||||
defer u.wg.Done()
|
||||
// Read forever from ctrlConn; per RFC 1928 §6 the proxy doesn't
|
||||
// send anything on this conn after the UDP ASSOCIATE reply, so
|
||||
// any read-completion (with or without bytes) means the conn is
|
||||
// gone. This is a fire-and-forget watcher — it doesn't actively
|
||||
// re-associate; ensureAssociated() will do that on next Forward.
|
||||
one := make([]byte, 1)
|
||||
for {
|
||||
// Use a generous read deadline so we wake up periodically to
|
||||
// honor ctx cancellation.
|
||||
_ = u.ctrlConn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
_, err := u.ctrlConn.Read(one)
|
||||
if err == nil {
|
||||
continue // unexpected data; keep monitoring
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
select {
|
||||
case <-u.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Real error — control conn is dead. Tear down so next Forward
|
||||
// re-associates.
|
||||
log.Printf("%scontrol TCP closed: %v — relay invalidated", u.cfg.LogPrefix, err)
|
||||
u.ctrlMu.Lock()
|
||||
if u.ctrlConn != nil {
|
||||
u.ctrlConn.Close()
|
||||
u.ctrlConn = nil
|
||||
}
|
||||
if u.relayConn != nil {
|
||||
u.relayConn.Close()
|
||||
u.relayConn = nil
|
||||
}
|
||||
u.relayAddr = nil
|
||||
u.ctrlMu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// relayReadLoop reads inbound datagrams from the relay socket.
|
||||
// Datagrams from the relay are SOCKS5 UDP envelopes (RFC 1928 §7);
|
||||
// we decap, look up the corresponding Discord flow by the envelope's
|
||||
// DST.ADDR/DST.PORT (which contains the ORIGIN of the response), and
|
||||
// reinject a fabricated IPv4+UDP packet as inbound via WinDivert.
|
||||
func (u *UDPProxy) relayReadLoop() {
|
||||
defer u.wg.Done()
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
select {
|
||||
case <-u.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Snapshot relay conn under lock; if torn down by ctrlWatcher
|
||||
// we need to bail out.
|
||||
u.ctrlMu.Lock()
|
||||
pc := u.relayConn
|
||||
relay := u.relayAddr
|
||||
u.ctrlMu.Unlock()
|
||||
if pc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = pc.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, fromAddr, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
// Likely closed — exit.
|
||||
return
|
||||
}
|
||||
atomic.AddUint64(&u.recvPackets, 1)
|
||||
|
||||
// Sanity-check source: relay datagrams come from the relay's
|
||||
// known address. Ignore anything else (in particular some
|
||||
// SOCKS5 implementations bind 0.0.0.0; we accept any port match
|
||||
// loosely, but require IP match when available).
|
||||
fromUDP, ok := fromAddr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if relay != nil && relay.IP != nil && !relay.IP.Equal(net.IPv4zero) {
|
||||
if !fromUDP.IP.Equal(relay.IP) || fromUDP.Port != relay.Port {
|
||||
// Not from our relay — drop.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
srcIP, srcPort, payload, derr := socks5.DecapUDPv4(buf[:n])
|
||||
if derr != nil {
|
||||
log.Printf("%sdecap error: %v", u.cfg.LogPrefix, derr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Look up the Discord flow by (origin IP, origin port)
|
||||
v4 := srcIP.To4()
|
||||
if v4 == nil {
|
||||
continue
|
||||
}
|
||||
var key flowDstKey
|
||||
copy(key.ip[:], v4)
|
||||
key.port = srcPort
|
||||
|
||||
u.flowMu.RLock()
|
||||
fl, ok := u.flowsByDst[key]
|
||||
u.flowMu.RUnlock()
|
||||
if !ok {
|
||||
// No active flow for this origin; drop.
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark the flow as recently used (touched by inbound).
|
||||
u.flowMu.Lock()
|
||||
fl.lastUsed = time.Now()
|
||||
u.flowMu.Unlock()
|
||||
|
||||
// Fabricate IPv4+UDP packet:
|
||||
// src = real_origin (the proxy's relay tells us this in the envelope)
|
||||
// dst = local LAN IP we bound on
|
||||
// srcPort = real origin port
|
||||
// dstPort = Discord ephemeral port (so kernel matches the connect()-bound socket)
|
||||
discordIP := net.IPv4(fl.discordSrcIP[0], fl.discordSrcIP[1], fl.discordSrcIP[2], fl.discordSrcIP[3])
|
||||
// Some Discord sockets bind to local LAN IP, others bind 0.0.0.0
|
||||
// (which the SOCKET layer reports as 0.0.0.0). When discord's
|
||||
// reported srcIP is 0.0.0.0 the kernel's connect-bound socket
|
||||
// will still match dst=our LocalIP. But to be safe for the
|
||||
// non-zero case (sockets bound to specific local IP), use the
|
||||
// recorded discord side IP if it is non-zero; otherwise fall
|
||||
// back to LocalIP.
|
||||
dstIP := discordIP
|
||||
if discordIP.Equal(net.IPv4zero) {
|
||||
dstIP = u.cfg.LocalIP
|
||||
}
|
||||
|
||||
pkt, berr := divert.BuildIPv4UDPInbound(srcIP, dstIP, srcPort, fl.discordSrcPort, payload)
|
||||
if berr != nil {
|
||||
log.Printf("%sbuild packet error: %v", u.cfg.LogPrefix, berr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Reinject as inbound. WinDivert flag bits we set: IPChecksum
|
||||
// (we computed it), UDPChecksum (we computed it). Outbound bit
|
||||
// stays clear — kernel delivers via inbound path.
|
||||
if _, serr := u.cfg.Injector.Send(pkt, UDPInjectAddr{Outbound: false}); serr != nil {
|
||||
log.Printf("%sinject error: %v", u.cfg.LogPrefix, serr)
|
||||
} else {
|
||||
atomic.AddUint64(&u.injectedPackets, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sweepLoop garbage-collects stale udpFlow entries. UDP "flows" are
|
||||
// stateless — there's no FIN-equivalent — so we rely on idle timeout.
|
||||
// 5 minutes matches RFC 4787 NAT requirements (REQ-5).
|
||||
func (u *UDPProxy) sweepLoop() {
|
||||
defer u.wg.Done()
|
||||
tk := time.NewTicker(time.Minute)
|
||||
defer tk.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-u.ctx.Done():
|
||||
return
|
||||
case <-tk.C:
|
||||
cutoff := time.Now().Add(-u.IdleTTL)
|
||||
u.flowMu.Lock()
|
||||
for k, f := range u.flowsByDst {
|
||||
if f.lastUsed.Before(cutoff) {
|
||||
delete(u.flowsByDst, k)
|
||||
}
|
||||
}
|
||||
u.flowMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close tears down the UDPProxy: cancels reader goroutines, closes
|
||||
// the relay UDP socket and the SOCKS5 control TCP. Safe to call
|
||||
// multiple times.
|
||||
func (u *UDPProxy) Close() error {
|
||||
u.cancel()
|
||||
u.ctrlMu.Lock()
|
||||
if u.relayConn != nil {
|
||||
_ = u.relayConn.Close()
|
||||
u.relayConn = nil
|
||||
}
|
||||
if u.ctrlConn != nil {
|
||||
_ = u.ctrlConn.Close()
|
||||
u.ctrlConn = nil
|
||||
}
|
||||
u.relayAddr = nil
|
||||
u.ctrlMu.Unlock()
|
||||
u.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// FlowCount returns the current number of tracked UDP flows. Test
|
||||
// helper.
|
||||
func (u *UDPProxy) FlowCount() int {
|
||||
u.flowMu.RLock()
|
||||
defer u.flowMu.RUnlock()
|
||||
return len(u.flowsByDst)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package redirect
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/socks5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeInjector captures injected packets for assertions.
|
||||
type fakeInjector struct {
|
||||
mu sync.Mutex
|
||||
packets [][]byte
|
||||
addrs []UDPInjectAddr
|
||||
}
|
||||
|
||||
func (f *fakeInjector) Send(buf []byte, addr UDPInjectAddr) (int, error) {
|
||||
f.mu.Lock()
|
||||
cp := make([]byte, len(buf))
|
||||
copy(cp, buf)
|
||||
f.packets = append(f.packets, cp)
|
||||
f.addrs = append(f.addrs, addr)
|
||||
f.mu.Unlock()
|
||||
return len(buf), nil
|
||||
}
|
||||
|
||||
func (f *fakeInjector) packetsLen() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.packets)
|
||||
}
|
||||
|
||||
func (f *fakeInjector) get(idx int) ([]byte, UDPInjectAddr) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.packets[idx], f.addrs[idx]
|
||||
}
|
||||
|
||||
// startUDPRelayProxy starts a fake SOCKS5 proxy with UDP ASSOCIATE
|
||||
// support. It echoes any datagram it receives on the relay back to
|
||||
// the sender, with the SOCKS5 envelope's DST.ADDR/DST.PORT preserved.
|
||||
// The "echoOrigin" return-path is what the real upstream relay does:
|
||||
// when an upstream UDP server responds, the proxy puts that server's
|
||||
// addr in DST.ADDR/DST.PORT for the inbound envelope.
|
||||
func startUDPRelayProxy(t *testing.T) (tcpAddr string, relay *net.UDPConn) {
|
||||
tcpLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { tcpLn.Close() })
|
||||
|
||||
relay, err = net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { relay.Close() })
|
||||
|
||||
go func() {
|
||||
for {
|
||||
c, err := tcpLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
buf := make([]byte, 256)
|
||||
// Greet
|
||||
io.ReadFull(c, buf[:2])
|
||||
nm := int(buf[1])
|
||||
io.ReadFull(c, buf[:nm])
|
||||
c.Write([]byte{0x05, 0x00})
|
||||
// UDP ASSOCIATE
|
||||
io.ReadFull(c, buf[:4])
|
||||
if buf[1] != 0x03 {
|
||||
return
|
||||
}
|
||||
atyp := buf[3]
|
||||
switch atyp {
|
||||
case 1:
|
||||
io.ReadFull(c, buf[:4])
|
||||
case 3:
|
||||
io.ReadFull(c, buf[:1])
|
||||
io.ReadFull(c, buf[:int(buf[0])])
|
||||
}
|
||||
io.ReadFull(c, buf[:2])
|
||||
// Reply with relay addr
|
||||
ra := relay.LocalAddr().(*net.UDPAddr)
|
||||
rep := []byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}
|
||||
copy(rep[4:8], ra.IP.To4())
|
||||
binary.BigEndian.PutUint16(rep[8:10], uint16(ra.Port))
|
||||
c.Write(rep)
|
||||
// Hold open
|
||||
io.Copy(io.Discard, c)
|
||||
}(c)
|
||||
}
|
||||
}()
|
||||
return tcpLn.Addr().String(), relay
|
||||
}
|
||||
|
||||
func TestUDPProxy_ForwardEncapsulates(t *testing.T) {
|
||||
tcpAddr, relay := startUDPRelayProxy(t)
|
||||
inj := &fakeInjector{}
|
||||
|
||||
u, err := NewUDP(UDPConfig{
|
||||
SOCKS5: socks5.Config{ProxyAddr: tcpAddr},
|
||||
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||
Injector: inj,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { u.Close() })
|
||||
|
||||
// Forward a packet and verify the relay receives it encapsulated.
|
||||
srcIP := net.IPv4(127, 0, 0, 1)
|
||||
dstIP := net.IPv4(140, 82, 121, 4)
|
||||
payload := []byte("hello voice")
|
||||
require.NoError(t, u.Forward(srcIP, 50100, dstIP, 50007, payload))
|
||||
|
||||
// Read from the relay to verify the SOCKS5 envelope.
|
||||
buf := make([]byte, 1500)
|
||||
_ = relay.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, _, err := relay.ReadFromUDP(buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := buf[:n]
|
||||
gotIP, gotPort, gotPayload, err := socks5.DecapUDPv4(got)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "140.82.121.4", gotIP.String())
|
||||
assert.Equal(t, uint16(50007), gotPort)
|
||||
assert.Equal(t, payload, gotPayload)
|
||||
|
||||
assert.Equal(t, 1, u.FlowCount(), "should have one tracked flow")
|
||||
}
|
||||
|
||||
func TestUDPProxy_RelayResponseInjectsBackToDiscord(t *testing.T) {
|
||||
tcpAddr, relay := startUDPRelayProxy(t)
|
||||
inj := &fakeInjector{}
|
||||
|
||||
u, err := NewUDP(UDPConfig{
|
||||
SOCKS5: socks5.Config{ProxyAddr: tcpAddr},
|
||||
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||
Injector: inj,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { u.Close() })
|
||||
|
||||
// Establish a flow by forwarding one packet
|
||||
discordSrcIP := net.IPv4(127, 0, 0, 1)
|
||||
discordSrcPort := uint16(50100)
|
||||
realDstIP := net.IPv4(140, 82, 121, 4)
|
||||
realDstPort := uint16(50007)
|
||||
require.NoError(t, u.Forward(discordSrcIP, discordSrcPort, realDstIP, realDstPort, []byte("hi")))
|
||||
|
||||
// Drain the encapsulated forward
|
||||
drainBuf := make([]byte, 1500)
|
||||
_ = relay.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, clientRelayAddr, err := relay.ReadFromUDP(drainBuf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate upstream UDP server response: relay sends back an
|
||||
// envelope where DST.ADDR/DST.PORT = real upstream origin.
|
||||
respPayload := []byte("voice response")
|
||||
envelope, err := socks5.EncapUDPv4(realDstIP, realDstPort, respPayload)
|
||||
require.NoError(t, err)
|
||||
_, err = relay.WriteToUDP(envelope, clientRelayAddr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The proxy's relayReadLoop should receive, decap, and inject.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && inj.packetsLen() == 0 {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
require.Equal(t, 1, inj.packetsLen(), "expected one injected packet")
|
||||
|
||||
pkt, addr := inj.get(0)
|
||||
assert.False(t, addr.Outbound, "injected as inbound")
|
||||
|
||||
// Parse the fabricated IPv4+UDP packet
|
||||
require.GreaterOrEqual(t, len(pkt), 28)
|
||||
// Verify proto=UDP
|
||||
assert.Equal(t, byte(17), pkt[9], "IPv4 proto field")
|
||||
srcIP := net.IPv4(pkt[12], pkt[13], pkt[14], pkt[15])
|
||||
dstIP := net.IPv4(pkt[16], pkt[17], pkt[18], pkt[19])
|
||||
srcPort := binary.BigEndian.Uint16(pkt[20:22])
|
||||
dstPort := binary.BigEndian.Uint16(pkt[22:24])
|
||||
|
||||
assert.Equal(t, "140.82.121.4", srcIP.String(), "fabricated src = real upstream origin")
|
||||
assert.Equal(t, "127.0.0.1", dstIP.String(), "fabricated dst = Discord-side IP")
|
||||
assert.Equal(t, realDstPort, srcPort, "fabricated src port = real upstream port")
|
||||
assert.Equal(t, discordSrcPort, dstPort, "fabricated dst port = Discord ephemeral port")
|
||||
|
||||
// Payload after IPv4(20)+UDP(8) headers
|
||||
assert.Equal(t, respPayload, pkt[28:])
|
||||
}
|
||||
|
||||
func TestUDPProxy_NoFlowDropsResponse(t *testing.T) {
|
||||
tcpAddr, relay := startUDPRelayProxy(t)
|
||||
inj := &fakeInjector{}
|
||||
|
||||
u, err := NewUDP(UDPConfig{
|
||||
SOCKS5: socks5.Config{ProxyAddr: tcpAddr},
|
||||
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||
Injector: inj,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { u.Close() })
|
||||
|
||||
// Force association without registering any flow.
|
||||
require.NoError(t, u.ensureAssociated())
|
||||
|
||||
// Read the local relay socket's port and substitute 127.0.0.1 for
|
||||
// 0.0.0.0 (kernel binds wildcard but Windows refuses to send TO
|
||||
// 0.0.0.0:N — it requires a routable destination).
|
||||
localAddr := u.relayConn.LocalAddr().(*net.UDPAddr)
|
||||
dst := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: localAddr.Port}
|
||||
|
||||
// Send a "stray" relay datagram with an origin we never registered.
|
||||
envelope, _ := socks5.EncapUDPv4(net.IPv4(8, 8, 8, 8), 53, []byte("dns"))
|
||||
_, err = relay.WriteToUDP(envelope, dst)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Give the reader time to process and drop.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
assert.Equal(t, 0, inj.packetsLen(), "stray response should be dropped, not injected")
|
||||
}
|
||||
|
||||
func TestUDPProxy_RejectsIPv6(t *testing.T) {
|
||||
inj := &fakeInjector{}
|
||||
u, err := NewUDP(UDPConfig{
|
||||
SOCKS5: socks5.Config{ProxyAddr: "127.0.0.1:0"},
|
||||
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||
Injector: inj,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { u.Close() })
|
||||
|
||||
v6 := net.ParseIP("::1")
|
||||
err = u.Forward(net.IPv4(1, 2, 3, 4), 1000, v6, 80, []byte("x"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewUDP_RejectsNilInjector(t *testing.T) {
|
||||
_, err := NewUDP(UDPConfig{
|
||||
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewUDP_RejectsNonIPv4LocalIP(t *testing.T) {
|
||||
_, err := NewUDP(UDPConfig{
|
||||
LocalIP: net.ParseIP("::1"),
|
||||
Injector: &fakeInjector{},
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user