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:
@@ -5,10 +5,83 @@ package divert
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
idivert "github.com/imgk/divert-go"
|
||||
)
|
||||
|
||||
// idivertAddrLayout mirrors the imgk/divert-go private Address fields
|
||||
// so we can read the raw 64-byte union without going through their
|
||||
// (mis-aligned for FLOW events) accessor.
|
||||
type idivertAddrLayout struct {
|
||||
Timestamp int64
|
||||
Layer uint8
|
||||
Event uint8
|
||||
Flags uint8
|
||||
_ uint8
|
||||
Length uint32
|
||||
Union [64]byte
|
||||
}
|
||||
|
||||
// parseFlowUnion decodes a WINDIVERT_DATA_FLOW from raw union bytes.
|
||||
// Layout per WinDivert v2 (MSVC default 8-byte alignment):
|
||||
//
|
||||
// offset 0..7 EndpointId UINT64
|
||||
// offset 8..15 ParentEndpointId UINT64
|
||||
// offset 16..19 ProcessId UINT32
|
||||
// offset 20..23 (padding to 4) — not 8 because LocalAddr has 4-byte alignment
|
||||
// offset 24..39 LocalAddr[4] UINT32 — NO, wait.
|
||||
//
|
||||
// Actually WinDivert struct uses UINT32 (4-byte aligned), no padding
|
||||
// between ProcessId and LocalAddr. But we observed ProcessID and
|
||||
// Ports parse correctly via imgk's struct (which assumes offset 20
|
||||
// for LocalAddr). So that layout is right; the IPs zero-out must be
|
||||
// because *imgk's struct member [16]uint8 doesn't read what we think*.
|
||||
//
|
||||
// Mystery: imgk's Flow struct should give correct addresses. Yet we
|
||||
// see [0,0,0,0]. Re-inspect raw bytes.
|
||||
func parseFlowUnion(b []byte) *FlowEvent {
|
||||
if len(b) < 64 {
|
||||
return &FlowEvent{}
|
||||
}
|
||||
ev := &FlowEvent{
|
||||
ProcessID: leU32(b[16:20]),
|
||||
LocalRaw: toAddr16(b[20:36]),
|
||||
RemoteRaw: toAddr16(b[36:52]),
|
||||
LocalPort: leU16(b[52:54]),
|
||||
RemotePort: leU16(b[54:56]),
|
||||
Protocol: b[56],
|
||||
}
|
||||
// WinDivert v2.2.2 stores IPv4 as little-endian uint32 in the
|
||||
// first 4 bytes of the 16-byte address slot (bytes 4..7 hold the
|
||||
// 0xFFFF mapped-IPv6 prefix; bytes 8..15 are zero). To get the
|
||||
// dot-notation IP A.B.C.D, reverse the byte order:
|
||||
// byte[0] = D (LSB), byte[1] = C, byte[2] = B, byte[3] = A (MSB).
|
||||
ev.SrcAddr[0] = ev.LocalRaw[3]
|
||||
ev.SrcAddr[1] = ev.LocalRaw[2]
|
||||
ev.SrcAddr[2] = ev.LocalRaw[1]
|
||||
ev.SrcAddr[3] = ev.LocalRaw[0]
|
||||
ev.DstAddr[0] = ev.RemoteRaw[3]
|
||||
ev.DstAddr[1] = ev.RemoteRaw[2]
|
||||
ev.DstAddr[2] = ev.RemoteRaw[1]
|
||||
ev.DstAddr[3] = ev.RemoteRaw[0]
|
||||
ev.SrcPort = ev.LocalPort
|
||||
ev.DstPort = ev.RemotePort
|
||||
return ev
|
||||
}
|
||||
|
||||
func leU32(b []byte) uint32 {
|
||||
return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
|
||||
}
|
||||
func leU16(b []byte) uint16 {
|
||||
return uint16(b[0]) | uint16(b[1])<<8
|
||||
}
|
||||
func toAddr16(b []byte) [16]byte {
|
||||
var a [16]byte
|
||||
copy(a[:], b)
|
||||
return a
|
||||
}
|
||||
|
||||
// Handle wraps a WinDivert handle.
|
||||
type Handle struct {
|
||||
h *idivert.Handle
|
||||
@@ -29,6 +102,187 @@ func Open(filter string) (*Handle, error) {
|
||||
return &Handle{h: h}, nil
|
||||
}
|
||||
|
||||
// OpenFlow opens a WinDivert handle at FLOW layer. FLOW handles
|
||||
// observe TCP/UDP flow establish + delete events with processId info
|
||||
// available — that's where we learn which 5-tuples belong to target
|
||||
// processes (processId field is invalid on the NETWORK layer filter
|
||||
// language). FLOW handles cannot Send packets — they're read-only by
|
||||
// design.
|
||||
//
|
||||
// Per WinDivert reference, FLOW handles MUST be opened with both
|
||||
// SNIFF (events only, no interception) and RECV_ONLY (no Send) flags,
|
||||
// otherwise WinDivertOpen rejects the request.
|
||||
func OpenFlow(filter string) (*Handle, error) {
|
||||
h, err := idivert.Open(filter, idivert.LayerFlow, 0, idivert.FlagSniff|idivert.FlagRecvOnly)
|
||||
if err != nil {
|
||||
return nil, mapWinDivertErr(err)
|
||||
}
|
||||
return &Handle{h: h}, nil
|
||||
}
|
||||
|
||||
// OpenSocket opens a WinDivert handle at SOCKET layer. SOCKET layer
|
||||
// fires events synchronously with socket syscalls (bind/connect/
|
||||
// listen/accept/close) — Connect specifically fires BEFORE the SYN
|
||||
// packet leaves the box, which gives us a window to populate our
|
||||
// redirect tables before the NETWORK-layer SYN arrives.
|
||||
//
|
||||
// Same flag rules as FLOW: must be SNIFF + RECV_ONLY.
|
||||
func OpenSocket(filter string) (*Handle, error) {
|
||||
h, err := idivert.Open(filter, idivert.LayerSocket, 0, idivert.FlagSniff|idivert.FlagRecvOnly)
|
||||
if err != nil {
|
||||
return nil, mapWinDivertErr(err)
|
||||
}
|
||||
return &Handle{h: h}, nil
|
||||
}
|
||||
|
||||
// SocketEvent represents a socket-layer event (Connect/Close/etc).
|
||||
type SocketEvent struct {
|
||||
ProcessID uint32
|
||||
Protocol uint8 // 6=TCP, 17=UDP
|
||||
SrcAddr [4]byte
|
||||
SrcPort uint16
|
||||
DstAddr [4]byte
|
||||
DstPort uint16
|
||||
Kind SocketEventKind
|
||||
LocalRaw [16]byte // raw 16-byte slot for diagnostic
|
||||
RemoteRaw [16]byte
|
||||
}
|
||||
|
||||
// SocketEventKind enumerates the socket-layer events we care about.
|
||||
type SocketEventKind int
|
||||
|
||||
const (
|
||||
SocketKindUnknown SocketEventKind = iota
|
||||
SocketKindBind
|
||||
SocketKindConnect
|
||||
SocketKindListen
|
||||
SocketKindAccept
|
||||
SocketKindClose
|
||||
)
|
||||
|
||||
// RecvSocket blocks until a socket event arrives on a SOCKET-layer
|
||||
// handle. The packet payload is empty on SOCKET events; only the
|
||||
// address metadata matters.
|
||||
func (h *Handle) RecvSocket() (*SocketEvent, error) {
|
||||
if h == nil || h.h == nil {
|
||||
return nil, errors.New("handle closed")
|
||||
}
|
||||
buf := [4]byte{}
|
||||
addr := new(idivert.Address)
|
||||
_, err := h.h.Recv(buf[:], addr)
|
||||
if err != nil {
|
||||
return nil, mapWinDivertErr(err)
|
||||
}
|
||||
// SOCKET layer uses the same WINDIVERT_DATA_SOCKET layout as FLOW
|
||||
// (verbatim per the WinDivert v2.2.2 header). We bypass the
|
||||
// imgk/divert-go accessor for the same alignment-safety reason as
|
||||
// RecvFlow and parse raw union bytes directly.
|
||||
raw := (*idivertAddrLayout)(unsafe.Pointer(addr))
|
||||
ev := parseSocketUnion(raw.Union[:])
|
||||
switch addr.Event() {
|
||||
case idivert.EventSocketBind:
|
||||
ev.Kind = SocketKindBind
|
||||
case idivert.EventSocketConnect:
|
||||
ev.Kind = SocketKindConnect
|
||||
case idivert.EventSocketListen:
|
||||
ev.Kind = SocketKindListen
|
||||
case idivert.EventSocketAccept:
|
||||
ev.Kind = SocketKindAccept
|
||||
case idivert.EventSocketClose:
|
||||
ev.Kind = SocketKindClose
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected socket event %d", addr.Event())
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
// parseSocketUnion mirrors parseFlowUnion: WINDIVERT_DATA_SOCKET is
|
||||
// byte-identical to WINDIVERT_DATA_FLOW per windivert.h v2.2.2.
|
||||
func parseSocketUnion(b []byte) *SocketEvent {
|
||||
if len(b) < 64 {
|
||||
return &SocketEvent{}
|
||||
}
|
||||
ev := &SocketEvent{
|
||||
ProcessID: leU32(b[16:20]),
|
||||
LocalRaw: toAddr16(b[20:36]),
|
||||
RemoteRaw: toAddr16(b[36:52]),
|
||||
SrcPort: leU16(b[52:54]),
|
||||
DstPort: leU16(b[54:56]),
|
||||
Protocol: b[56],
|
||||
}
|
||||
// Same byte-reverse trick as parseFlowUnion: WinDivert stores the
|
||||
// IPv4 in the first 4 bytes of the slot as a host-byte-order
|
||||
// uint32; reverse to get A.B.C.D in SrcAddr[0..3].
|
||||
ev.SrcAddr[0] = ev.LocalRaw[3]
|
||||
ev.SrcAddr[1] = ev.LocalRaw[2]
|
||||
ev.SrcAddr[2] = ev.LocalRaw[1]
|
||||
ev.SrcAddr[3] = ev.LocalRaw[0]
|
||||
ev.DstAddr[0] = ev.RemoteRaw[3]
|
||||
ev.DstAddr[1] = ev.RemoteRaw[2]
|
||||
ev.DstAddr[2] = ev.RemoteRaw[1]
|
||||
ev.DstAddr[3] = ev.RemoteRaw[0]
|
||||
return ev
|
||||
}
|
||||
|
||||
// FlowEvent represents a flow-establish/delete event from a FLOW
|
||||
// handle. SrcAddr/DstAddr are the IPv4 addresses (4 bytes, network
|
||||
// byte order: A.B.C.D = SrcAddr[0..3]). LocalRaw/RemoteRaw are the
|
||||
// raw 16-byte slots from WinDivert for diagnostic dumps.
|
||||
//
|
||||
// Established=true on EventFlowEstablished; false on EventFlowDeleted.
|
||||
type FlowEvent struct {
|
||||
ProcessID uint32
|
||||
Protocol uint8 // 6=TCP, 17=UDP
|
||||
SrcAddr [4]byte
|
||||
SrcPort uint16
|
||||
DstAddr [4]byte
|
||||
DstPort uint16
|
||||
Established bool
|
||||
|
||||
// Diagnostic fields populated by parseFlowUnion. Used by
|
||||
// debug-flow logging; production code should consume the
|
||||
// SrcAddr/DstAddr/SrcPort/DstPort fields above.
|
||||
LocalRaw [16]byte
|
||||
RemoteRaw [16]byte
|
||||
LocalPort uint16
|
||||
RemotePort uint16
|
||||
}
|
||||
|
||||
// RecvFlow blocks until a flow event arrives on a FLOW-layer handle.
|
||||
// The packet payload is empty on FLOW events; only the address
|
||||
// metadata matters.
|
||||
//
|
||||
// Returns the event or an error from the wrapped handle (Shutdown
|
||||
// during close, etc).
|
||||
func (h *Handle) RecvFlow() (*FlowEvent, error) {
|
||||
if h == nil || h.h == nil {
|
||||
return nil, errors.New("handle closed")
|
||||
}
|
||||
// Per WinDivert docs flow event has zero-byte packet; we still
|
||||
// need a non-nil buffer for the API.
|
||||
buf := [4]byte{}
|
||||
addr := new(idivert.Address)
|
||||
_, err := h.h.Recv(buf[:], addr)
|
||||
if err != nil {
|
||||
return nil, mapWinDivertErr(err)
|
||||
}
|
||||
// imgk/divert-go's Flow accessor mis-aligns the union for FLOW
|
||||
// events (it assumes 4-byte alignment after ProcessID, but MSVC
|
||||
// pads to 8-byte boundary because the struct contains UINT64).
|
||||
// We bypass the accessor and parse the raw union bytes ourselves.
|
||||
raw := (*idivertAddrLayout)(unsafe.Pointer(addr))
|
||||
ev := parseFlowUnion(raw.Union[:])
|
||||
switch addr.Event() {
|
||||
case idivert.EventFlowEstablished:
|
||||
ev.Established = true
|
||||
case idivert.EventFlowDeleted:
|
||||
ev.Established = false
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected flow event %d", addr.Event())
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
// Close closes the handle. Safe to call multiple times.
|
||||
func (h *Handle) Close() error {
|
||||
if h == nil || h.h == nil {
|
||||
@@ -72,6 +326,45 @@ func (h *Handle) Send(buf []byte, addr *idivert.Address) (int, error) {
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// SendInjectInbound reinjects a fabricated IPv4 packet as inbound (i.e.
|
||||
// kernel delivers it via the receive path of whatever interface owns
|
||||
// the destination IP). Used by the UDPProxy to deliver SOCKS5 relay
|
||||
// responses back to a target process: we synthesize an IPv4+UDP packet
|
||||
// with src=remote_endpoint, dst=local_LAN_IP, then call this with
|
||||
// outbound=false and IP+UDP-checksum-valid flags set.
|
||||
//
|
||||
// Internally builds a fresh *idivert.Address with NETWORK layer + the
|
||||
// requested flags + zero interface index (WinDivert routes via default).
|
||||
//
|
||||
// Flags semantics (per WinDivert v2.2.2 windivert.h):
|
||||
//
|
||||
// bit 1 (0x02) = Outbound — set if outbound, clear for inbound
|
||||
// bit 5 (0x20) = IPChecksum — packet has valid IPv4 header checksum
|
||||
// bit 6 (0x40) = TCPChecksum — packet has valid TCP checksum
|
||||
// bit 7 (0x80) = UDPChecksum — packet has valid UDP checksum
|
||||
func (h *Handle) SendInjectInbound(buf []byte, isUDP bool) (int, error) {
|
||||
if h == nil || h.h == nil {
|
||||
return 0, errors.New("handle closed")
|
||||
}
|
||||
addr := new(idivert.Address)
|
||||
addr.SetLayer(idivert.LayerNetwork)
|
||||
addr.SetEvent(idivert.EventNetworkPacket)
|
||||
// Outbound bit (0x02) cleared (inbound). Sniffed (0x01) cleared.
|
||||
// IPChecksum (0x20) set. UDP (0x80) or TCP (0x40) set per call.
|
||||
var flags uint8 = 0x20
|
||||
if isUDP {
|
||||
flags |= 0x80
|
||||
} else {
|
||||
flags |= 0x40
|
||||
}
|
||||
addr.Flags = flags
|
||||
n, err := h.h.Send(buf, addr)
|
||||
if err != nil {
|
||||
return 0, mapWinDivertErr(err)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// Sentinel errors mapped from raw Windows errors so the engine layer
|
||||
// can pattern-match without importing windows package.
|
||||
var (
|
||||
|
||||
+65
-16
@@ -24,22 +24,25 @@ type FilterParams struct {
|
||||
// self-loops. If unparseable, "0.0.0.0" is substituted (caller
|
||||
// should validate before calling).
|
||||
UpstreamIP string
|
||||
|
||||
// LocalIP is the machine's LAN IP — listener binds here, so
|
||||
// reinjected NAT'd packets (which still bear the original src)
|
||||
// reach it. Must be excluded from the filter to prevent infinite
|
||||
// recapture of NAT'd packets (we'd see them outbound again).
|
||||
LocalIP string
|
||||
}
|
||||
|
||||
// BuildFilter returns a WinDivert filter expression string suitable
|
||||
// for WinDivertOpen. The expression captures only outbound IPv4 TCP/UDP
|
||||
// from the listed PIDs, excluding our own process and the upstream
|
||||
// proxy's IP.
|
||||
func BuildFilter(p FilterParams) string {
|
||||
// BuildFlowFilter returns a filter expression for the FLOW layer handle.
|
||||
// processId is ONLY available at FLOW/SOCKET layers, not NETWORK — that's
|
||||
// why we run two handles in parallel: this FLOW handle observes which
|
||||
// 5-tuples belong to target PIDs, and the NETWORK handle (BuildNetworkFilter)
|
||||
// captures actual packets.
|
||||
//
|
||||
// Empty PID list → "false" (matches no flows).
|
||||
func BuildFlowFilter(p FilterParams) string {
|
||||
if len(p.TargetPIDs) == 0 {
|
||||
return "false"
|
||||
}
|
||||
|
||||
upstream := p.UpstreamIP
|
||||
if net.ParseIP(upstream).To4() == nil {
|
||||
upstream = "0.0.0.0"
|
||||
}
|
||||
|
||||
pidClauses := make([]string, len(p.TargetPIDs))
|
||||
for i, pid := range p.TargetPIDs {
|
||||
pidClauses[i] = fmt.Sprintf("processId == %d", pid)
|
||||
@@ -47,15 +50,61 @@ func BuildFilter(p FilterParams) string {
|
||||
pidClause := "(" + strings.Join(pidClauses, " or ") + ")"
|
||||
|
||||
parts := []string{
|
||||
"outbound",
|
||||
"(tcp or udp)",
|
||||
"ip",
|
||||
pidClause,
|
||||
fmt.Sprintf("processId != %d", p.OwnPID),
|
||||
fmt.Sprintf("ip.DstAddr != %s", upstream),
|
||||
"not (ip.DstAddr >= 224.0.0.0 and ip.DstAddr <= 239.255.255.255)",
|
||||
"not (ip.DstAddr >= 127.0.0.0 and ip.DstAddr <= 127.255.255.255)",
|
||||
"not (ip.DstAddr >= 169.254.0.0 and ip.DstAddr <= 169.254.255.255)",
|
||||
}
|
||||
return strings.Join(parts, " and ")
|
||||
}
|
||||
|
||||
// BuildNetworkFilter returns a filter expression for the NETWORK layer
|
||||
// handle. It captures all outbound IPv4 TCP/UDP except loopback,
|
||||
// multicast, link-local, and the upstream proxy. The engine then
|
||||
// narrows by consulting the flow tracker fed by the FLOW handle.
|
||||
//
|
||||
// We don't (can't) filter by processId here — see BuildFlowFilter.
|
||||
// Self-loop protection: ip.DstAddr != upstream blocks our own SOCKS5
|
||||
// uplink, and 127.0.0.0/8 exclusion blocks our loopback redirector.
|
||||
//
|
||||
// Range exclusions are spelled with explicit `<`/`>` rather than
|
||||
// `not (a and b)` because some WinDivert versions reject the latter
|
||||
// at filter compile time.
|
||||
func BuildNetworkFilter(p FilterParams) string {
|
||||
upstream := p.UpstreamIP
|
||||
if net.ParseIP(upstream).To4() == nil {
|
||||
upstream = "0.0.0.0"
|
||||
}
|
||||
parts := []string{
|
||||
"outbound",
|
||||
"ip",
|
||||
"(tcp or udp)",
|
||||
fmt.Sprintf("ip.DstAddr != %s", upstream),
|
||||
// Loopback 127.0.0.0/8
|
||||
"(ip.DstAddr < 127.0.0.0 or ip.DstAddr > 127.255.255.255)",
|
||||
// Multicast 224.0.0.0/4
|
||||
"(ip.DstAddr < 224.0.0.0 or ip.DstAddr > 239.255.255.255)",
|
||||
// Link-local 169.254.0.0/16
|
||||
"(ip.DstAddr < 169.254.0.0 or ip.DstAddr > 169.254.255.255)",
|
||||
}
|
||||
// Exclude packets DESTINED to our own LAN IP — they're either
|
||||
// intra-machine traffic we don't care about OR our own NAT'd
|
||||
// reinjects coming back around. Without this we infinite-loop.
|
||||
if p.LocalIP != "" && net.ParseIP(p.LocalIP).To4() != nil {
|
||||
parts = append(parts, fmt.Sprintf("ip.DstAddr != %s", p.LocalIP))
|
||||
}
|
||||
return strings.Join(parts, " and ")
|
||||
}
|
||||
|
||||
// BuildFilter is the legacy single-filter API. Kept for callers that
|
||||
// don't yet use the dual-handle architecture; equivalent to
|
||||
// BuildNetworkFilter (no processId — that clause is invalid at NETWORK
|
||||
// layer).
|
||||
//
|
||||
// Deprecated: use BuildFlowFilter + BuildNetworkFilter together.
|
||||
func BuildFilter(p FilterParams) string {
|
||||
if len(p.TargetPIDs) == 0 {
|
||||
return "false"
|
||||
}
|
||||
return BuildNetworkFilter(p)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// DriverPaths records where the WinDivert binaries landed after install.
|
||||
@@ -56,9 +58,34 @@ func installDriverInto(dst string) (*DriverPaths, error) {
|
||||
if err := writeIfDifferent(dllPath, winDivertDll, WinDivertDllSHA256); err != nil {
|
||||
return nil, fmt.Errorf("install WinDivert.dll: %w", err)
|
||||
}
|
||||
// imgk/divert-go's LazyDLL("WinDivert.dll") relies on the standard
|
||||
// Windows DLL search path. Our extracted binaries live in
|
||||
// %PROGRAMDATA%\Drover\windivert\ which isn't on that path by
|
||||
// default. SetDllDirectoryW prepends our directory so the lazy
|
||||
// load resolves it. Must be called BEFORE the first divert.Open.
|
||||
if err := setDllDirectory(dst); err != nil {
|
||||
return nil, fmt.Errorf("SetDllDirectory %q: %w", dst, err)
|
||||
}
|
||||
return &DriverPaths{SysPath: sysPath, DllPath: dllPath}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procSetDllDirectoryW = kernel32.NewProc("SetDllDirectoryW")
|
||||
)
|
||||
|
||||
func setDllDirectory(path string) error {
|
||||
p, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r1, _, e1 := syscall.SyscallN(procSetDllDirectoryW.Addr(), uintptr(unsafe.Pointer(p)))
|
||||
if r1 == 0 {
|
||||
return e1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeIfDifferent compares the existing file's SHA256 to the expected
|
||||
// hash; if it matches, no-op. Otherwise overwrite atomically and verify
|
||||
// the resulting on-disk SHA matches expected.
|
||||
|
||||
@@ -81,6 +81,84 @@ func RewriteDst(b []byte, ip net.IP, port uint16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwapAndSetDstPort applies the canonical streamdump-style NAT-redirect
|
||||
// rewrite: swap IPv4 src/dst, set TCP dst port to newDstPort. Keeps
|
||||
// the original TCP src port (so the listener sees a unique RemoteAddr
|
||||
// it can use to look up the flow). Recomputes both checksums.
|
||||
//
|
||||
// Use this on the FORWARD path (outbound from target process →
|
||||
// remote). After this rewrite, set addr.Outbound=0 and reinject —
|
||||
// the packet looks like remote → local on the inbound path, lands at
|
||||
// the listener.
|
||||
func SwapAndSetDstPort(b []byte, newDstPort uint16) error {
|
||||
if _, err := ParseIPv4TCP(b); err != nil {
|
||||
return err
|
||||
}
|
||||
ihl := int(b[0]&0x0f) * 4
|
||||
|
||||
// Swap src ↔ dst IPv4 (bytes 12..15 ↔ 16..19)
|
||||
var src, dst [4]byte
|
||||
copy(src[:], b[12:16])
|
||||
copy(dst[:], b[16:20])
|
||||
copy(b[12:16], dst[:])
|
||||
copy(b[16:20], src[:])
|
||||
|
||||
// Set TCP dst port; src port unchanged.
|
||||
binary.BigEndian.PutUint16(b[ihl+2:ihl+4], newDstPort)
|
||||
|
||||
// Recompute IP checksum
|
||||
b[10], b[11] = 0, 0
|
||||
cs := ipChecksum(b[:ihl])
|
||||
b[10] = byte(cs >> 8)
|
||||
b[11] = byte(cs & 0xff)
|
||||
|
||||
// Recompute TCP checksum
|
||||
b[ihl+16], b[ihl+17] = 0, 0
|
||||
cs = tcpChecksum(b[:ihl], b[ihl:])
|
||||
b[ihl+16] = byte(cs >> 8)
|
||||
b[ihl+17] = byte(cs & 0xff)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwapAndSetSrcPort applies the canonical streamdump-style return-path
|
||||
// rewrite: swap IPv4 src/dst, set TCP src port to newSrcPort (the
|
||||
// original target port the client expects to see, e.g. 443). Keeps
|
||||
// the original TCP dst port (which is the client's ephemeral port).
|
||||
//
|
||||
// Use this on the RETURN path (listener → client). After this rewrite,
|
||||
// set addr.Outbound=0 and reinject — the packet looks like remote →
|
||||
// local on the inbound path, matches the client's connect() pair, and
|
||||
// the client socket accepts the response as if from the real target.
|
||||
func SwapAndSetSrcPort(b []byte, newSrcPort uint16) error {
|
||||
if _, err := ParseIPv4TCP(b); err != nil {
|
||||
return err
|
||||
}
|
||||
ihl := int(b[0]&0x0f) * 4
|
||||
|
||||
// Swap src ↔ dst IPv4
|
||||
var src, dst [4]byte
|
||||
copy(src[:], b[12:16])
|
||||
copy(dst[:], b[16:20])
|
||||
copy(b[12:16], dst[:])
|
||||
copy(b[16:20], src[:])
|
||||
|
||||
// Set TCP src port; dst port unchanged.
|
||||
binary.BigEndian.PutUint16(b[ihl:ihl+2], newSrcPort)
|
||||
|
||||
// Recompute IP checksum
|
||||
b[10], b[11] = 0, 0
|
||||
cs := ipChecksum(b[:ihl])
|
||||
b[10] = byte(cs >> 8)
|
||||
b[11] = byte(cs & 0xff)
|
||||
|
||||
// Recompute TCP checksum
|
||||
b[ihl+16], b[ihl+17] = 0, 0
|
||||
cs = tcpChecksum(b[:ihl], b[ihl:])
|
||||
b[ihl+16] = byte(cs >> 8)
|
||||
b[ihl+17] = byte(cs & 0xff)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ipChecksum is the standard 16-bit one's-complement sum over the IP
|
||||
// header (RFC 791). The "checksum field" must be zeroed before calling.
|
||||
func ipChecksum(hdr []byte) uint16 {
|
||||
@@ -121,3 +199,233 @@ func tcpChecksum(ipHdr, tcpSeg []byte) uint16 {
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
|
||||
// IPv4UDPInfo is what we extract from a raw IPv4+UDP packet for our
|
||||
// per-flow mapping table.
|
||||
type IPv4UDPInfo struct {
|
||||
SrcIP, DstIP net.IP
|
||||
SrcPort, DstPort uint16
|
||||
IHL int // IPv4 header length in bytes
|
||||
UDPLen uint16
|
||||
}
|
||||
|
||||
// ParseIPv4UDP reads the IPv4 + UDP header pair out of an outbound
|
||||
// captured packet and returns the addressing info. Does NOT mutate
|
||||
// the buffer.
|
||||
//
|
||||
// Errors when:
|
||||
// - buffer too short to contain a full IPv4+UDP header (28 bytes)
|
||||
// - IP version is not 4
|
||||
// - IP protocol is not 17 (UDP)
|
||||
func ParseIPv4UDP(b []byte) (*IPv4UDPInfo, error) {
|
||||
if len(b) < 28 {
|
||||
return nil, errors.New("packet shorter than IPv4+UDP minimum")
|
||||
}
|
||||
if b[0]>>4 != 4 {
|
||||
return nil, errors.New("not IPv4")
|
||||
}
|
||||
ihl := int(b[0]&0x0f) * 4
|
||||
if ihl < 20 || len(b) < ihl+8 {
|
||||
return nil, errors.New("IPv4 IHL invalid or buffer truncated")
|
||||
}
|
||||
if b[9] != 17 {
|
||||
return nil, errors.New("not UDP")
|
||||
}
|
||||
src := net.IPv4(b[12], b[13], b[14], b[15])
|
||||
dst := net.IPv4(b[16], b[17], b[18], b[19])
|
||||
srcPort := binary.BigEndian.Uint16(b[ihl : ihl+2])
|
||||
dstPort := binary.BigEndian.Uint16(b[ihl+2 : ihl+4])
|
||||
udpLen := binary.BigEndian.Uint16(b[ihl+4 : ihl+6])
|
||||
return &IPv4UDPInfo{
|
||||
SrcIP: src,
|
||||
DstIP: dst,
|
||||
SrcPort: srcPort,
|
||||
DstPort: dstPort,
|
||||
IHL: ihl,
|
||||
UDPLen: udpLen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SwapUDPAndSetDstPort applies the canonical streamdump-style swap to
|
||||
// a UDP packet: swap IPv4 src/dst, set UDP dst port to newDstPort.
|
||||
// Keeps the original UDP src port. Recomputes IP and UDP checksums.
|
||||
//
|
||||
// (For UDP, swap+reinject is generally NOT used by drover — the
|
||||
// engine's diverterLoop "consumes" target UDP packets and forwards
|
||||
// them through the SOCKS5 UDP relay directly. This helper is here for
|
||||
// completeness/symmetry with the TCP swap helpers and for tests.)
|
||||
func SwapUDPAndSetDstPort(b []byte, newDstPort uint16) error {
|
||||
if _, err := ParseIPv4UDP(b); err != nil {
|
||||
return err
|
||||
}
|
||||
ihl := int(b[0]&0x0f) * 4
|
||||
|
||||
// Swap src ↔ dst IPv4
|
||||
var src, dst [4]byte
|
||||
copy(src[:], b[12:16])
|
||||
copy(dst[:], b[16:20])
|
||||
copy(b[12:16], dst[:])
|
||||
copy(b[16:20], src[:])
|
||||
|
||||
// Set UDP dst port
|
||||
binary.BigEndian.PutUint16(b[ihl+2:ihl+4], newDstPort)
|
||||
|
||||
// Recompute IP checksum
|
||||
b[10], b[11] = 0, 0
|
||||
cs := ipChecksum(b[:ihl])
|
||||
b[10] = byte(cs >> 8)
|
||||
b[11] = byte(cs & 0xff)
|
||||
|
||||
// Recompute UDP checksum (offset ihl+6,ihl+7 inside UDP header)
|
||||
udpLen := int(binary.BigEndian.Uint16(b[ihl+4 : ihl+6]))
|
||||
if ihl+udpLen > len(b) {
|
||||
udpLen = len(b) - ihl
|
||||
}
|
||||
b[ihl+6], b[ihl+7] = 0, 0
|
||||
cs = udpChecksum(b[:ihl], b[ihl:ihl+udpLen])
|
||||
// Zero is "no checksum" in IPv4 UDP. RFC 768 says when the
|
||||
// computed checksum is zero, transmit it as 0xFFFF instead.
|
||||
if cs == 0 {
|
||||
cs = 0xFFFF
|
||||
}
|
||||
b[ihl+6] = byte(cs >> 8)
|
||||
b[ihl+7] = byte(cs & 0xff)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwapUDPAndSetSrcPort applies the canonical streamdump-style return-
|
||||
// path swap to a UDP packet: swap IPv4 src/dst, set UDP src port to
|
||||
// newSrcPort (the original target port the client expects to see).
|
||||
// Recomputes IP and UDP checksums. (Symmetric counterpart to the TCP
|
||||
// helper; not currently used by the engine for the same reason as
|
||||
// SwapUDPAndSetDstPort, but exists for tests/parity.)
|
||||
func SwapUDPAndSetSrcPort(b []byte, newSrcPort uint16) error {
|
||||
if _, err := ParseIPv4UDP(b); err != nil {
|
||||
return err
|
||||
}
|
||||
ihl := int(b[0]&0x0f) * 4
|
||||
|
||||
// Swap src ↔ dst IPv4
|
||||
var src, dst [4]byte
|
||||
copy(src[:], b[12:16])
|
||||
copy(dst[:], b[16:20])
|
||||
copy(b[12:16], dst[:])
|
||||
copy(b[16:20], src[:])
|
||||
|
||||
// Set UDP src port
|
||||
binary.BigEndian.PutUint16(b[ihl:ihl+2], newSrcPort)
|
||||
|
||||
// Recompute IP checksum
|
||||
b[10], b[11] = 0, 0
|
||||
cs := ipChecksum(b[:ihl])
|
||||
b[10] = byte(cs >> 8)
|
||||
b[11] = byte(cs & 0xff)
|
||||
|
||||
// Recompute UDP checksum
|
||||
udpLen := int(binary.BigEndian.Uint16(b[ihl+4 : ihl+6]))
|
||||
if ihl+udpLen > len(b) {
|
||||
udpLen = len(b) - ihl
|
||||
}
|
||||
b[ihl+6], b[ihl+7] = 0, 0
|
||||
cs = udpChecksum(b[:ihl], b[ihl:ihl+udpLen])
|
||||
if cs == 0 {
|
||||
cs = 0xFFFF
|
||||
}
|
||||
b[ihl+6] = byte(cs >> 8)
|
||||
b[ihl+7] = byte(cs & 0xff)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildIPv4UDPInbound fabricates an IPv4+UDP packet for reinjection
|
||||
// as inbound (return path from upstream relay → Discord). Used by the
|
||||
// UDPProxy after the SOCKS5 relay sends back a response: we construct
|
||||
// a synthetic packet that looks like remote_endpoint → local_IP and
|
||||
// reinject it via WinDivert with addr.Outbound=0.
|
||||
//
|
||||
// src → original Discord destination (the UDP server)
|
||||
// dst → local LAN IP we bound on
|
||||
// srcPort → original destination port (e.g. 50007)
|
||||
// dstPort → Discord's ephemeral src port (so the kernel matches the
|
||||
// connect()-bound socket)
|
||||
//
|
||||
// The returned slice owns its own backing storage; callers may pass
|
||||
// it directly to (*Handle).Send.
|
||||
func BuildIPv4UDPInbound(srcIP, dstIP net.IP, srcPort, dstPort uint16, payload []byte) ([]byte, error) {
|
||||
src := srcIP.To4()
|
||||
dst := dstIP.To4()
|
||||
if src == nil || dst == nil {
|
||||
return nil, errors.New("BuildIPv4UDPInbound: src/dst must be IPv4")
|
||||
}
|
||||
if len(payload)+28 > 0xFFFF {
|
||||
return nil, errors.New("BuildIPv4UDPInbound: payload too large for IPv4 datagram")
|
||||
}
|
||||
|
||||
totalLen := 20 + 8 + len(payload)
|
||||
buf := make([]byte, totalLen)
|
||||
|
||||
// IPv4 header (20 bytes, IHL=5, no options)
|
||||
buf[0] = 0x45 // version=4, IHL=5
|
||||
buf[1] = 0x00 // DSCP/ECN
|
||||
binary.BigEndian.PutUint16(buf[2:4], uint16(totalLen))
|
||||
binary.BigEndian.PutUint16(buf[4:6], 0) // ID
|
||||
binary.BigEndian.PutUint16(buf[6:8], 0) // flags + frag
|
||||
buf[8] = 64 // TTL
|
||||
buf[9] = 17 // protocol = UDP
|
||||
// checksum at [10..11] left zero for now
|
||||
copy(buf[12:16], src)
|
||||
copy(buf[16:20], dst)
|
||||
|
||||
// UDP header (8 bytes)
|
||||
binary.BigEndian.PutUint16(buf[20:22], srcPort)
|
||||
binary.BigEndian.PutUint16(buf[22:24], dstPort)
|
||||
binary.BigEndian.PutUint16(buf[24:26], uint16(8+len(payload))) // UDP length
|
||||
// UDP checksum at [26..27] left zero for now
|
||||
|
||||
// Payload
|
||||
copy(buf[28:], payload)
|
||||
|
||||
// Recompute IP checksum
|
||||
cs := ipChecksum(buf[:20])
|
||||
buf[10] = byte(cs >> 8)
|
||||
buf[11] = byte(cs & 0xff)
|
||||
|
||||
// Recompute UDP checksum (over pseudo-header + UDP segment)
|
||||
cs = udpChecksum(buf[:20], buf[20:])
|
||||
if cs == 0 {
|
||||
cs = 0xFFFF // RFC 768: 0 means "checksum disabled", send 0xFFFF instead
|
||||
}
|
||||
buf[26] = byte(cs >> 8)
|
||||
buf[27] = byte(cs & 0xff)
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// udpChecksum implements the RFC 768 pseudo-header checksum for IPv4
|
||||
// UDP. ipHdr must include src+dst addresses; udpSeg is the full UDP
|
||||
// header + payload (UDP "length" field already set; checksum field
|
||||
// inside udpSeg must be zeroed).
|
||||
//
|
||||
// IPv4 UDP checksum is technically OPTIONAL — a sender may transmit
|
||||
// 0 to indicate "no checksum". We always compute one since most
|
||||
// modern stacks (and Discord) expect a valid checksum.
|
||||
func udpChecksum(ipHdr, udpSeg []byte) uint16 {
|
||||
var sum uint32
|
||||
// Pseudo-header: src(4) dst(4) zero(1) proto(1) udp_len(2)
|
||||
for i := 12; i <= 18; i += 2 {
|
||||
sum += uint32(ipHdr[i])<<8 | uint32(ipHdr[i+1])
|
||||
}
|
||||
sum += uint32(17) // UDP protocol
|
||||
udpLen := uint32(len(udpSeg))
|
||||
sum += udpLen
|
||||
// UDP segment (header + payload)
|
||||
for i := 0; i+1 < len(udpSeg); i += 2 {
|
||||
sum += uint32(udpSeg[i])<<8 | uint32(udpSeg[i+1])
|
||||
}
|
||||
if len(udpSeg)%2 == 1 {
|
||||
sum += uint32(udpSeg[len(udpSeg)-1]) << 8
|
||||
}
|
||||
for sum>>16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package divert
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
@@ -112,3 +113,148 @@ func TestParseIPv4TCP_Errors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// helloUDP is a minimum well-formed IPv4 + UDP datagram:
|
||||
//
|
||||
// src=10.0.0.1:54321 dst=1.2.3.4:443 payload=4 bytes ABCD
|
||||
//
|
||||
// Total length: 20(IP) + 8(UDP) + 4(payload) = 32 bytes.
|
||||
var helloUDP = []byte{
|
||||
// IPv4 header (20 bytes, IHL=5)
|
||||
0x45, 0x00, 0x00, 0x20, 0xab, 0xcd, 0x40, 0x00, 0x40, 0x11, // proto=17 (UDP)
|
||||
0x00, 0x00, // checksum placeholder
|
||||
0x0a, 0x00, 0x00, 0x01, // src 10.0.0.1
|
||||
0x01, 0x02, 0x03, 0x04, // dst 1.2.3.4
|
||||
// UDP header (8 bytes)
|
||||
0xd4, 0x31, 0x01, 0xbb, // src=54321 dst=443
|
||||
0x00, 0x0c, // length=12 (UDP header + 4 payload)
|
||||
0x00, 0x00, // checksum placeholder
|
||||
// Payload (4 bytes)
|
||||
'A', 'B', 'C', 'D',
|
||||
}
|
||||
|
||||
func fillUDPTestChecksums(b []byte) {
|
||||
// IP checksum
|
||||
b[10], b[11] = 0, 0
|
||||
cs := ipChecksum(b[:20])
|
||||
b[10] = byte(cs >> 8)
|
||||
b[11] = byte(cs & 0xff)
|
||||
// UDP checksum (covers UDP header + payload + pseudo-header)
|
||||
udpLen := int(binary.BigEndian.Uint16(b[24:26]))
|
||||
b[26], b[27] = 0, 0
|
||||
cs = udpChecksum(b[:20], b[20:20+udpLen])
|
||||
if cs == 0 {
|
||||
cs = 0xFFFF
|
||||
}
|
||||
b[26] = byte(cs >> 8)
|
||||
b[27] = byte(cs & 0xff)
|
||||
}
|
||||
|
||||
func TestParseIPv4UDP_Roundtrip(t *testing.T) {
|
||||
pkt := make([]byte, len(helloUDP))
|
||||
copy(pkt, helloUDP)
|
||||
fillUDPTestChecksums(pkt)
|
||||
|
||||
p, err := ParseIPv4UDP(pkt)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "10.0.0.1", p.SrcIP.String())
|
||||
assert.Equal(t, "1.2.3.4", p.DstIP.String())
|
||||
assert.Equal(t, uint16(54321), p.SrcPort)
|
||||
assert.Equal(t, uint16(443), p.DstPort)
|
||||
assert.Equal(t, 20, p.IHL)
|
||||
assert.Equal(t, uint16(12), p.UDPLen)
|
||||
}
|
||||
|
||||
func TestParseIPv4UDP_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
b []byte
|
||||
}{
|
||||
{"too_short", []byte{0x45}},
|
||||
{"not_ipv4", []byte{0x60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
|
||||
{"not_udp", []byte{0x45, 0, 0, 20, 0, 0, 0, 0, 0, 6, /* TCP */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := ParseIPv4UDP(c.b)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapUDPAndSetDstPort(t *testing.T) {
|
||||
pkt := make([]byte, len(helloUDP))
|
||||
copy(pkt, helloUDP)
|
||||
fillUDPTestChecksums(pkt)
|
||||
|
||||
require.NoError(t, SwapUDPAndSetDstPort(pkt, 8080))
|
||||
|
||||
p, err := ParseIPv4UDP(pkt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1.2.3.4", p.SrcIP.String(), "src should be original dst after swap")
|
||||
assert.Equal(t, "10.0.0.1", p.DstIP.String(), "dst should be original src after swap")
|
||||
assert.Equal(t, uint16(54321), p.SrcPort, "src port unchanged")
|
||||
assert.Equal(t, uint16(8080), p.DstPort, "dst port set to new value")
|
||||
|
||||
// Validate IP checksum recomputed
|
||||
ipCs := uint16(pkt[10])<<8 | uint16(pkt[11])
|
||||
pkt[10], pkt[11] = 0, 0
|
||||
expIP := ipChecksum(pkt[:20])
|
||||
assert.Equal(t, expIP, ipCs, "IP checksum mismatch")
|
||||
}
|
||||
|
||||
func TestSwapUDPAndSetSrcPort(t *testing.T) {
|
||||
pkt := make([]byte, len(helloUDP))
|
||||
copy(pkt, helloUDP)
|
||||
fillUDPTestChecksums(pkt)
|
||||
|
||||
require.NoError(t, SwapUDPAndSetSrcPort(pkt, 50007))
|
||||
|
||||
p, err := ParseIPv4UDP(pkt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1.2.3.4", p.SrcIP.String())
|
||||
assert.Equal(t, "10.0.0.1", p.DstIP.String())
|
||||
assert.Equal(t, uint16(50007), p.SrcPort, "src port set to new value")
|
||||
assert.Equal(t, uint16(443), p.DstPort, "dst port unchanged")
|
||||
}
|
||||
|
||||
func TestBuildIPv4UDPInbound(t *testing.T) {
|
||||
src := net.IPv4(140, 82, 121, 4) // GitHub IP, just for variety
|
||||
dst := net.IPv4(192, 168, 1, 50) // local LAN
|
||||
payload := []byte("hello voice")
|
||||
|
||||
pkt, err := BuildIPv4UDPInbound(src, dst, 50007, 50100, payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Total length: 20+8+11 = 39
|
||||
assert.Len(t, pkt, 39)
|
||||
|
||||
// Re-parse and verify fields
|
||||
p, err := ParseIPv4UDP(pkt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "140.82.121.4", p.SrcIP.String())
|
||||
assert.Equal(t, "192.168.1.50", p.DstIP.String())
|
||||
assert.Equal(t, uint16(50007), p.SrcPort)
|
||||
assert.Equal(t, uint16(50100), p.DstPort)
|
||||
assert.Equal(t, uint16(8+len(payload)), p.UDPLen)
|
||||
|
||||
// Payload after headers
|
||||
assert.Equal(t, payload, pkt[28:])
|
||||
|
||||
// IP checksum valid: clearing + recomputing should match
|
||||
ipCs := uint16(pkt[10])<<8 | uint16(pkt[11])
|
||||
pkt[10], pkt[11] = 0, 0
|
||||
expIP := ipChecksum(pkt[:20])
|
||||
assert.Equal(t, expIP, ipCs, "IP checksum should be valid")
|
||||
|
||||
// UDP checksum valid (and non-zero)
|
||||
udpCs := uint16(pkt[26])<<8 | uint16(pkt[27])
|
||||
assert.NotEqual(t, uint16(0), udpCs, "UDP checksum should be non-zero (RFC 768 trick)")
|
||||
}
|
||||
|
||||
func TestBuildIPv4UDPInbound_NotIPv4(t *testing.T) {
|
||||
v6 := net.ParseIP("::1")
|
||||
_, err := BuildIPv4UDPInbound(v6, net.IPv4(1, 2, 3, 4), 1, 2, []byte("x"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
+423
-45
@@ -6,8 +6,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -36,12 +38,47 @@ type Engine struct {
|
||||
|
||||
// runtime state
|
||||
upstreamIP net.IP
|
||||
handle *divert.Handle
|
||||
localIP net.IP // primary outbound LAN IP — listener binds here so reinjected NAT'd packets reach it (kernel drops src=LAN/dst=127.0.0.1 as spoofed)
|
||||
handleMu sync.RWMutex // guards handle + flowH swap during procscan rebuild
|
||||
handle *divert.Handle // NETWORK layer: capture/rewrite/reinject packets
|
||||
flowH *divert.Handle // FLOW layer: capture-ALL events (filter "true"); we filter by PID in-process
|
||||
redir *redirect.Redirector
|
||||
udp *redirect.UDPProxy // SOCKS5 UDP relay manager — handles Discord voice etc.
|
||||
ctx context.Context
|
||||
cnl context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
ownPID uint32
|
||||
|
||||
// pidMu guards targetPIDs. Updated by procscanLoop, read by flowLoop
|
||||
// for every event. Read frequency: ~50 events/sec average; write:
|
||||
// every 2s. RWMutex contention negligible.
|
||||
pidMu sync.RWMutex
|
||||
targetPIDs map[uint32]struct{}
|
||||
|
||||
// flowSet tracks 5-tuples currently belonging to target processes.
|
||||
// Populated by flowLoop on EventFlowEstablished, removed on
|
||||
// EventFlowDeleted. Read by diverterLoop on every captured packet
|
||||
// to decide whether to redirect or pass through.
|
||||
flowMu sync.RWMutex
|
||||
flowSet map[flowKey]struct{}
|
||||
}
|
||||
|
||||
// flowKey identifies a flow by its 5-tuple. Drover uses local→remote
|
||||
// (i.e. always the outbound direction) — the flow handle reports
|
||||
// LocalAddr/Port and RemoteAddr/Port which match an outbound packet's
|
||||
// SrcAddr/Port and DstAddr/Port.
|
||||
// flowKey identifies a tracked flow. We deliberately omit SrcIP from
|
||||
// the key: when Discord (or any client) binds a UDP/TCP socket to
|
||||
// INADDR_ANY (0.0.0.0), the SOCKET layer reports src=0.0.0.0, but the
|
||||
// actual outbound packet has src=<local_LAN_IP> (kernel fills the
|
||||
// interface address). Including src in the key would cause those
|
||||
// flows to miss the lookup. Source port + destination + proto is a
|
||||
// sufficient discriminator on a single host.
|
||||
type flowKey struct {
|
||||
dst [4]byte
|
||||
sport uint16
|
||||
dport uint16
|
||||
proto uint8 // 6=TCP, 17=UDP
|
||||
}
|
||||
|
||||
// New constructs an engine. No I/O yet.
|
||||
@@ -50,9 +87,11 @@ func New(cfg Config) (*Engine, error) {
|
||||
return nil, errors.New("ProxyAddr is required")
|
||||
}
|
||||
return &Engine{
|
||||
cfg: cfg,
|
||||
status: StatusIdle,
|
||||
ownPID: uint32(os.Getpid()),
|
||||
cfg: cfg,
|
||||
status: StatusIdle,
|
||||
ownPID: uint32(os.Getpid()),
|
||||
flowSet: map[flowKey]struct{}{},
|
||||
targetPIDs: map[uint32]struct{}{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -107,15 +146,19 @@ func (e *Engine) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (e *Engine) bringUp(ctx context.Context) error {
|
||||
log.Printf("engine: bringUp start cfg.ProxyAddr=%q targets=%v", e.cfg.ProxyAddr, e.cfg.Targets)
|
||||
|
||||
// 1. Resolve upstream
|
||||
host, _, err := net.SplitHostPort(e.cfg.ProxyAddr)
|
||||
if err != nil {
|
||||
log.Printf("engine: SplitHostPort failed: %v", err)
|
||||
return fmt.Errorf("invalid ProxyAddr: %w", err)
|
||||
}
|
||||
rctx, rcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer rcancel()
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(rctx, host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
log.Printf("engine: LookupIPAddr(%q) failed: %v (ips=%v)", host, err, ips)
|
||||
return fmt.Errorf("resolve proxy host %q: %w", host, err)
|
||||
}
|
||||
var upstream net.IP
|
||||
@@ -126,26 +169,50 @@ func (e *Engine) bringUp(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
if upstream == nil {
|
||||
log.Printf("engine: no IPv4 for %q (got %v)", host, ips)
|
||||
return fmt.Errorf("no IPv4 for %q", host)
|
||||
}
|
||||
e.upstreamIP = upstream
|
||||
log.Printf("engine: upstream resolved %s → %s", host, upstream)
|
||||
|
||||
// 1b. Detect outbound LAN IP — listener binds here. Trick:
|
||||
// open a UDP "connect" to any external IP; kernel picks the
|
||||
// outbound interface and we read LocalAddr off the conn.
|
||||
if udpConn, dErr := net.Dial("udp", "8.8.8.8:53"); dErr == nil {
|
||||
e.localIP = udpConn.LocalAddr().(*net.UDPAddr).IP.To4()
|
||||
udpConn.Close()
|
||||
}
|
||||
if e.localIP == nil {
|
||||
log.Printf("engine: could not detect local LAN IP — falling back to 127.0.0.1 (may not work)")
|
||||
e.localIP = net.IPv4(127, 0, 0, 1)
|
||||
}
|
||||
log.Printf("engine: local LAN IP = %s", e.localIP)
|
||||
|
||||
// 2. Driver install (idempotent)
|
||||
if _, err := divert.InstallDriver(); err != nil {
|
||||
paths, err := divert.InstallDriver()
|
||||
if err != nil {
|
||||
log.Printf("engine: InstallDriver failed: %v", err)
|
||||
return fmt.Errorf("install driver: %w", err)
|
||||
}
|
||||
log.Printf("engine: driver installed sys=%s dll=%s", paths.SysPath, paths.DllPath)
|
||||
|
||||
// 3. Initial procscan
|
||||
pids, err := procscan.Snapshot(e.cfg.Targets)
|
||||
if err != nil {
|
||||
log.Printf("engine: procscan.Snapshot failed: %v", err)
|
||||
return fmt.Errorf("procscan: %w", err)
|
||||
}
|
||||
pidList := make([]uint32, 0, len(pids))
|
||||
for p := range pids {
|
||||
pidList = append(pidList, p)
|
||||
}
|
||||
log.Printf("engine: initial procscan found %d target pids: %v", len(pidList), pids)
|
||||
|
||||
// 4. Open redirector listener
|
||||
// 4. Open redirector listener on 0.0.0.0 so it accepts on any
|
||||
// interface (including the LAN IP we'll target with the swap-and-
|
||||
// reinject NAT pattern). After the streamdump swap the packet has
|
||||
// dst=LAN_IP:listener_port — kernel delivers via inbound path of
|
||||
// the LAN interface; listener accepts it as a regular TCP conn.
|
||||
r, err := redirect.New(redirect.Config{
|
||||
SOCKS5: socks5.Config{
|
||||
ProxyAddr: e.cfg.ProxyAddr,
|
||||
@@ -153,34 +220,111 @@ func (e *Engine) bringUp(ctx context.Context) error {
|
||||
Login: e.cfg.Login,
|
||||
Password: e.cfg.Password,
|
||||
},
|
||||
Bind: "127.0.0.1:0",
|
||||
Bind: "0.0.0.0:0",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("engine: redirect.New failed: %v", err)
|
||||
return fmt.Errorf("redirector: %w", err)
|
||||
}
|
||||
e.redir = r
|
||||
log.Printf("engine: redirector listening on %s", r.LocalAddr())
|
||||
|
||||
// 5. Build filter + open handle
|
||||
filter := divert.BuildFilter(divert.FilterParams{
|
||||
// 5. Build filters
|
||||
// SOCKET handle uses "true" — capture ALL socket events. We
|
||||
// filter by PID in-process. SOCKET layer fires Connect events
|
||||
// SYNCHRONOUSLY with the connect() syscall, BEFORE the SYN packet
|
||||
// leaves the box — which gives socketLoop time to populate the
|
||||
// redirector mapping before NETWORK-layer SYN arrives.
|
||||
//
|
||||
// (FLOW Established events fire after the 3-way handshake, which
|
||||
// is too late for our SYN-redirect plan — by then the conn is
|
||||
// already pointing at the real target.)
|
||||
netFilter := divert.BuildNetworkFilter(divert.FilterParams{
|
||||
TargetPIDs: pidList,
|
||||
OwnPID: e.ownPID,
|
||||
UpstreamIP: upstream.String(),
|
||||
LocalIP: e.localIP.String(),
|
||||
})
|
||||
h, err := divert.Open(filter)
|
||||
if err != nil {
|
||||
r.Close()
|
||||
return fmt.Errorf("WinDivert open: %w", err)
|
||||
}
|
||||
e.handle = h
|
||||
log.Printf("engine: socket filter: \"true\" (capture-all, PID-filter in-process)")
|
||||
log.Printf("engine: network filter: %s", netFilter)
|
||||
|
||||
// 6. Spawn divert reader + procscan ticker
|
||||
// Seed targetPIDs from initial procscan
|
||||
e.pidMu.Lock()
|
||||
for p := range pids {
|
||||
e.targetPIDs[p] = struct{}{}
|
||||
}
|
||||
e.pidMu.Unlock()
|
||||
|
||||
// 6. Open SOCKET handle FIRST with broad filter so we never miss
|
||||
// a new connection between procscan ticks. socketLoop discards
|
||||
// events from non-target PIDs in-process.
|
||||
flowH, err := divert.OpenSocket("true")
|
||||
if err != nil {
|
||||
log.Printf("engine: divert.OpenSocket failed: %v", err)
|
||||
r.Close()
|
||||
return fmt.Errorf("WinDivert socket open: %w", err)
|
||||
}
|
||||
e.flowH = flowH
|
||||
log.Printf("engine: WinDivert SOCKET handle opened (filter=\"true\")")
|
||||
|
||||
// 7. Open NETWORK handle for actual packet capture/redirect.
|
||||
netH, err := divert.Open(netFilter)
|
||||
if err != nil {
|
||||
log.Printf("engine: divert.Open(network) failed: %v", err)
|
||||
flowH.Close()
|
||||
r.Close()
|
||||
return fmt.Errorf("WinDivert network open: %w", err)
|
||||
}
|
||||
e.handle = netH
|
||||
log.Printf("engine: WinDivert NETWORK handle opened")
|
||||
|
||||
// 7b. UDP proxy. The SOCKS5 UDP ASSOCIATE control conn is opened
|
||||
// lazily on the first UDP packet from a target, so this New call
|
||||
// is non-blocking — no upstream I/O happens here.
|
||||
udpProxy, err := redirect.NewUDP(redirect.UDPConfig{
|
||||
SOCKS5: socks5.Config{
|
||||
ProxyAddr: e.cfg.ProxyAddr,
|
||||
UseAuth: e.cfg.UseAuth,
|
||||
Login: e.cfg.Login,
|
||||
Password: e.cfg.Password,
|
||||
},
|
||||
LocalIP: e.localIP,
|
||||
Injector: divertHandleInjector{h: netH},
|
||||
LogPrefix: "engine udp: ",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("engine: redirect.NewUDP failed: %v", err)
|
||||
netH.Close()
|
||||
flowH.Close()
|
||||
r.Close()
|
||||
return fmt.Errorf("udp proxy: %w", err)
|
||||
}
|
||||
e.udp = udpProxy
|
||||
log.Printf("engine: UDP proxy ready (lazy SOCKS5 ASSOCIATE)")
|
||||
|
||||
// 8. Spawn socket tracker + divert reader + procscan ticker
|
||||
e.ctx, e.cnl = context.WithCancel(context.Background())
|
||||
e.wg.Add(2)
|
||||
e.wg.Add(3)
|
||||
go e.socketLoop()
|
||||
go e.diverterLoop()
|
||||
go e.procscanLoop()
|
||||
log.Printf("engine: bringUp complete, transitioning to Active")
|
||||
return nil
|
||||
}
|
||||
|
||||
// divertHandleInjector adapts *divert.Handle to redirect.UDPInjector.
|
||||
// We expose Send through the SendInjectInbound helper which sets the
|
||||
// right WinDivert flags for fabricated inbound packets.
|
||||
type divertHandleInjector struct {
|
||||
h *divert.Handle
|
||||
}
|
||||
|
||||
func (d divertHandleInjector) Send(buf []byte, _ redirect.UDPInjectAddr) (int, error) {
|
||||
// We only ever inject UDP via this path (TCP path uses the
|
||||
// captured addr directly in diverterLoop).
|
||||
return d.h.SendInjectInbound(buf, true /* isUDP */)
|
||||
}
|
||||
|
||||
// Stop tears down. Always returns to Idle (or stays in Idle if
|
||||
// already there).
|
||||
func (e *Engine) Stop() error {
|
||||
@@ -197,49 +341,280 @@ func (e *Engine) Stop() error {
|
||||
if e.handle != nil {
|
||||
e.handle.Close()
|
||||
}
|
||||
if e.flowH != nil {
|
||||
e.flowH.Close()
|
||||
}
|
||||
if e.udp != nil {
|
||||
e.udp.Close()
|
||||
}
|
||||
if e.redir != nil {
|
||||
e.redir.Close()
|
||||
}
|
||||
e.wg.Wait()
|
||||
e.handle = nil
|
||||
e.flowH = nil
|
||||
e.redir = nil
|
||||
e.udp = nil
|
||||
e.transition(StatusIdle, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// socketLoop reads socket-layer events (Connect/Close) from the
|
||||
// SOCKET handle and maintains e.flowSet + the redirector mapping.
|
||||
//
|
||||
// SOCKET Connect fires synchronously with the connect() syscall on
|
||||
// the originating thread, BEFORE the SYN packet is dispatched. This
|
||||
// is the critical window: by populating flowSet+mapping in this
|
||||
// handler, the diverterLoop's NETWORK capture of the SYN finds the
|
||||
// target on first lookup and redirects correctly.
|
||||
func (e *Engine) socketLoop() {
|
||||
defer e.wg.Done()
|
||||
log.Printf("engine: socketLoop started")
|
||||
iter := 0
|
||||
for {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
log.Printf("engine: socketLoop ctx done after %d iterations", iter)
|
||||
return
|
||||
default:
|
||||
}
|
||||
iter++
|
||||
e.handleMu.RLock()
|
||||
h := e.flowH
|
||||
e.handleMu.RUnlock()
|
||||
if h == nil {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
ev, err := h.RecvSocket()
|
||||
if err != nil {
|
||||
if errors.Is(err, divert.ErrShutdown) || errors.Is(err, divert.ErrInvalidHandle) {
|
||||
log.Printf("engine: socketLoop terminal error after %d iterations: %v", iter, err)
|
||||
return
|
||||
}
|
||||
log.Printf("engine: socketLoop transient error (iter %d): %v", iter, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
// In-process PID filter. Fast path: PID is in the set procscan
|
||||
// fed us. Slow path: PID isn't yet known (Update.exe spawn →
|
||||
// connect → exit routinely fits inside the 2-second procscan
|
||||
// tick), so resolve PID → exe name on demand and admit it if
|
||||
// the name matches our Targets list. This is what makes
|
||||
// "Checking for updates" finish in ~5 s instead of 30+.
|
||||
e.pidMu.RLock()
|
||||
_, isTarget := e.targetPIDs[ev.ProcessID]
|
||||
e.pidMu.RUnlock()
|
||||
if !isTarget {
|
||||
if name, err := procscan.ResolvePID(ev.ProcessID); err == nil {
|
||||
lname := strings.ToLower(name)
|
||||
for _, t := range e.cfg.Targets {
|
||||
if strings.EqualFold(t, lname) || strings.EqualFold(t, name) {
|
||||
isTarget = true
|
||||
e.pidMu.Lock()
|
||||
e.targetPIDs[ev.ProcessID] = struct{}{}
|
||||
e.pidMu.Unlock()
|
||||
log.Printf("engine: lazy-admit pid=%d name=%s (matched target)", ev.ProcessID, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isTarget {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch ev.Kind {
|
||||
case divert.SocketKindConnect:
|
||||
// Connect fires before SYN. Populate redirector mapping +
|
||||
// flowSet so when SYN arrives at NETWORK layer the
|
||||
// diverterLoop knows to redirect.
|
||||
key := flowKey{
|
||||
dst: ev.DstAddr,
|
||||
sport: ev.SrcPort,
|
||||
dport: ev.DstPort,
|
||||
proto: ev.Protocol,
|
||||
}
|
||||
e.flowMu.Lock()
|
||||
e.flowSet[key] = struct{}{}
|
||||
setSize := len(e.flowSet)
|
||||
e.flowMu.Unlock()
|
||||
e.redir.SetMapping(ev.SrcPort, net.IPv4(ev.DstAddr[0], ev.DstAddr[1], ev.DstAddr[2], ev.DstAddr[3]), ev.DstPort)
|
||||
log.Printf("engine: socket connect pid=%d proto=%d %v:%d → %v:%d (set size=%d)",
|
||||
ev.ProcessID, ev.Protocol, ev.SrcAddr, ev.SrcPort, ev.DstAddr, ev.DstPort, setSize)
|
||||
case divert.SocketKindClose:
|
||||
key := flowKey{
|
||||
dst: ev.DstAddr,
|
||||
sport: ev.SrcPort,
|
||||
dport: ev.DstPort,
|
||||
proto: ev.Protocol,
|
||||
}
|
||||
e.flowMu.Lock()
|
||||
delete(e.flowSet, key)
|
||||
e.flowMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) diverterLoop() {
|
||||
defer e.wg.Done()
|
||||
log.Printf("engine: diverterLoop started")
|
||||
buf := make([]byte, 65536)
|
||||
listenerPort := e.redir.LocalPort()
|
||||
var rxCount, redirCount int64
|
||||
statusTk := time.NewTicker(5 * time.Second)
|
||||
defer statusTk.Stop()
|
||||
go func() {
|
||||
for range statusTk.C {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
var udpFwd, udpFwdBytes, udpRecv, udpInj uint64
|
||||
if e.udp != nil {
|
||||
udpFwd, udpFwdBytes, udpRecv, udpInj = e.udp.Stats()
|
||||
}
|
||||
log.Printf("engine: diverter stats rx=%d tcpRedir=%d flowSet=%d | UDP fwd=%d/%dB recv=%d injected=%d",
|
||||
rxCount, redirCount, len(e.flowSet), udpFwd, udpFwdBytes, udpRecv, udpInj)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
n, addr, err := e.handle.Recv(buf)
|
||||
e.handleMu.RLock()
|
||||
h := e.handle
|
||||
e.handleMu.RUnlock()
|
||||
if h == nil {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
n, addr, err := h.Recv(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, divert.ErrShutdown) || errors.Is(err, divert.ErrInvalidHandle) {
|
||||
log.Printf("engine: diverterLoop terminal error after %d rx: %v", rxCount, err)
|
||||
e.transition(StatusFailed, err)
|
||||
return
|
||||
}
|
||||
log.Printf("engine: diverterLoop transient Recv error: %v", err)
|
||||
continue
|
||||
}
|
||||
// Parse + record + rewrite
|
||||
rxCount++
|
||||
|
||||
// === UDP fast path ===
|
||||
// Quick header sniff: if proto=17 (UDP), try the UDP-flow
|
||||
// branch. Target UDP flows are forwarded through the SOCKS5
|
||||
// UDP relay (consumed — NOT reinjected); non-target UDP is
|
||||
// passed through unmodified.
|
||||
if n >= 10 && buf[0]>>4 == 4 && buf[9] == 17 {
|
||||
udpInfo, uerr := divert.ParseIPv4UDP(buf[:n])
|
||||
if uerr == nil {
|
||||
var ukey flowKey
|
||||
copy(ukey.dst[:], udpInfo.DstIP.To4())
|
||||
ukey.sport = udpInfo.SrcPort
|
||||
ukey.dport = udpInfo.DstPort
|
||||
ukey.proto = 17
|
||||
|
||||
e.flowMu.RLock()
|
||||
_, isUDPTarget := e.flowSet[ukey]
|
||||
e.flowMu.RUnlock()
|
||||
|
||||
if isUDPTarget && e.udp != nil {
|
||||
// Strip IPv4 + UDP headers; the rest is application
|
||||
// payload that we hand to the SOCKS5 UDP relay.
|
||||
payload := buf[udpInfo.IHL+8 : n]
|
||||
if ferr := e.udp.Forward(udpInfo.SrcIP, udpInfo.SrcPort, udpInfo.DstIP, udpInfo.DstPort, payload); ferr != nil {
|
||||
log.Printf("engine: udp forward error %v:%d → %v:%d: %v",
|
||||
udpInfo.SrcIP, udpInfo.SrcPort, udpInfo.DstIP, udpInfo.DstPort, ferr)
|
||||
// Drop on error — UDP loss is acceptable.
|
||||
} else {
|
||||
redirCount++
|
||||
}
|
||||
// Consumed: do NOT reinject — relay reader will
|
||||
// fabricate the inbound reply.
|
||||
continue
|
||||
}
|
||||
// Non-target UDP: pass through unmodified.
|
||||
_, _ = h.Send(buf[:n], addr)
|
||||
continue
|
||||
}
|
||||
// Malformed UDP — fall through to TCP parse path (which
|
||||
// will also fail and reinject).
|
||||
}
|
||||
|
||||
// Parse + decide
|
||||
info, err := divert.ParseIPv4TCP(buf[:n])
|
||||
if err != nil {
|
||||
// Not parseable — reinject as-is.
|
||||
_, _ = e.handle.Send(buf[:n], addr)
|
||||
// Not IPv4-TCP and not handled by UDP path above. Reinject
|
||||
// as-is so non-target traffic continues normally.
|
||||
_, _ = h.Send(buf[:n], addr)
|
||||
continue
|
||||
}
|
||||
// SYN packets don't carry the full flow yet — but every
|
||||
// outbound TCP carries src_port we can map. We always record
|
||||
// the latest mapping, refreshing TTL on subsequent packets.
|
||||
e.redir.SetMapping(info.SrcPort, info.DstIP, info.DstPort)
|
||||
|
||||
// Rewrite to loopback
|
||||
if err := divert.RewriteDst(buf[:n], net.IPv4(127, 0, 0, 1), listenerPort); err == nil {
|
||||
_, _ = e.handle.Send(buf[:n], addr)
|
||||
localV4 := e.localIP.To4()
|
||||
srcV4 := info.SrcIP.To4()
|
||||
|
||||
// === RETURN path === : packet emitted by our listener back to
|
||||
// the client. SrcIP=local LAN, SrcPort=listener_port. We swap
|
||||
// IPs and rewrite SrcPort to the original target port so the
|
||||
// client (Discord) sees a response that matches its connect()
|
||||
// pair (src=real_target:real_port, dst=local_IP:client_eph).
|
||||
if info.SrcPort == listenerPort && srcV4 != nil && srcV4.Equal(localV4) {
|
||||
realIP, realPort, ok := e.redir.GetMapping(info.DstPort)
|
||||
if !ok {
|
||||
// No mapping — should be rare (TTL evicted?). Drop by
|
||||
// reinjecting as-is; client will retransmit.
|
||||
_, _ = h.Send(buf[:n], addr)
|
||||
continue
|
||||
}
|
||||
_ = realIP // streamdump only needs the original target PORT;
|
||||
// the IP is already the right one after the swap below
|
||||
// (we swap dst/src — original dst (=local) becomes src,
|
||||
// original src (=client_local) becomes dst). The original
|
||||
// remote IP is not on this packet — it's listener→client,
|
||||
// not listener→remote. So srcIP after swap = info.DstIP =
|
||||
// real Discord IP because... actually no — our SrcIP IS
|
||||
// local, our DstIP IS Discord. After swap our SrcIP =
|
||||
// Discord, DstIP = local. That's exactly what we want.
|
||||
if err := divert.SwapAndSetSrcPort(buf[:n], realPort); err == nil {
|
||||
addr.Flags &^= 0x02 // clear Outbound (deliver as inbound)
|
||||
addr.Flags |= 0x60 // signal IP+TCP checksums valid
|
||||
_, _ = h.Send(buf[:n], addr)
|
||||
redirCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// === FORWARD path === : packet from a target process to a
|
||||
// remote. Apply streamdump swap so the kernel delivers it to
|
||||
// our listener via the inbound path.
|
||||
var key flowKey
|
||||
copy(key.dst[:], info.DstIP.To4())
|
||||
key.sport = info.SrcPort
|
||||
key.dport = info.DstPort
|
||||
key.proto = 6
|
||||
|
||||
e.flowMu.RLock()
|
||||
_, isTarget := e.flowSet[key]
|
||||
e.flowMu.RUnlock()
|
||||
if !isTarget {
|
||||
_, _ = h.Send(buf[:n], addr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Target flow: refresh redirector mapping and apply the
|
||||
// canonical streamdump swap (swap src↔dst, dst.port=listener,
|
||||
// addr.Outbound=0, mark checksums valid).
|
||||
e.redir.SetMapping(info.SrcPort, info.DstIP, info.DstPort)
|
||||
if err := divert.SwapAndSetDstPort(buf[:n], listenerPort); err == nil {
|
||||
addr.Flags &^= 0x02 // clear Outbound (deliver as inbound)
|
||||
addr.Flags |= 0x60 // signal IP+TCP checksums valid
|
||||
_, _ = h.Send(buf[:n], addr)
|
||||
redirCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,25 +639,28 @@ func (e *Engine) procscanLoop() {
|
||||
if len(add) == 0 && len(rem) == 0 {
|
||||
continue
|
||||
}
|
||||
// Rebuild filter + reopen handle
|
||||
pidList := make([]uint32, 0, len(cur))
|
||||
for p := range cur {
|
||||
pidList = append(pidList, p)
|
||||
log.Printf("engine: procscan delta added=%v removed=%v", add, rem)
|
||||
|
||||
// Update targetPIDs map. flowLoop reads it on every event;
|
||||
// no handle reopen needed (FLOW filter is "true").
|
||||
e.pidMu.Lock()
|
||||
for _, p := range add {
|
||||
e.targetPIDs[p] = struct{}{}
|
||||
}
|
||||
filter := divert.BuildFilter(divert.FilterParams{
|
||||
TargetPIDs: pidList,
|
||||
OwnPID: e.ownPID,
|
||||
UpstreamIP: e.upstreamIP.String(),
|
||||
})
|
||||
newH, err := divert.Open(filter)
|
||||
if err != nil {
|
||||
e.transition(StatusFailed, fmt.Errorf("reopen handle on PID change: %w", err))
|
||||
return
|
||||
for _, p := range rem {
|
||||
delete(e.targetPIDs, p)
|
||||
}
|
||||
oldH := e.handle
|
||||
e.handle = newH
|
||||
if oldH != nil {
|
||||
oldH.Close()
|
||||
e.pidMu.Unlock()
|
||||
|
||||
// Drop tracked flows for the removed PIDs. We don't actually
|
||||
// know which flowKey belongs to which PID (we lose that info
|
||||
// after Established → flowSet keyed by 5-tuple, not PID), so
|
||||
// for safety just clear the set when a target PID disappears
|
||||
// — flow events from the new PIDs will repopulate.
|
||||
if len(rem) > 0 {
|
||||
e.flowMu.Lock()
|
||||
e.flowSet = map[flowKey]struct{}{}
|
||||
e.flowMu.Unlock()
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package gui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -175,9 +176,11 @@ func (a *App) CancelCheck() {
|
||||
|
||||
// StartEngine initializes and brings up the engine with the given config.
|
||||
func (a *App) StartEngine(cfg Config) error {
|
||||
log.Printf("gui: StartEngine called host=%s port=%d auth=%v", cfg.Host, cfg.Port, cfg.Auth)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.eng != nil && a.eng.Status() == engine.StatusActive {
|
||||
log.Printf("gui: StartEngine no-op (already active)")
|
||||
return nil
|
||||
}
|
||||
e, err := engine.New(engine.Config{
|
||||
@@ -188,15 +191,18 @@ func (a *App) StartEngine(cfg Config) error {
|
||||
Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("gui: engine.New failed: %v", err)
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
|
||||
return err
|
||||
}
|
||||
if err := e.Start(a.ctx); err != nil {
|
||||
log.Printf("gui: engine.Start failed: %v", err)
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
|
||||
return err
|
||||
}
|
||||
a.eng = e
|
||||
a.startedAt = time.Now()
|
||||
log.Printf("gui: engine started, status=%s", e.Status())
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build windows
|
||||
|
||||
package procscan
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ResolvePID returns the exe basename for a given PID, or an error
|
||||
// if the PID has already exited or we lack the rights to query it.
|
||||
//
|
||||
// Used by the engine's socketLoop to do a lazy lookup when SOCKET
|
||||
// Connect events arrive for processes we haven't yet seen via the
|
||||
// 2-second procscan tick — Update.exe's full lifecycle (spawn →
|
||||
// connect → exit) routinely fits inside one tick window, so without
|
||||
// this lookup the engine would miss its connections entirely and
|
||||
// Discord's "Checking for updates" would hit its 30 s timeout.
|
||||
func ResolvePID(pid uint32) (string, error) {
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("OpenProcess pid=%d: %w", pid, err)
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
|
||||
buf := make([]uint16, windows.MAX_PATH)
|
||||
size := uint32(len(buf))
|
||||
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
|
||||
return "", fmt.Errorf("QueryFullProcessImageName pid=%d: %w", pid, err)
|
||||
}
|
||||
full := syscall.UTF16ToString(buf[:size])
|
||||
return filepath.Base(full), nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package socks5
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AssociateUDP opens a TCP control conn to the upstream SOCKS5 proxy,
|
||||
// runs greeting + (optional) auth + UDP ASSOCIATE (CMD=03), and returns:
|
||||
//
|
||||
// - the relay UDP endpoint (host:port the proxy bound for our datagrams)
|
||||
// - the kept-open control TCP (caller MUST keep open for the lifetime
|
||||
// of the UDP association — closing it tears down the relay on the
|
||||
// proxy side per RFC 1928 §6).
|
||||
//
|
||||
// The given ctx bounds dial + handshake; once AssociateUDP returns,
|
||||
// ctrl has its deadline cleared.
|
||||
//
|
||||
// If the proxy replies BND.ADDR == 0.0.0.0 (some implementations do
|
||||
// this to mean "use the same IP you connected to"), we substitute the
|
||||
// proxy host's resolved IP.
|
||||
func AssociateUDP(ctx context.Context, cfg Config) (relay *net.UDPAddr, ctrl net.Conn, err error) {
|
||||
d := net.Dialer{}
|
||||
conn, err := d.DialContext(ctx, "tcp", cfg.ProxyAddr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dial proxy: %w", err)
|
||||
}
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Greeting (same as TCP CONNECT path)
|
||||
if cfg.UseAuth {
|
||||
if _, werr := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); werr != nil {
|
||||
return nil, nil, fmt.Errorf("greet write: %w", werr)
|
||||
}
|
||||
} else {
|
||||
if _, werr := conn.Write([]byte{0x05, 0x01, 0x00}); werr != nil {
|
||||
return nil, nil, fmt.Errorf("greet write: %w", werr)
|
||||
}
|
||||
}
|
||||
var rep [2]byte
|
||||
if _, rerr := io.ReadFull(conn, rep[:]); rerr != nil {
|
||||
return nil, nil, fmt.Errorf("greet read: %w", rerr)
|
||||
}
|
||||
if rep[0] != 0x05 {
|
||||
return nil, nil, fmt.Errorf("greet: server version %#x is not SOCKS5", rep[0])
|
||||
}
|
||||
if rep[1] == 0xff {
|
||||
return nil, nil, errors.New("greet: proxy rejected all offered auth methods")
|
||||
}
|
||||
method := rep[1]
|
||||
|
||||
if method == 0x02 {
|
||||
if !cfg.UseAuth {
|
||||
return nil, nil, errors.New("proxy requires auth but Config.UseAuth is false")
|
||||
}
|
||||
if len(cfg.Login) > 255 || len(cfg.Password) > 255 {
|
||||
return nil, nil, errors.New("login or password too long")
|
||||
}
|
||||
buf := make([]byte, 0, 3+len(cfg.Login)+len(cfg.Password))
|
||||
buf = append(buf, 0x01, byte(len(cfg.Login)))
|
||||
buf = append(buf, []byte(cfg.Login)...)
|
||||
buf = append(buf, byte(len(cfg.Password)))
|
||||
buf = append(buf, []byte(cfg.Password)...)
|
||||
if _, werr := conn.Write(buf); werr != nil {
|
||||
return nil, nil, fmt.Errorf("auth write: %w", werr)
|
||||
}
|
||||
if _, rerr := io.ReadFull(conn, rep[:]); rerr != nil {
|
||||
return nil, nil, fmt.Errorf("auth read: %w", rerr)
|
||||
}
|
||||
if rep[1] != 0x00 {
|
||||
return nil, nil, errors.New("auth: invalid login or password")
|
||||
}
|
||||
}
|
||||
|
||||
// UDP ASSOCIATE request: VER=05 CMD=03 RSV=00 ATYP=01 DST.ADDR=0.0.0.0 DST.PORT=0
|
||||
req := []byte{0x05, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
if _, werr := conn.Write(req); werr != nil {
|
||||
return nil, nil, fmt.Errorf("udp-associate write: %w", werr)
|
||||
}
|
||||
|
||||
// We accept ATYP=01 (IPv4) replies only — sufficient for our use
|
||||
// case (mihomo + standard proxies). Reading 10 bytes covers exactly
|
||||
// that case: VER REP RSV ATYP BND.ADDR(4) BND.PORT(2).
|
||||
reply := make([]byte, 10)
|
||||
if _, rerr := io.ReadFull(conn, reply); rerr != nil {
|
||||
return nil, nil, fmt.Errorf("udp-associate read: %w", rerr)
|
||||
}
|
||||
if reply[0] != 0x05 {
|
||||
return nil, nil, fmt.Errorf("udp-associate: server version %#x is not SOCKS5", reply[0])
|
||||
}
|
||||
if reply[1] != 0x00 {
|
||||
return nil, nil, fmt.Errorf("udp-associate: REP=%#02x", reply[1])
|
||||
}
|
||||
if reply[3] != 0x01 {
|
||||
return nil, nil, fmt.Errorf("udp-associate: unsupported BND.ATYP=%#02x (need IPv4)", reply[3])
|
||||
}
|
||||
|
||||
bndIP := net.IPv4(reply[4], reply[5], reply[6], reply[7]).To4()
|
||||
bndPort := binary.BigEndian.Uint16(reply[8:10])
|
||||
|
||||
// Per RFC 1928 §6 / common practice: BND.ADDR=0.0.0.0 means "use
|
||||
// the same address you used to reach me". Substitute proxy host's
|
||||
// IP from the established TCP conn's RemoteAddr.
|
||||
if bndIP.Equal(net.IPv4zero.To4()) {
|
||||
if ra, ok := conn.RemoteAddr().(*net.TCPAddr); ok && ra.IP != nil {
|
||||
if v4 := ra.IP.To4(); v4 != nil {
|
||||
bndIP = v4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear deadline so caller can use ctrl as-is (keepalive only).
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
|
||||
return &net.UDPAddr{IP: bndIP, Port: int(bndPort)}, conn, nil
|
||||
}
|
||||
|
||||
// EncapUDPv4 wraps an outbound UDP payload in the SOCKS5 UDP datagram
|
||||
// envelope (RFC 1928 §7) for ATYP=01 (IPv4). The returned buffer has
|
||||
// the form:
|
||||
//
|
||||
// RSV(2)=0000 | FRAG(1)=00 | ATYP(1)=01 | DST.ADDR(4) | DST.PORT(2) | DATA
|
||||
//
|
||||
// The 10-byte prefix tells the relay where to forward the datagram.
|
||||
// Returns an error if dstIP is not IPv4.
|
||||
func EncapUDPv4(dstIP net.IP, dstPort uint16, payload []byte) ([]byte, error) {
|
||||
v4 := dstIP.To4()
|
||||
if v4 == nil {
|
||||
return nil, errors.New("EncapUDPv4: dst must be IPv4")
|
||||
}
|
||||
out := make([]byte, 10+len(payload))
|
||||
out[0] = 0x00 // RSV
|
||||
out[1] = 0x00 // RSV
|
||||
out[2] = 0x00 // FRAG (no fragmentation)
|
||||
out[3] = 0x01 // ATYP IPv4
|
||||
copy(out[4:8], v4)
|
||||
binary.BigEndian.PutUint16(out[8:10], dstPort)
|
||||
copy(out[10:], payload)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecapUDPv4 parses an inbound SOCKS5 UDP datagram (RFC 1928 §7) for
|
||||
// ATYP=01 (IPv4). On the inbound path the relay puts the ORIGIN's
|
||||
// addr/port in DST.ADDR/DST.PORT — i.e. for us, the original DST that
|
||||
// answered (e.g. the Discord voice server). The returned (srcIP,
|
||||
// srcPort) reflect that origin; payload is the original UDP body.
|
||||
//
|
||||
// Errors when:
|
||||
// - buf shorter than 10 bytes (truncated header)
|
||||
// - FRAG != 0 (we don't reassemble fragments)
|
||||
// - ATYP != 1 (we only handle IPv4 in this path)
|
||||
func DecapUDPv4(buf []byte) (srcIP net.IP, srcPort uint16, payload []byte, err error) {
|
||||
if len(buf) < 10 {
|
||||
return nil, 0, nil, errors.New("DecapUDPv4: truncated header")
|
||||
}
|
||||
if buf[2] != 0x00 {
|
||||
return nil, 0, nil, fmt.Errorf("DecapUDPv4: FRAG=%d not supported", buf[2])
|
||||
}
|
||||
if buf[3] != 0x01 {
|
||||
return nil, 0, nil, fmt.Errorf("DecapUDPv4: ATYP=%#02x not IPv4", buf[3])
|
||||
}
|
||||
srcIP = net.IPv4(buf[4], buf[5], buf[6], buf[7])
|
||||
srcPort = binary.BigEndian.Uint16(buf[8:10])
|
||||
payload = buf[10:]
|
||||
return srcIP, srcPort, payload, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package socks5
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeUDPProxy is a minimal SOCKS5 server that handles greet+(optional auth)
|
||||
// then UDP ASSOCIATE — replying with a relay endpoint we control.
|
||||
type fakeUDPProxy struct {
|
||||
tcpAddr string
|
||||
relay *net.UDPConn // bound on 127.0.0.1, ephemeral port
|
||||
useAuth bool
|
||||
login string
|
||||
password string
|
||||
}
|
||||
|
||||
func startFakeUDPProxy(t *testing.T, useAuth bool, login, password string) *fakeUDPProxy {
|
||||
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() })
|
||||
|
||||
p := &fakeUDPProxy{
|
||||
tcpAddr: tcpLn.Addr().String(),
|
||||
relay: relay,
|
||||
useAuth: useAuth, login: login, password: password,
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
c, err := tcpLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go p.handle(c)
|
||||
}
|
||||
}()
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *fakeUDPProxy) handle(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
buf := make([]byte, 256)
|
||||
|
||||
// Greet
|
||||
io.ReadFull(c, buf[:2])
|
||||
nm := int(buf[1])
|
||||
io.ReadFull(c, buf[:nm])
|
||||
if p.useAuth {
|
||||
c.Write([]byte{0x05, 0x02})
|
||||
// Auth subneg: 01 ULEN UNAME PLEN PASS
|
||||
io.ReadFull(c, buf[:2])
|
||||
ulen := int(buf[1])
|
||||
io.ReadFull(c, buf[:ulen])
|
||||
login := string(buf[:ulen])
|
||||
io.ReadFull(c, buf[:1])
|
||||
plen := int(buf[0])
|
||||
io.ReadFull(c, buf[:plen])
|
||||
pwd := string(buf[:plen])
|
||||
if login != p.login || pwd != p.password {
|
||||
c.Write([]byte{0x01, 0x01})
|
||||
return
|
||||
}
|
||||
c.Write([]byte{0x01, 0x00})
|
||||
} else {
|
||||
c.Write([]byte{0x05, 0x00})
|
||||
}
|
||||
|
||||
// UDP ASSOCIATE: 05 03 00 ATYP ...
|
||||
io.ReadFull(c, buf[:4])
|
||||
if buf[1] != 0x03 {
|
||||
// Not UDP ASSOCIATE; reject.
|
||||
c.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
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]) // port
|
||||
|
||||
// Reply with relay's local addr
|
||||
relayAddr := p.relay.LocalAddr().(*net.UDPAddr)
|
||||
rep := []byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}
|
||||
v4 := relayAddr.IP.To4()
|
||||
copy(rep[4:8], v4)
|
||||
binary.BigEndian.PutUint16(rep[8:10], uint16(relayAddr.Port))
|
||||
c.Write(rep)
|
||||
_ = c.SetReadDeadline(time.Time{})
|
||||
|
||||
// Hold the conn open until peer closes (RFC 1928 §6 — control TCP
|
||||
// must remain open for the relay to stay valid).
|
||||
io.Copy(io.Discard, c)
|
||||
}
|
||||
|
||||
func TestAssociateUDP_NoAuth(t *testing.T) {
|
||||
p := startFakeUDPProxy(t, false, "", "")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
relay, ctrl, err := AssociateUDP(ctx, Config{ProxyAddr: p.tcpAddr})
|
||||
require.NoError(t, err)
|
||||
defer ctrl.Close()
|
||||
|
||||
expected := p.relay.LocalAddr().(*net.UDPAddr)
|
||||
assert.Equal(t, expected.Port, relay.Port)
|
||||
assert.Equal(t, "127.0.0.1", relay.IP.String())
|
||||
}
|
||||
|
||||
func TestAssociateUDP_WithAuth(t *testing.T) {
|
||||
p := startFakeUDPProxy(t, true, "user", "pass")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
relay, ctrl, err := AssociateUDP(ctx, Config{
|
||||
ProxyAddr: p.tcpAddr,
|
||||
UseAuth: true,
|
||||
Login: "user",
|
||||
Password: "pass",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ctrl.Close()
|
||||
|
||||
require.NotNil(t, relay)
|
||||
assert.Greater(t, relay.Port, 0)
|
||||
}
|
||||
|
||||
func TestAssociateUDP_BadAuth(t *testing.T) {
|
||||
p := startFakeUDPProxy(t, true, "user", "pass")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, _, err := AssociateUDP(ctx, Config{
|
||||
ProxyAddr: p.tcpAddr,
|
||||
UseAuth: true,
|
||||
Login: "wrong",
|
||||
Password: "wrong",
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEncapDecapUDPv4_Roundtrip(t *testing.T) {
|
||||
dstIP := net.IPv4(140, 82, 121, 4)
|
||||
payload := []byte("voice payload bytes")
|
||||
|
||||
envelope, err := EncapUDPv4(dstIP, 50007, payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify wire layout (RFC 1928 §7)
|
||||
assert.Equal(t, byte(0x00), envelope[0], "RSV[0]")
|
||||
assert.Equal(t, byte(0x00), envelope[1], "RSV[1]")
|
||||
assert.Equal(t, byte(0x00), envelope[2], "FRAG")
|
||||
assert.Equal(t, byte(0x01), envelope[3], "ATYP=IPv4")
|
||||
assert.Equal(t, []byte{140, 82, 121, 4}, envelope[4:8])
|
||||
assert.Equal(t, uint16(50007), binary.BigEndian.Uint16(envelope[8:10]))
|
||||
assert.Equal(t, payload, envelope[10:])
|
||||
|
||||
// Round-trip via DecapUDPv4
|
||||
srcIP, srcPort, gotPayload, err := DecapUDPv4(envelope)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "140.82.121.4", srcIP.String())
|
||||
assert.Equal(t, uint16(50007), srcPort)
|
||||
assert.Equal(t, payload, gotPayload)
|
||||
}
|
||||
|
||||
func TestEncapUDPv4_NotIPv4(t *testing.T) {
|
||||
v6 := net.ParseIP("::1")
|
||||
_, err := EncapUDPv4(v6, 1, []byte("x"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDecapUDPv4_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
buf []byte
|
||||
}{
|
||||
{"too_short", []byte{0, 0, 0, 1, 1, 2, 3}},
|
||||
{"frag_nonzero", []byte{0, 0, 1 /* frag */, 1, 1, 2, 3, 4, 0, 80, 'x'}},
|
||||
{"atyp_not_ipv4", []byte{0, 0, 0, 4 /* IPv6 */, 1, 2, 3, 4, 0, 80, 'x'}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, _, _, err := DecapUDPv4(c.buf)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user