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:
2026-05-01 22:27:54 +03:00
parent 8ceb7775d7
commit 4074e68715
19 changed files with 2666 additions and 62 deletions
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package main
import (
"context"
"fmt"
)
func runDebugFlow(_ context.Context) error {
return fmt.Errorf("debug-flow requires Windows")
}
+64
View File
@@ -0,0 +1,64 @@
//go:build windows
package main
import (
"context"
"log"
"time"
"git.okcu.io/root/drover-go/internal/divert"
)
// runDebugFlow opens a WinDivert FLOW handle with the broadest possible
// filter ("tcp") and logs every flow-establish/delete event for up to
// 30 seconds. This is the simplest possible test that the FLOW layer
// is delivering events to our handle.
//
// If we see events here but our process-targeted handle in `proxy`
// stays silent, the bug is in our processId filter clause. If we see
// nothing here, the FLOW layer is broken on this machine.
func runDebugFlow(parent context.Context) error {
if _, err := divert.InstallDriver(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
log.Printf("debug-flow: opening FLOW handle with filter \"true\" (capture all flows)")
h, err := divert.OpenFlow("true")
if err != nil {
log.Printf("debug-flow: OpenFlow failed: %v", err)
return err
}
defer h.Close()
log.Printf("debug-flow: handle open, listening for 30s")
go func() {
<-ctx.Done()
_ = h.Close() // unblock RecvFlow
}()
count := 0
for {
ev, err := h.RecvFlow()
if err != nil {
if ctx.Err() != nil {
log.Printf("debug-flow: done — captured %d events in 30s", count)
return nil
}
log.Printf("debug-flow: RecvFlow err: %v", err)
return err
}
count++
log.Printf("debug-flow: event #%d est=%v pid=%d proto=%d %v:%d → %v:%d rawLocal=%x rawRemote=%x",
count, ev.Established, ev.ProcessID, ev.Protocol,
ev.SrcAddr, ev.SrcPort, ev.DstAddr, ev.DstPort,
ev.LocalRaw, ev.RemoteRaw)
if count >= 20 {
log.Printf("debug-flow: hit 20-event cap, stopping")
return nil
}
}
}
+94 -1
View File
@@ -3,7 +3,11 @@ package main
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
@@ -29,16 +33,30 @@ func main() {
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
attachToParentConsole()
// Open a debug log file at %LOCALAPPDATA%\Drover\debug.log so we have
// post-mortem visibility into engine startup failures even when the
// process was launched via UAC re-elevation (which detaches stderr
// from the parent terminal).
setupDebugLog()
// Detect if we need admin for the command in os.Args[1:]. If we do and
// we're not admin, re-launch via ShellExecute("runas", ...) and exit.
// CLI subcommands like "check", "version", "update" don't need admin
// and will run without UAC prompt.
if CmdNeedsAdmin(os.Args[1:]) && !IsAdmin() {
needsAdm := CmdNeedsAdmin(os.Args[1:])
isAdm := IsAdmin()
log.Printf("main: post-console admin=%v needsAdmin=%v args=%v", isAdm, needsAdm, os.Args[1:])
if needsAdm && !isAdm {
log.Printf("main: invoking ReElevate")
if err := ReElevate(os.Args[1:]); err != nil {
log.Printf("main: ReElevate returned err: %v", err)
fmt.Fprintf(os.Stderr, "failed to re-elevate: %v\n", err)
} else {
log.Printf("main: ReElevate returned ok, exiting parent")
}
os.Exit(0)
}
log.Printf("main: continuing in current process (no re-elevation needed)")
// Inject our build version so the updater package can stamp it on the
// User-Agent header it sends to git.okcu.io.
@@ -50,6 +68,39 @@ func main() {
}
}
// setupDebugLog wires the standard `log` package to write to both stderr
// and %LOCALAPPDATA%\Drover\debug.log. Survives UAC re-launch (each
// process opens its own append-mode handle).
func setupDebugLog() {
dir := os.Getenv("LOCALAPPDATA")
if dir == "" {
dir = os.Getenv("TEMP")
}
if dir == "" {
return
}
dir = filepath.Join(dir, "Drover")
_ = os.MkdirAll(dir, 0755)
// Truncate on each startup — keeps the log focused on the current
// run instead of accumulating past sessions. If you need history,
// rotate before launch.
f, err := os.OpenFile(filepath.Join(dir, "debug.log"), os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
// On a UAC-elevated launch (Start-Process -Verb RunAs) we have no
// parent console — os.Stderr points at an invalid handle. Writing
// to it via MultiWriter fails the *entire* write, so logs silently
// drop. Just write to the file; CLI subcommands launched from a
// real console can grep the file.
log.SetOutput(f)
_ = io.Discard // keep io import used
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Printf("=== drover %s start pid=%d args=%v admin=%v at %s ===",
Version, os.Getpid(), os.Args[1:], IsAdmin(), time.Now().Format(time.RFC3339))
}
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "drover",
@@ -76,10 +127,52 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newUpdateCmd())
root.AddCommand(newServiceCmd())
root.AddCommand(newGUICmd())
root.AddCommand(newProxyCmd())
root.AddCommand(newDebugFlowCmd())
return root
}
// newDebugFlowCmd opens a WinDivert FLOW handle with filter "tcp"
// (capture all TCP flow events from any process) and logs every event
// for 30 seconds. Useful to verify the FLOW layer is working at all
// without process-targeting interference.
func newDebugFlowCmd() *cobra.Command {
return &cobra.Command{
Use: "debug-flow",
Short: "[debug] open broad FLOW handle, log events for 30s",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDebugFlow(cmd.Context())
},
}
}
// newProxyCmd is the headless engine-only mode: no Wails, no tray —
// just spin up the WinDivert + SOCKS5 pipeline against the configured
// upstream and block on Ctrl+C. Useful for debugging without the GUI
// stack in the way; everything still goes to %LOCALAPPDATA%\Drover\debug.log.
func newProxyCmd() *cobra.Command {
var host, login, password string
var port int
var auth bool
cmd := &cobra.Command{
Use: "proxy",
Short: "Run the WinDivert+SOCKS5 engine in headless mode (no GUI, blocks until Ctrl+C)",
RunE: func(cmd *cobra.Command, args []string) error {
return runProxy(cmd.Context(), host, port, auth, login, password)
},
}
cmd.Flags().StringVar(&host, "host", "", "upstream SOCKS5 host (required)")
cmd.Flags().IntVar(&port, "port", 0, "upstream SOCKS5 port (required)")
cmd.Flags().BoolVar(&auth, "auth", false, "enable user/pass auth")
cmd.Flags().StringVar(&login, "login", "", "SOCKS5 login (when --auth)")
cmd.Flags().StringVar(&password, "password", "", "SOCKS5 password (when --auth)")
_ = cmd.MarkFlagRequired("host")
_ = cmd.MarkFlagRequired("port")
return cmd
}
func newGUICmd() *cobra.Command {
return &cobra.Command{
Use: "gui",
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package main
import (
"context"
"fmt"
)
// runProxy stub for non-Windows builds (drover only ships for Windows;
// this stub keeps `go build ./...` clean on Linux dev/CI machines).
func runProxy(_ context.Context, _ string, _ int, _ bool, _, _ string) error {
return fmt.Errorf("the proxy subcommand requires Windows (WinDivert is Windows-only)")
}
+80
View File
@@ -0,0 +1,80 @@
//go:build windows
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"git.okcu.io/root/drover-go/internal/engine"
)
// runProxy is the body of the `drover proxy` subcommand. It builds an
// engine.Engine from the supplied flags, calls Start, and blocks until
// the process receives SIGINT (Ctrl+C) or SIGTERM. On signal, it
// gracefully Stops the engine and exits.
//
// All output is mirrored to stderr (visible when launched from a
// console session) AND %LOCALAPPDATA%\Drover\debug.log. setupDebugLog
// in main.go has already wired the log package to write to both.
func runProxy(parent context.Context, host string, port int, auth bool, login, password string) error {
if host == "" || port == 0 {
return fmt.Errorf("--host and --port are required")
}
ctx, cancel := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
defer cancel()
cfg := engine.Config{
ProxyAddr: fmt.Sprintf("%s:%d", host, port),
UseAuth: auth,
Login: login,
Password: password,
Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"},
}
log.Printf("proxy: building engine (proxy=%s auth=%v targets=%v)", cfg.ProxyAddr, cfg.UseAuth, cfg.Targets)
e, err := engine.New(cfg)
if err != nil {
return fmt.Errorf("engine.New: %w", err)
}
startCtx, startCancel := context.WithTimeout(ctx, 15*time.Second)
defer startCancel()
if err := e.Start(startCtx); err != nil {
log.Printf("proxy: Start failed: %v", err)
return fmt.Errorf("engine.Start: %w", err)
}
log.Printf("proxy: engine status=%s — press Ctrl+C to stop", e.Status())
// Periodic status ping so the user sees the engine is alive.
statusTk := time.NewTicker(10 * time.Second)
defer statusTk.Stop()
for {
select {
case <-ctx.Done():
log.Printf("proxy: signal received, shutting down")
if err := e.Stop(); err != nil {
log.Printf("proxy: Stop returned: %v", err)
}
log.Printf("proxy: bye")
return nil
case <-statusTk.C:
if le := e.LastError(); le != nil {
log.Printf("proxy: heartbeat status=%s lastErr=%v", e.Status(), le)
} else {
log.Printf("proxy: heartbeat status=%s", e.Status())
}
if e.Status() == engine.StatusFailed {
log.Printf("proxy: engine entered Failed state, exiting")
_ = e.Stop()
return fmt.Errorf("engine failed: %v", e.LastError())
}
}
}
}
BIN
View File
Binary file not shown.
+293
View File
@@ -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
View File
@@ -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)
}
+27
View File
@@ -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.
+308
View File
@@ -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)
}
+146
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+36
View File
@@ -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
}
+15
View File
@@ -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()
+446
View File
@@ -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)
}
+256
View File
@@ -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)
}
+178
View File
@@ -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
}
+203
View File
@@ -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)
})
}
}