4074e68715
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>
257 lines
7.5 KiB
Go
257 lines
7.5 KiB
Go
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)
|
|
}
|