diff --git a/cmd/drover/debugflow_other.go b/cmd/drover/debugflow_other.go new file mode 100644 index 0000000..c7967cb --- /dev/null +++ b/cmd/drover/debugflow_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import ( + "context" + "fmt" +) + +func runDebugFlow(_ context.Context) error { + return fmt.Errorf("debug-flow requires Windows") +} diff --git a/cmd/drover/debugflow_windows.go b/cmd/drover/debugflow_windows.go new file mode 100644 index 0000000..78fd445 --- /dev/null +++ b/cmd/drover/debugflow_windows.go @@ -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 + } + } +} diff --git a/cmd/drover/main.go b/cmd/drover/main.go index 6cf2679..7ef4b79 100644 --- a/cmd/drover/main.go +++ b/cmd/drover/main.go @@ -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", diff --git a/cmd/drover/proxy_other.go b/cmd/drover/proxy_other.go new file mode 100644 index 0000000..9c2b150 --- /dev/null +++ b/cmd/drover/proxy_other.go @@ -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)") +} diff --git a/cmd/drover/proxy_windows.go b/cmd/drover/proxy_windows.go new file mode 100644 index 0000000..019d728 --- /dev/null +++ b/cmd/drover/proxy_windows.go @@ -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()) + } + } + } +} diff --git a/drover-test.exe~ b/drover-test.exe~ new file mode 100644 index 0000000..4837186 Binary files /dev/null and b/drover-test.exe~ differ diff --git a/internal/divert/divert.go b/internal/divert/divert.go index d91d7d9..0587831 100644 --- a/internal/divert/divert.go +++ b/internal/divert/divert.go @@ -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 ( diff --git a/internal/divert/filter.go b/internal/divert/filter.go index 8102e25..014f1b4 100644 --- a/internal/divert/filter.go +++ b/internal/divert/filter.go @@ -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) +} diff --git a/internal/divert/installer.go b/internal/divert/installer.go index c074a0b..bd2b50d 100644 --- a/internal/divert/installer.go +++ b/internal/divert/installer.go @@ -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. diff --git a/internal/divert/packet.go b/internal/divert/packet.go index dddd4cb..06b6503 100644 --- a/internal/divert/packet.go +++ b/internal/divert/packet.go @@ -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) +} diff --git a/internal/divert/packet_test.go b/internal/divert/packet_test.go index 89773a6..a28554f 100644 --- a/internal/divert/packet_test.go +++ b/internal/divert/packet_test.go @@ -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) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9beb120..8ba6975 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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= (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 } diff --git a/internal/gui/app.go b/internal/gui/app.go index 61508d2..fbf9c9e 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -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 } diff --git a/internal/procscan/resolve_windows.go b/internal/procscan/resolve_windows.go new file mode 100644 index 0000000..7e66a1a --- /dev/null +++ b/internal/procscan/resolve_windows.go @@ -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 +} diff --git a/internal/redirect/tcp.go b/internal/redirect/tcp.go index 73a2416..504efb0 100644 --- a/internal/redirect/tcp.go +++ b/internal/redirect/tcp.go @@ -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() diff --git a/internal/redirect/udp.go b/internal/redirect/udp.go new file mode 100644 index 0000000..49c05ce --- /dev/null +++ b/internal/redirect/udp.go @@ -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) +} diff --git a/internal/redirect/udp_test.go b/internal/redirect/udp_test.go new file mode 100644 index 0000000..d275d55 --- /dev/null +++ b/internal/redirect/udp_test.go @@ -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) +} diff --git a/internal/socks5/udp.go b/internal/socks5/udp.go new file mode 100644 index 0000000..b655a5d --- /dev/null +++ b/internal/socks5/udp.go @@ -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 +} diff --git a/internal/socks5/udp_test.go b/internal/socks5/udp_test.go new file mode 100644 index 0000000..b96598c --- /dev/null +++ b/internal/socks5/udp_test.go @@ -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) + }) + } +}