experimental/windivert: P2.1+P2.2 with WinDivert NETWORK+SOCKET layers
WIP snapshot before pivot to sing-box+TUN. Reached: - TCP redirect via streamdump pattern (swap+Outbound=0+reinject) - SOCKET layer for SYN-stage flow detection (avoids FLOW Establish-too-late race) - Lazy PID→name resolution (catches Update.exe inside procscan tick) - UDP forward via SOCKS5 UDP ASSOCIATE relay + manual reinject - Result: chat works, voice times out (Discord IP discovery / RTC handshake fails) Reason for pivot: WinDivert NAT-reinject pattern has subtle layer-3 semantics issues that DLL-injection / TUN-based proxies sidestep entirely. Going with embedded sing-box + wintun as the engine — proven path for Discord voice through SOCKS5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runDebugFlow(_ context.Context) error {
|
||||||
|
return fmt.Errorf("debug-flow requires Windows")
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.okcu.io/root/drover-go/internal/divert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runDebugFlow opens a WinDivert FLOW handle with the broadest possible
|
||||||
|
// filter ("tcp") and logs every flow-establish/delete event for up to
|
||||||
|
// 30 seconds. This is the simplest possible test that the FLOW layer
|
||||||
|
// is delivering events to our handle.
|
||||||
|
//
|
||||||
|
// If we see events here but our process-targeted handle in `proxy`
|
||||||
|
// stays silent, the bug is in our processId filter clause. If we see
|
||||||
|
// nothing here, the FLOW layer is broken on this machine.
|
||||||
|
func runDebugFlow(parent context.Context) error {
|
||||||
|
if _, err := divert.InstallDriver(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Printf("debug-flow: opening FLOW handle with filter \"true\" (capture all flows)")
|
||||||
|
h, err := divert.OpenFlow("true")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("debug-flow: OpenFlow failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer h.Close()
|
||||||
|
log.Printf("debug-flow: handle open, listening for 30s")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = h.Close() // unblock RecvFlow
|
||||||
|
}()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for {
|
||||||
|
ev, err := h.RecvFlow()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
log.Printf("debug-flow: done — captured %d events in 30s", count)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("debug-flow: RecvFlow err: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
log.Printf("debug-flow: event #%d est=%v pid=%d proto=%d %v:%d → %v:%d rawLocal=%x rawRemote=%x",
|
||||||
|
count, ev.Established, ev.ProcessID, ev.Protocol,
|
||||||
|
ev.SrcAddr, ev.SrcPort, ev.DstAddr, ev.DstPort,
|
||||||
|
ev.LocalRaw, ev.RemoteRaw)
|
||||||
|
if count >= 20 {
|
||||||
|
log.Printf("debug-flow: hit 20-event cap, stopping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
-1
@@ -3,7 +3,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
@@ -29,16 +33,30 @@ func main() {
|
|||||||
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
|
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
|
||||||
attachToParentConsole()
|
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
|
// 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.
|
// we're not admin, re-launch via ShellExecute("runas", ...) and exit.
|
||||||
// CLI subcommands like "check", "version", "update" don't need admin
|
// CLI subcommands like "check", "version", "update" don't need admin
|
||||||
// and will run without UAC prompt.
|
// 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 {
|
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)
|
fmt.Fprintf(os.Stderr, "failed to re-elevate: %v\n", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("main: ReElevate returned ok, exiting parent")
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
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
|
// Inject our build version so the updater package can stamp it on the
|
||||||
// User-Agent header it sends to git.okcu.io.
|
// 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 {
|
func newRootCmd() *cobra.Command {
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: "drover",
|
Use: "drover",
|
||||||
@@ -76,10 +127,52 @@ func newRootCmd() *cobra.Command {
|
|||||||
root.AddCommand(newUpdateCmd())
|
root.AddCommand(newUpdateCmd())
|
||||||
root.AddCommand(newServiceCmd())
|
root.AddCommand(newServiceCmd())
|
||||||
root.AddCommand(newGUICmd())
|
root.AddCommand(newGUICmd())
|
||||||
|
root.AddCommand(newProxyCmd())
|
||||||
|
root.AddCommand(newDebugFlowCmd())
|
||||||
|
|
||||||
return root
|
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 {
|
func newGUICmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "gui",
|
Use: "gui",
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -5,10 +5,83 @@ package divert
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
idivert "github.com/imgk/divert-go"
|
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.
|
// Handle wraps a WinDivert handle.
|
||||||
type Handle struct {
|
type Handle struct {
|
||||||
h *idivert.Handle
|
h *idivert.Handle
|
||||||
@@ -29,6 +102,187 @@ func Open(filter string) (*Handle, error) {
|
|||||||
return &Handle{h: h}, nil
|
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.
|
// Close closes the handle. Safe to call multiple times.
|
||||||
func (h *Handle) Close() error {
|
func (h *Handle) Close() error {
|
||||||
if h == nil || h.h == nil {
|
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
|
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
|
// Sentinel errors mapped from raw Windows errors so the engine layer
|
||||||
// can pattern-match without importing windows package.
|
// can pattern-match without importing windows package.
|
||||||
var (
|
var (
|
||||||
|
|||||||
+65
-16
@@ -24,22 +24,25 @@ type FilterParams struct {
|
|||||||
// self-loops. If unparseable, "0.0.0.0" is substituted (caller
|
// self-loops. If unparseable, "0.0.0.0" is substituted (caller
|
||||||
// should validate before calling).
|
// should validate before calling).
|
||||||
UpstreamIP string
|
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
|
// BuildFlowFilter returns a filter expression for the FLOW layer handle.
|
||||||
// for WinDivertOpen. The expression captures only outbound IPv4 TCP/UDP
|
// processId is ONLY available at FLOW/SOCKET layers, not NETWORK — that's
|
||||||
// from the listed PIDs, excluding our own process and the upstream
|
// why we run two handles in parallel: this FLOW handle observes which
|
||||||
// proxy's IP.
|
// 5-tuples belong to target PIDs, and the NETWORK handle (BuildNetworkFilter)
|
||||||
func BuildFilter(p FilterParams) string {
|
// captures actual packets.
|
||||||
|
//
|
||||||
|
// Empty PID list → "false" (matches no flows).
|
||||||
|
func BuildFlowFilter(p FilterParams) string {
|
||||||
if len(p.TargetPIDs) == 0 {
|
if len(p.TargetPIDs) == 0 {
|
||||||
return "false"
|
return "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream := p.UpstreamIP
|
|
||||||
if net.ParseIP(upstream).To4() == nil {
|
|
||||||
upstream = "0.0.0.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
pidClauses := make([]string, len(p.TargetPIDs))
|
pidClauses := make([]string, len(p.TargetPIDs))
|
||||||
for i, pid := range p.TargetPIDs {
|
for i, pid := range p.TargetPIDs {
|
||||||
pidClauses[i] = fmt.Sprintf("processId == %d", pid)
|
pidClauses[i] = fmt.Sprintf("processId == %d", pid)
|
||||||
@@ -47,15 +50,61 @@ func BuildFilter(p FilterParams) string {
|
|||||||
pidClause := "(" + strings.Join(pidClauses, " or ") + ")"
|
pidClause := "(" + strings.Join(pidClauses, " or ") + ")"
|
||||||
|
|
||||||
parts := []string{
|
parts := []string{
|
||||||
"outbound",
|
|
||||||
"(tcp or udp)",
|
"(tcp or udp)",
|
||||||
"ip",
|
"ip",
|
||||||
pidClause,
|
pidClause,
|
||||||
fmt.Sprintf("processId != %d", p.OwnPID),
|
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 ")
|
return strings.Join(parts, " and ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildNetworkFilter returns a filter expression for the NETWORK layer
|
||||||
|
// handle. It captures all outbound IPv4 TCP/UDP except loopback,
|
||||||
|
// multicast, link-local, and the upstream proxy. The engine then
|
||||||
|
// narrows by consulting the flow tracker fed by the FLOW handle.
|
||||||
|
//
|
||||||
|
// We don't (can't) filter by processId here — see BuildFlowFilter.
|
||||||
|
// Self-loop protection: ip.DstAddr != upstream blocks our own SOCKS5
|
||||||
|
// uplink, and 127.0.0.0/8 exclusion blocks our loopback redirector.
|
||||||
|
//
|
||||||
|
// Range exclusions are spelled with explicit `<`/`>` rather than
|
||||||
|
// `not (a and b)` because some WinDivert versions reject the latter
|
||||||
|
// at filter compile time.
|
||||||
|
func BuildNetworkFilter(p FilterParams) string {
|
||||||
|
upstream := p.UpstreamIP
|
||||||
|
if net.ParseIP(upstream).To4() == nil {
|
||||||
|
upstream = "0.0.0.0"
|
||||||
|
}
|
||||||
|
parts := []string{
|
||||||
|
"outbound",
|
||||||
|
"ip",
|
||||||
|
"(tcp or udp)",
|
||||||
|
fmt.Sprintf("ip.DstAddr != %s", upstream),
|
||||||
|
// Loopback 127.0.0.0/8
|
||||||
|
"(ip.DstAddr < 127.0.0.0 or ip.DstAddr > 127.255.255.255)",
|
||||||
|
// Multicast 224.0.0.0/4
|
||||||
|
"(ip.DstAddr < 224.0.0.0 or ip.DstAddr > 239.255.255.255)",
|
||||||
|
// Link-local 169.254.0.0/16
|
||||||
|
"(ip.DstAddr < 169.254.0.0 or ip.DstAddr > 169.254.255.255)",
|
||||||
|
}
|
||||||
|
// Exclude packets DESTINED to our own LAN IP — they're either
|
||||||
|
// intra-machine traffic we don't care about OR our own NAT'd
|
||||||
|
// reinjects coming back around. Without this we infinite-loop.
|
||||||
|
if p.LocalIP != "" && net.ParseIP(p.LocalIP).To4() != nil {
|
||||||
|
parts = append(parts, fmt.Sprintf("ip.DstAddr != %s", p.LocalIP))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " and ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFilter is the legacy single-filter API. Kept for callers that
|
||||||
|
// don't yet use the dual-handle architecture; equivalent to
|
||||||
|
// BuildNetworkFilter (no processId — that clause is invalid at NETWORK
|
||||||
|
// layer).
|
||||||
|
//
|
||||||
|
// Deprecated: use BuildFlowFilter + BuildNetworkFilter together.
|
||||||
|
func BuildFilter(p FilterParams) string {
|
||||||
|
if len(p.TargetPIDs) == 0 {
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
return BuildNetworkFilter(p)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DriverPaths records where the WinDivert binaries landed after install.
|
// 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 {
|
if err := writeIfDifferent(dllPath, winDivertDll, WinDivertDllSHA256); err != nil {
|
||||||
return nil, fmt.Errorf("install WinDivert.dll: %w", err)
|
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
|
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
|
// writeIfDifferent compares the existing file's SHA256 to the expected
|
||||||
// hash; if it matches, no-op. Otherwise overwrite atomically and verify
|
// hash; if it matches, no-op. Otherwise overwrite atomically and verify
|
||||||
// the resulting on-disk SHA matches expected.
|
// the resulting on-disk SHA matches expected.
|
||||||
|
|||||||
@@ -81,6 +81,84 @@ func RewriteDst(b []byte, ip net.IP, port uint16) error {
|
|||||||
return nil
|
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
|
// ipChecksum is the standard 16-bit one's-complement sum over the IP
|
||||||
// header (RFC 791). The "checksum field" must be zeroed before calling.
|
// header (RFC 791). The "checksum field" must be zeroed before calling.
|
||||||
func ipChecksum(hdr []byte) uint16 {
|
func ipChecksum(hdr []byte) uint16 {
|
||||||
@@ -121,3 +199,233 @@ func tcpChecksum(ipHdr, tcpSeg []byte) uint16 {
|
|||||||
}
|
}
|
||||||
return ^uint16(sum)
|
return ^uint16(sum)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IPv4UDPInfo is what we extract from a raw IPv4+UDP packet for our
|
||||||
|
// per-flow mapping table.
|
||||||
|
type IPv4UDPInfo struct {
|
||||||
|
SrcIP, DstIP net.IP
|
||||||
|
SrcPort, DstPort uint16
|
||||||
|
IHL int // IPv4 header length in bytes
|
||||||
|
UDPLen uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseIPv4UDP reads the IPv4 + UDP header pair out of an outbound
|
||||||
|
// captured packet and returns the addressing info. Does NOT mutate
|
||||||
|
// the buffer.
|
||||||
|
//
|
||||||
|
// Errors when:
|
||||||
|
// - buffer too short to contain a full IPv4+UDP header (28 bytes)
|
||||||
|
// - IP version is not 4
|
||||||
|
// - IP protocol is not 17 (UDP)
|
||||||
|
func ParseIPv4UDP(b []byte) (*IPv4UDPInfo, error) {
|
||||||
|
if len(b) < 28 {
|
||||||
|
return nil, errors.New("packet shorter than IPv4+UDP minimum")
|
||||||
|
}
|
||||||
|
if b[0]>>4 != 4 {
|
||||||
|
return nil, errors.New("not IPv4")
|
||||||
|
}
|
||||||
|
ihl := int(b[0]&0x0f) * 4
|
||||||
|
if ihl < 20 || len(b) < ihl+8 {
|
||||||
|
return nil, errors.New("IPv4 IHL invalid or buffer truncated")
|
||||||
|
}
|
||||||
|
if b[9] != 17 {
|
||||||
|
return nil, errors.New("not UDP")
|
||||||
|
}
|
||||||
|
src := net.IPv4(b[12], b[13], b[14], b[15])
|
||||||
|
dst := net.IPv4(b[16], b[17], b[18], b[19])
|
||||||
|
srcPort := binary.BigEndian.Uint16(b[ihl : ihl+2])
|
||||||
|
dstPort := binary.BigEndian.Uint16(b[ihl+2 : ihl+4])
|
||||||
|
udpLen := binary.BigEndian.Uint16(b[ihl+4 : ihl+6])
|
||||||
|
return &IPv4UDPInfo{
|
||||||
|
SrcIP: src,
|
||||||
|
DstIP: dst,
|
||||||
|
SrcPort: srcPort,
|
||||||
|
DstPort: dstPort,
|
||||||
|
IHL: ihl,
|
||||||
|
UDPLen: udpLen,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwapUDPAndSetDstPort applies the canonical streamdump-style swap to
|
||||||
|
// a UDP packet: swap IPv4 src/dst, set UDP dst port to newDstPort.
|
||||||
|
// Keeps the original UDP src port. Recomputes IP and UDP checksums.
|
||||||
|
//
|
||||||
|
// (For UDP, swap+reinject is generally NOT used by drover — the
|
||||||
|
// engine's diverterLoop "consumes" target UDP packets and forwards
|
||||||
|
// them through the SOCKS5 UDP relay directly. This helper is here for
|
||||||
|
// completeness/symmetry with the TCP swap helpers and for tests.)
|
||||||
|
func SwapUDPAndSetDstPort(b []byte, newDstPort uint16) error {
|
||||||
|
if _, err := ParseIPv4UDP(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ihl := int(b[0]&0x0f) * 4
|
||||||
|
|
||||||
|
// Swap src ↔ dst IPv4
|
||||||
|
var src, dst [4]byte
|
||||||
|
copy(src[:], b[12:16])
|
||||||
|
copy(dst[:], b[16:20])
|
||||||
|
copy(b[12:16], dst[:])
|
||||||
|
copy(b[16:20], src[:])
|
||||||
|
|
||||||
|
// Set UDP dst port
|
||||||
|
binary.BigEndian.PutUint16(b[ihl+2:ihl+4], newDstPort)
|
||||||
|
|
||||||
|
// Recompute IP checksum
|
||||||
|
b[10], b[11] = 0, 0
|
||||||
|
cs := ipChecksum(b[:ihl])
|
||||||
|
b[10] = byte(cs >> 8)
|
||||||
|
b[11] = byte(cs & 0xff)
|
||||||
|
|
||||||
|
// Recompute UDP checksum (offset ihl+6,ihl+7 inside UDP header)
|
||||||
|
udpLen := int(binary.BigEndian.Uint16(b[ihl+4 : ihl+6]))
|
||||||
|
if ihl+udpLen > len(b) {
|
||||||
|
udpLen = len(b) - ihl
|
||||||
|
}
|
||||||
|
b[ihl+6], b[ihl+7] = 0, 0
|
||||||
|
cs = udpChecksum(b[:ihl], b[ihl:ihl+udpLen])
|
||||||
|
// Zero is "no checksum" in IPv4 UDP. RFC 768 says when the
|
||||||
|
// computed checksum is zero, transmit it as 0xFFFF instead.
|
||||||
|
if cs == 0 {
|
||||||
|
cs = 0xFFFF
|
||||||
|
}
|
||||||
|
b[ihl+6] = byte(cs >> 8)
|
||||||
|
b[ihl+7] = byte(cs & 0xff)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwapUDPAndSetSrcPort applies the canonical streamdump-style return-
|
||||||
|
// path swap to a UDP packet: swap IPv4 src/dst, set UDP src port to
|
||||||
|
// newSrcPort (the original target port the client expects to see).
|
||||||
|
// Recomputes IP and UDP checksums. (Symmetric counterpart to the TCP
|
||||||
|
// helper; not currently used by the engine for the same reason as
|
||||||
|
// SwapUDPAndSetDstPort, but exists for tests/parity.)
|
||||||
|
func SwapUDPAndSetSrcPort(b []byte, newSrcPort uint16) error {
|
||||||
|
if _, err := ParseIPv4UDP(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ihl := int(b[0]&0x0f) * 4
|
||||||
|
|
||||||
|
// Swap src ↔ dst IPv4
|
||||||
|
var src, dst [4]byte
|
||||||
|
copy(src[:], b[12:16])
|
||||||
|
copy(dst[:], b[16:20])
|
||||||
|
copy(b[12:16], dst[:])
|
||||||
|
copy(b[16:20], src[:])
|
||||||
|
|
||||||
|
// Set UDP src port
|
||||||
|
binary.BigEndian.PutUint16(b[ihl:ihl+2], newSrcPort)
|
||||||
|
|
||||||
|
// Recompute IP checksum
|
||||||
|
b[10], b[11] = 0, 0
|
||||||
|
cs := ipChecksum(b[:ihl])
|
||||||
|
b[10] = byte(cs >> 8)
|
||||||
|
b[11] = byte(cs & 0xff)
|
||||||
|
|
||||||
|
// Recompute UDP checksum
|
||||||
|
udpLen := int(binary.BigEndian.Uint16(b[ihl+4 : ihl+6]))
|
||||||
|
if ihl+udpLen > len(b) {
|
||||||
|
udpLen = len(b) - ihl
|
||||||
|
}
|
||||||
|
b[ihl+6], b[ihl+7] = 0, 0
|
||||||
|
cs = udpChecksum(b[:ihl], b[ihl:ihl+udpLen])
|
||||||
|
if cs == 0 {
|
||||||
|
cs = 0xFFFF
|
||||||
|
}
|
||||||
|
b[ihl+6] = byte(cs >> 8)
|
||||||
|
b[ihl+7] = byte(cs & 0xff)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildIPv4UDPInbound fabricates an IPv4+UDP packet for reinjection
|
||||||
|
// as inbound (return path from upstream relay → Discord). Used by the
|
||||||
|
// UDPProxy after the SOCKS5 relay sends back a response: we construct
|
||||||
|
// a synthetic packet that looks like remote_endpoint → local_IP and
|
||||||
|
// reinject it via WinDivert with addr.Outbound=0.
|
||||||
|
//
|
||||||
|
// src → original Discord destination (the UDP server)
|
||||||
|
// dst → local LAN IP we bound on
|
||||||
|
// srcPort → original destination port (e.g. 50007)
|
||||||
|
// dstPort → Discord's ephemeral src port (so the kernel matches the
|
||||||
|
// connect()-bound socket)
|
||||||
|
//
|
||||||
|
// The returned slice owns its own backing storage; callers may pass
|
||||||
|
// it directly to (*Handle).Send.
|
||||||
|
func BuildIPv4UDPInbound(srcIP, dstIP net.IP, srcPort, dstPort uint16, payload []byte) ([]byte, error) {
|
||||||
|
src := srcIP.To4()
|
||||||
|
dst := dstIP.To4()
|
||||||
|
if src == nil || dst == nil {
|
||||||
|
return nil, errors.New("BuildIPv4UDPInbound: src/dst must be IPv4")
|
||||||
|
}
|
||||||
|
if len(payload)+28 > 0xFFFF {
|
||||||
|
return nil, errors.New("BuildIPv4UDPInbound: payload too large for IPv4 datagram")
|
||||||
|
}
|
||||||
|
|
||||||
|
totalLen := 20 + 8 + len(payload)
|
||||||
|
buf := make([]byte, totalLen)
|
||||||
|
|
||||||
|
// IPv4 header (20 bytes, IHL=5, no options)
|
||||||
|
buf[0] = 0x45 // version=4, IHL=5
|
||||||
|
buf[1] = 0x00 // DSCP/ECN
|
||||||
|
binary.BigEndian.PutUint16(buf[2:4], uint16(totalLen))
|
||||||
|
binary.BigEndian.PutUint16(buf[4:6], 0) // ID
|
||||||
|
binary.BigEndian.PutUint16(buf[6:8], 0) // flags + frag
|
||||||
|
buf[8] = 64 // TTL
|
||||||
|
buf[9] = 17 // protocol = UDP
|
||||||
|
// checksum at [10..11] left zero for now
|
||||||
|
copy(buf[12:16], src)
|
||||||
|
copy(buf[16:20], dst)
|
||||||
|
|
||||||
|
// UDP header (8 bytes)
|
||||||
|
binary.BigEndian.PutUint16(buf[20:22], srcPort)
|
||||||
|
binary.BigEndian.PutUint16(buf[22:24], dstPort)
|
||||||
|
binary.BigEndian.PutUint16(buf[24:26], uint16(8+len(payload))) // UDP length
|
||||||
|
// UDP checksum at [26..27] left zero for now
|
||||||
|
|
||||||
|
// Payload
|
||||||
|
copy(buf[28:], payload)
|
||||||
|
|
||||||
|
// Recompute IP checksum
|
||||||
|
cs := ipChecksum(buf[:20])
|
||||||
|
buf[10] = byte(cs >> 8)
|
||||||
|
buf[11] = byte(cs & 0xff)
|
||||||
|
|
||||||
|
// Recompute UDP checksum (over pseudo-header + UDP segment)
|
||||||
|
cs = udpChecksum(buf[:20], buf[20:])
|
||||||
|
if cs == 0 {
|
||||||
|
cs = 0xFFFF // RFC 768: 0 means "checksum disabled", send 0xFFFF instead
|
||||||
|
}
|
||||||
|
buf[26] = byte(cs >> 8)
|
||||||
|
buf[27] = byte(cs & 0xff)
|
||||||
|
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// udpChecksum implements the RFC 768 pseudo-header checksum for IPv4
|
||||||
|
// UDP. ipHdr must include src+dst addresses; udpSeg is the full UDP
|
||||||
|
// header + payload (UDP "length" field already set; checksum field
|
||||||
|
// inside udpSeg must be zeroed).
|
||||||
|
//
|
||||||
|
// IPv4 UDP checksum is technically OPTIONAL — a sender may transmit
|
||||||
|
// 0 to indicate "no checksum". We always compute one since most
|
||||||
|
// modern stacks (and Discord) expect a valid checksum.
|
||||||
|
func udpChecksum(ipHdr, udpSeg []byte) uint16 {
|
||||||
|
var sum uint32
|
||||||
|
// Pseudo-header: src(4) dst(4) zero(1) proto(1) udp_len(2)
|
||||||
|
for i := 12; i <= 18; i += 2 {
|
||||||
|
sum += uint32(ipHdr[i])<<8 | uint32(ipHdr[i+1])
|
||||||
|
}
|
||||||
|
sum += uint32(17) // UDP protocol
|
||||||
|
udpLen := uint32(len(udpSeg))
|
||||||
|
sum += udpLen
|
||||||
|
// UDP segment (header + payload)
|
||||||
|
for i := 0; i+1 < len(udpSeg); i += 2 {
|
||||||
|
sum += uint32(udpSeg[i])<<8 | uint32(udpSeg[i+1])
|
||||||
|
}
|
||||||
|
if len(udpSeg)%2 == 1 {
|
||||||
|
sum += uint32(udpSeg[len(udpSeg)-1]) << 8
|
||||||
|
}
|
||||||
|
for sum>>16 != 0 {
|
||||||
|
sum = (sum & 0xffff) + (sum >> 16)
|
||||||
|
}
|
||||||
|
return ^uint16(sum)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package divert
|
package divert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -112,3 +113,148 @@ func TestParseIPv4TCP_Errors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// helloUDP is a minimum well-formed IPv4 + UDP datagram:
|
||||||
|
//
|
||||||
|
// src=10.0.0.1:54321 dst=1.2.3.4:443 payload=4 bytes ABCD
|
||||||
|
//
|
||||||
|
// Total length: 20(IP) + 8(UDP) + 4(payload) = 32 bytes.
|
||||||
|
var helloUDP = []byte{
|
||||||
|
// IPv4 header (20 bytes, IHL=5)
|
||||||
|
0x45, 0x00, 0x00, 0x20, 0xab, 0xcd, 0x40, 0x00, 0x40, 0x11, // proto=17 (UDP)
|
||||||
|
0x00, 0x00, // checksum placeholder
|
||||||
|
0x0a, 0x00, 0x00, 0x01, // src 10.0.0.1
|
||||||
|
0x01, 0x02, 0x03, 0x04, // dst 1.2.3.4
|
||||||
|
// UDP header (8 bytes)
|
||||||
|
0xd4, 0x31, 0x01, 0xbb, // src=54321 dst=443
|
||||||
|
0x00, 0x0c, // length=12 (UDP header + 4 payload)
|
||||||
|
0x00, 0x00, // checksum placeholder
|
||||||
|
// Payload (4 bytes)
|
||||||
|
'A', 'B', 'C', 'D',
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillUDPTestChecksums(b []byte) {
|
||||||
|
// IP checksum
|
||||||
|
b[10], b[11] = 0, 0
|
||||||
|
cs := ipChecksum(b[:20])
|
||||||
|
b[10] = byte(cs >> 8)
|
||||||
|
b[11] = byte(cs & 0xff)
|
||||||
|
// UDP checksum (covers UDP header + payload + pseudo-header)
|
||||||
|
udpLen := int(binary.BigEndian.Uint16(b[24:26]))
|
||||||
|
b[26], b[27] = 0, 0
|
||||||
|
cs = udpChecksum(b[:20], b[20:20+udpLen])
|
||||||
|
if cs == 0 {
|
||||||
|
cs = 0xFFFF
|
||||||
|
}
|
||||||
|
b[26] = byte(cs >> 8)
|
||||||
|
b[27] = byte(cs & 0xff)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIPv4UDP_Roundtrip(t *testing.T) {
|
||||||
|
pkt := make([]byte, len(helloUDP))
|
||||||
|
copy(pkt, helloUDP)
|
||||||
|
fillUDPTestChecksums(pkt)
|
||||||
|
|
||||||
|
p, err := ParseIPv4UDP(pkt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "10.0.0.1", p.SrcIP.String())
|
||||||
|
assert.Equal(t, "1.2.3.4", p.DstIP.String())
|
||||||
|
assert.Equal(t, uint16(54321), p.SrcPort)
|
||||||
|
assert.Equal(t, uint16(443), p.DstPort)
|
||||||
|
assert.Equal(t, 20, p.IHL)
|
||||||
|
assert.Equal(t, uint16(12), p.UDPLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIPv4UDP_Errors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
b []byte
|
||||||
|
}{
|
||||||
|
{"too_short", []byte{0x45}},
|
||||||
|
{"not_ipv4", []byte{0x60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
|
||||||
|
{"not_udp", []byte{0x45, 0, 0, 20, 0, 0, 0, 0, 0, 6, /* TCP */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
_, err := ParseIPv4UDP(c.b)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapUDPAndSetDstPort(t *testing.T) {
|
||||||
|
pkt := make([]byte, len(helloUDP))
|
||||||
|
copy(pkt, helloUDP)
|
||||||
|
fillUDPTestChecksums(pkt)
|
||||||
|
|
||||||
|
require.NoError(t, SwapUDPAndSetDstPort(pkt, 8080))
|
||||||
|
|
||||||
|
p, err := ParseIPv4UDP(pkt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "1.2.3.4", p.SrcIP.String(), "src should be original dst after swap")
|
||||||
|
assert.Equal(t, "10.0.0.1", p.DstIP.String(), "dst should be original src after swap")
|
||||||
|
assert.Equal(t, uint16(54321), p.SrcPort, "src port unchanged")
|
||||||
|
assert.Equal(t, uint16(8080), p.DstPort, "dst port set to new value")
|
||||||
|
|
||||||
|
// Validate IP checksum recomputed
|
||||||
|
ipCs := uint16(pkt[10])<<8 | uint16(pkt[11])
|
||||||
|
pkt[10], pkt[11] = 0, 0
|
||||||
|
expIP := ipChecksum(pkt[:20])
|
||||||
|
assert.Equal(t, expIP, ipCs, "IP checksum mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapUDPAndSetSrcPort(t *testing.T) {
|
||||||
|
pkt := make([]byte, len(helloUDP))
|
||||||
|
copy(pkt, helloUDP)
|
||||||
|
fillUDPTestChecksums(pkt)
|
||||||
|
|
||||||
|
require.NoError(t, SwapUDPAndSetSrcPort(pkt, 50007))
|
||||||
|
|
||||||
|
p, err := ParseIPv4UDP(pkt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "1.2.3.4", p.SrcIP.String())
|
||||||
|
assert.Equal(t, "10.0.0.1", p.DstIP.String())
|
||||||
|
assert.Equal(t, uint16(50007), p.SrcPort, "src port set to new value")
|
||||||
|
assert.Equal(t, uint16(443), p.DstPort, "dst port unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIPv4UDPInbound(t *testing.T) {
|
||||||
|
src := net.IPv4(140, 82, 121, 4) // GitHub IP, just for variety
|
||||||
|
dst := net.IPv4(192, 168, 1, 50) // local LAN
|
||||||
|
payload := []byte("hello voice")
|
||||||
|
|
||||||
|
pkt, err := BuildIPv4UDPInbound(src, dst, 50007, 50100, payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Total length: 20+8+11 = 39
|
||||||
|
assert.Len(t, pkt, 39)
|
||||||
|
|
||||||
|
// Re-parse and verify fields
|
||||||
|
p, err := ParseIPv4UDP(pkt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "140.82.121.4", p.SrcIP.String())
|
||||||
|
assert.Equal(t, "192.168.1.50", p.DstIP.String())
|
||||||
|
assert.Equal(t, uint16(50007), p.SrcPort)
|
||||||
|
assert.Equal(t, uint16(50100), p.DstPort)
|
||||||
|
assert.Equal(t, uint16(8+len(payload)), p.UDPLen)
|
||||||
|
|
||||||
|
// Payload after headers
|
||||||
|
assert.Equal(t, payload, pkt[28:])
|
||||||
|
|
||||||
|
// IP checksum valid: clearing + recomputing should match
|
||||||
|
ipCs := uint16(pkt[10])<<8 | uint16(pkt[11])
|
||||||
|
pkt[10], pkt[11] = 0, 0
|
||||||
|
expIP := ipChecksum(pkt[:20])
|
||||||
|
assert.Equal(t, expIP, ipCs, "IP checksum should be valid")
|
||||||
|
|
||||||
|
// UDP checksum valid (and non-zero)
|
||||||
|
udpCs := uint16(pkt[26])<<8 | uint16(pkt[27])
|
||||||
|
assert.NotEqual(t, uint16(0), udpCs, "UDP checksum should be non-zero (RFC 768 trick)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIPv4UDPInbound_NotIPv4(t *testing.T) {
|
||||||
|
v6 := net.ParseIP("::1")
|
||||||
|
_, err := BuildIPv4UDPInbound(v6, net.IPv4(1, 2, 3, 4), 1, 2, []byte("x"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|||||||
+423
-45
@@ -6,8 +6,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,12 +38,47 @@ type Engine struct {
|
|||||||
|
|
||||||
// runtime state
|
// runtime state
|
||||||
upstreamIP net.IP
|
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
|
redir *redirect.Redirector
|
||||||
|
udp *redirect.UDPProxy // SOCKS5 UDP relay manager — handles Discord voice etc.
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cnl context.CancelFunc
|
cnl context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
ownPID uint32
|
ownPID uint32
|
||||||
|
|
||||||
|
// pidMu guards targetPIDs. Updated by procscanLoop, read by flowLoop
|
||||||
|
// for every event. Read frequency: ~50 events/sec average; write:
|
||||||
|
// every 2s. RWMutex contention negligible.
|
||||||
|
pidMu sync.RWMutex
|
||||||
|
targetPIDs map[uint32]struct{}
|
||||||
|
|
||||||
|
// flowSet tracks 5-tuples currently belonging to target processes.
|
||||||
|
// Populated by flowLoop on EventFlowEstablished, removed on
|
||||||
|
// EventFlowDeleted. Read by diverterLoop on every captured packet
|
||||||
|
// to decide whether to redirect or pass through.
|
||||||
|
flowMu sync.RWMutex
|
||||||
|
flowSet map[flowKey]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flowKey identifies a flow by its 5-tuple. Drover uses local→remote
|
||||||
|
// (i.e. always the outbound direction) — the flow handle reports
|
||||||
|
// LocalAddr/Port and RemoteAddr/Port which match an outbound packet's
|
||||||
|
// SrcAddr/Port and DstAddr/Port.
|
||||||
|
// flowKey identifies a tracked flow. We deliberately omit SrcIP from
|
||||||
|
// the key: when Discord (or any client) binds a UDP/TCP socket to
|
||||||
|
// INADDR_ANY (0.0.0.0), the SOCKET layer reports src=0.0.0.0, but the
|
||||||
|
// actual outbound packet has src=<local_LAN_IP> (kernel fills the
|
||||||
|
// interface address). Including src in the key would cause those
|
||||||
|
// flows to miss the lookup. Source port + destination + proto is a
|
||||||
|
// sufficient discriminator on a single host.
|
||||||
|
type flowKey struct {
|
||||||
|
dst [4]byte
|
||||||
|
sport uint16
|
||||||
|
dport uint16
|
||||||
|
proto uint8 // 6=TCP, 17=UDP
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs an engine. No I/O yet.
|
// 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 nil, errors.New("ProxyAddr is required")
|
||||||
}
|
}
|
||||||
return &Engine{
|
return &Engine{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
status: StatusIdle,
|
status: StatusIdle,
|
||||||
ownPID: uint32(os.Getpid()),
|
ownPID: uint32(os.Getpid()),
|
||||||
|
flowSet: map[flowKey]struct{}{},
|
||||||
|
targetPIDs: map[uint32]struct{}{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,15 +146,19 @@ func (e *Engine) Start(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) bringUp(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
|
// 1. Resolve upstream
|
||||||
host, _, err := net.SplitHostPort(e.cfg.ProxyAddr)
|
host, _, err := net.SplitHostPort(e.cfg.ProxyAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("engine: SplitHostPort failed: %v", err)
|
||||||
return fmt.Errorf("invalid ProxyAddr: %w", err)
|
return fmt.Errorf("invalid ProxyAddr: %w", err)
|
||||||
}
|
}
|
||||||
rctx, rcancel := context.WithTimeout(ctx, 5*time.Second)
|
rctx, rcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer rcancel()
|
defer rcancel()
|
||||||
ips, err := net.DefaultResolver.LookupIPAddr(rctx, host)
|
ips, err := net.DefaultResolver.LookupIPAddr(rctx, host)
|
||||||
if err != nil || len(ips) == 0 {
|
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)
|
return fmt.Errorf("resolve proxy host %q: %w", host, err)
|
||||||
}
|
}
|
||||||
var upstream net.IP
|
var upstream net.IP
|
||||||
@@ -126,26 +169,50 @@ func (e *Engine) bringUp(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if upstream == nil {
|
if upstream == nil {
|
||||||
|
log.Printf("engine: no IPv4 for %q (got %v)", host, ips)
|
||||||
return fmt.Errorf("no IPv4 for %q", host)
|
return fmt.Errorf("no IPv4 for %q", host)
|
||||||
}
|
}
|
||||||
e.upstreamIP = upstream
|
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)
|
// 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)
|
return fmt.Errorf("install driver: %w", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("engine: driver installed sys=%s dll=%s", paths.SysPath, paths.DllPath)
|
||||||
|
|
||||||
// 3. Initial procscan
|
// 3. Initial procscan
|
||||||
pids, err := procscan.Snapshot(e.cfg.Targets)
|
pids, err := procscan.Snapshot(e.cfg.Targets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("engine: procscan.Snapshot failed: %v", err)
|
||||||
return fmt.Errorf("procscan: %w", err)
|
return fmt.Errorf("procscan: %w", err)
|
||||||
}
|
}
|
||||||
pidList := make([]uint32, 0, len(pids))
|
pidList := make([]uint32, 0, len(pids))
|
||||||
for p := range pids {
|
for p := range pids {
|
||||||
pidList = append(pidList, p)
|
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{
|
r, err := redirect.New(redirect.Config{
|
||||||
SOCKS5: socks5.Config{
|
SOCKS5: socks5.Config{
|
||||||
ProxyAddr: e.cfg.ProxyAddr,
|
ProxyAddr: e.cfg.ProxyAddr,
|
||||||
@@ -153,34 +220,111 @@ func (e *Engine) bringUp(ctx context.Context) error {
|
|||||||
Login: e.cfg.Login,
|
Login: e.cfg.Login,
|
||||||
Password: e.cfg.Password,
|
Password: e.cfg.Password,
|
||||||
},
|
},
|
||||||
Bind: "127.0.0.1:0",
|
Bind: "0.0.0.0:0",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("engine: redirect.New failed: %v", err)
|
||||||
return fmt.Errorf("redirector: %w", err)
|
return fmt.Errorf("redirector: %w", err)
|
||||||
}
|
}
|
||||||
e.redir = r
|
e.redir = r
|
||||||
|
log.Printf("engine: redirector listening on %s", r.LocalAddr())
|
||||||
|
|
||||||
// 5. Build filter + open handle
|
// 5. Build filters
|
||||||
filter := divert.BuildFilter(divert.FilterParams{
|
// 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,
|
TargetPIDs: pidList,
|
||||||
OwnPID: e.ownPID,
|
OwnPID: e.ownPID,
|
||||||
UpstreamIP: upstream.String(),
|
UpstreamIP: upstream.String(),
|
||||||
|
LocalIP: e.localIP.String(),
|
||||||
})
|
})
|
||||||
h, err := divert.Open(filter)
|
log.Printf("engine: socket filter: \"true\" (capture-all, PID-filter in-process)")
|
||||||
if err != nil {
|
log.Printf("engine: network filter: %s", netFilter)
|
||||||
r.Close()
|
|
||||||
return fmt.Errorf("WinDivert open: %w", err)
|
|
||||||
}
|
|
||||||
e.handle = h
|
|
||||||
|
|
||||||
// 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.ctx, e.cnl = context.WithCancel(context.Background())
|
||||||
e.wg.Add(2)
|
e.wg.Add(3)
|
||||||
|
go e.socketLoop()
|
||||||
go e.diverterLoop()
|
go e.diverterLoop()
|
||||||
go e.procscanLoop()
|
go e.procscanLoop()
|
||||||
|
log.Printf("engine: bringUp complete, transitioning to Active")
|
||||||
return nil
|
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
|
// Stop tears down. Always returns to Idle (or stays in Idle if
|
||||||
// already there).
|
// already there).
|
||||||
func (e *Engine) Stop() error {
|
func (e *Engine) Stop() error {
|
||||||
@@ -197,49 +341,280 @@ func (e *Engine) Stop() error {
|
|||||||
if e.handle != nil {
|
if e.handle != nil {
|
||||||
e.handle.Close()
|
e.handle.Close()
|
||||||
}
|
}
|
||||||
|
if e.flowH != nil {
|
||||||
|
e.flowH.Close()
|
||||||
|
}
|
||||||
|
if e.udp != nil {
|
||||||
|
e.udp.Close()
|
||||||
|
}
|
||||||
if e.redir != nil {
|
if e.redir != nil {
|
||||||
e.redir.Close()
|
e.redir.Close()
|
||||||
}
|
}
|
||||||
e.wg.Wait()
|
e.wg.Wait()
|
||||||
e.handle = nil
|
e.handle = nil
|
||||||
|
e.flowH = nil
|
||||||
e.redir = nil
|
e.redir = nil
|
||||||
|
e.udp = nil
|
||||||
e.transition(StatusIdle, nil)
|
e.transition(StatusIdle, nil)
|
||||||
return 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() {
|
func (e *Engine) diverterLoop() {
|
||||||
defer e.wg.Done()
|
defer e.wg.Done()
|
||||||
|
log.Printf("engine: diverterLoop started")
|
||||||
buf := make([]byte, 65536)
|
buf := make([]byte, 65536)
|
||||||
listenerPort := e.redir.LocalPort()
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-e.ctx.Done():
|
case <-e.ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
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 err != nil {
|
||||||
if errors.Is(err, divert.ErrShutdown) || errors.Is(err, divert.ErrInvalidHandle) {
|
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)
|
e.transition(StatusFailed, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("engine: diverterLoop transient Recv error: %v", err)
|
||||||
continue
|
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])
|
info, err := divert.ParseIPv4TCP(buf[:n])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not parseable — reinject as-is.
|
// Not IPv4-TCP and not handled by UDP path above. Reinject
|
||||||
_, _ = e.handle.Send(buf[:n], addr)
|
// as-is so non-target traffic continues normally.
|
||||||
|
_, _ = h.Send(buf[:n], addr)
|
||||||
continue
|
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
|
localV4 := e.localIP.To4()
|
||||||
if err := divert.RewriteDst(buf[:n], net.IPv4(127, 0, 0, 1), listenerPort); err == nil {
|
srcV4 := info.SrcIP.To4()
|
||||||
_, _ = e.handle.Send(buf[:n], addr)
|
|
||||||
|
// === 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 {
|
if len(add) == 0 && len(rem) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Rebuild filter + reopen handle
|
log.Printf("engine: procscan delta added=%v removed=%v", add, rem)
|
||||||
pidList := make([]uint32, 0, len(cur))
|
|
||||||
for p := range cur {
|
// Update targetPIDs map. flowLoop reads it on every event;
|
||||||
pidList = append(pidList, p)
|
// no handle reopen needed (FLOW filter is "true").
|
||||||
|
e.pidMu.Lock()
|
||||||
|
for _, p := range add {
|
||||||
|
e.targetPIDs[p] = struct{}{}
|
||||||
}
|
}
|
||||||
filter := divert.BuildFilter(divert.FilterParams{
|
for _, p := range rem {
|
||||||
TargetPIDs: pidList,
|
delete(e.targetPIDs, p)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
oldH := e.handle
|
e.pidMu.Unlock()
|
||||||
e.handle = newH
|
|
||||||
if oldH != nil {
|
// Drop tracked flows for the removed PIDs. We don't actually
|
||||||
oldH.Close()
|
// 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
|
prev = cur
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package gui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -175,9 +176,11 @@ func (a *App) CancelCheck() {
|
|||||||
|
|
||||||
// StartEngine initializes and brings up the engine with the given config.
|
// StartEngine initializes and brings up the engine with the given config.
|
||||||
func (a *App) StartEngine(cfg Config) error {
|
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()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
if a.eng != nil && a.eng.Status() == engine.StatusActive {
|
if a.eng != nil && a.eng.Status() == engine.StatusActive {
|
||||||
|
log.Printf("gui: StartEngine no-op (already active)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
e, err := engine.New(engine.Config{
|
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"},
|
Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
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()})
|
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := e.Start(a.ctx); err != nil {
|
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()})
|
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.eng = e
|
a.eng = e
|
||||||
a.startedAt = time.Now()
|
a.startedAt = time.Now()
|
||||||
|
log.Printf("gui: engine started, status=%s", e.Status())
|
||||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
|
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package procscan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolvePID returns the exe basename for a given PID, or an error
|
||||||
|
// if the PID has already exited or we lack the rights to query it.
|
||||||
|
//
|
||||||
|
// Used by the engine's socketLoop to do a lazy lookup when SOCKET
|
||||||
|
// Connect events arrive for processes we haven't yet seen via the
|
||||||
|
// 2-second procscan tick — Update.exe's full lifecycle (spawn →
|
||||||
|
// connect → exit) routinely fits inside one tick window, so without
|
||||||
|
// this lookup the engine would miss its connections entirely and
|
||||||
|
// Discord's "Checking for updates" would hit its 30 s timeout.
|
||||||
|
func ResolvePID(pid uint32) (string, error) {
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("OpenProcess pid=%d: %w", pid, err)
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
|
||||||
|
buf := make([]uint16, windows.MAX_PATH)
|
||||||
|
size := uint32(len(buf))
|
||||||
|
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
|
||||||
|
return "", fmt.Errorf("QueryFullProcessImageName pid=%d: %w", pid, err)
|
||||||
|
}
|
||||||
|
full := syscall.UTF16ToString(buf[:size])
|
||||||
|
return filepath.Base(full), nil
|
||||||
|
}
|
||||||
@@ -78,6 +78,21 @@ func (r *Redirector) SetMapping(srcPort uint16, dstIP net.IP, dstPort uint16) {
|
|||||||
r.mu.Unlock()
|
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.
|
// Close stops accepting and tears down active flows.
|
||||||
func (r *Redirector) Close() error {
|
func (r *Redirector) Close() error {
|
||||||
r.cnl()
|
r.cnl()
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package redirect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.okcu.io/root/drover-go/internal/divert"
|
||||||
|
"git.okcu.io/root/drover-go/internal/socks5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UDPInjector is the minimal subset of *divert.Handle the UDPProxy
|
||||||
|
// needs to reinject return-path packets. Defined as an interface so
|
||||||
|
// tests can stub it out without spinning up a real WinDivert handle.
|
||||||
|
type UDPInjector interface {
|
||||||
|
Send(buf []byte, addr UDPInjectAddr) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPInjectAddr describes the WinDivert addr fields that matter for
|
||||||
|
// reinjection (we don't need the full 64-byte union here — only flags
|
||||||
|
// determine direction + checksum status). Production code uses the
|
||||||
|
// adapter (see DivertHandleInjector) to convert between this and the
|
||||||
|
// real *idivert.Address.
|
||||||
|
type UDPInjectAddr struct {
|
||||||
|
// Outbound=false → packet will be delivered as inbound (kernel
|
||||||
|
// rcv path), which is exactly what we want when fabricating a
|
||||||
|
// "remote → local" reply for Discord.
|
||||||
|
Outbound bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPConfig configures the UDPProxy.
|
||||||
|
type UDPConfig struct {
|
||||||
|
SOCKS5 socks5.Config
|
||||||
|
LocalIP net.IP // local LAN IP we use as the dst on fabricated reply packets
|
||||||
|
|
||||||
|
// Injector is used to reinject return-path packets back to Discord
|
||||||
|
// via the WinDivert NETWORK handle. Required.
|
||||||
|
Injector UDPInjector
|
||||||
|
|
||||||
|
// LogPrefix is prepended to all log lines emitted by the proxy.
|
||||||
|
// Empty defaults to "udp-proxy: ".
|
||||||
|
LogPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// udpFlow tracks one (Discord_src → real_dst) UDP flow for the
|
||||||
|
// purpose of routing relay responses back to Discord.
|
||||||
|
type udpFlow struct {
|
||||||
|
// realDst* identifies the upstream UDP target (the same key the
|
||||||
|
// SOCKS5 relay puts in DST.ADDR/DST.PORT on the inbound envelope).
|
||||||
|
realDstIP [4]byte
|
||||||
|
realDstPort uint16
|
||||||
|
|
||||||
|
// discordSrc* identifies the Discord side of the flow — used as
|
||||||
|
// the dst on fabricated reply packets so the kernel matches the
|
||||||
|
// connect()-bound socket.
|
||||||
|
discordSrcIP [4]byte
|
||||||
|
discordSrcPort uint16
|
||||||
|
|
||||||
|
lastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPProxy is the SOCKS5 UDP relay manager. The engine's diverterLoop
|
||||||
|
// calls Forward on outbound UDP packets from target processes; the
|
||||||
|
// proxy lazily opens a single UDP ASSOCIATE control TCP + relay UDP
|
||||||
|
// socket on first use, and shares them across all UDP flows. Inbound
|
||||||
|
// responses are read from the relay socket, decap'd, and reinjected
|
||||||
|
// as fabricated IPv4+UDP packets via the WinDivert NETWORK handle.
|
||||||
|
type UDPProxy struct {
|
||||||
|
cfg UDPConfig
|
||||||
|
|
||||||
|
// Lazy-opened on first Forward call.
|
||||||
|
ctrlMu sync.Mutex
|
||||||
|
ctrlConn net.Conn // SOCKS5 control TCP — must stay open for relay validity
|
||||||
|
relayAddr *net.UDPAddr // upstream relay UDP endpoint
|
||||||
|
relayConn net.PacketConn // local UDP socket bound to talk to relay
|
||||||
|
|
||||||
|
flowMu sync.RWMutex
|
||||||
|
// Keyed by realDstIP:realDstPort — the relay responds with these
|
||||||
|
// in the SOCKS5 envelope, so this is our reverse lookup.
|
||||||
|
flowsByDst map[flowDstKey]*udpFlow
|
||||||
|
|
||||||
|
// Atomic stats counters for diagnostics
|
||||||
|
fwdPackets uint64
|
||||||
|
fwdBytes uint64
|
||||||
|
recvPackets uint64
|
||||||
|
injectedPackets uint64
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
// Idle TTL for udpFlow entries (default 5 minutes per RFC 4787).
|
||||||
|
IdleTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type flowDstKey struct {
|
||||||
|
ip [4]byte
|
||||||
|
port uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUDP constructs a UDPProxy. It does not yet open the SOCKS5 UDP
|
||||||
|
// ASSOCIATE — that happens lazily on the first Forward call.
|
||||||
|
func NewUDP(cfg UDPConfig) (*UDPProxy, error) {
|
||||||
|
if cfg.Injector == nil {
|
||||||
|
return nil, errors.New("UDPConfig.Injector is required")
|
||||||
|
}
|
||||||
|
if cfg.LocalIP == nil || cfg.LocalIP.To4() == nil {
|
||||||
|
return nil, errors.New("UDPConfig.LocalIP must be IPv4")
|
||||||
|
}
|
||||||
|
if cfg.LogPrefix == "" {
|
||||||
|
cfg.LogPrefix = "udp-proxy: "
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
u := &UDPProxy{
|
||||||
|
cfg: cfg,
|
||||||
|
flowsByDst: map[flowDstKey]*udpFlow{},
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
IdleTTL: 5 * time.Minute,
|
||||||
|
}
|
||||||
|
u.wg.Add(1)
|
||||||
|
go u.sweepLoop()
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward is called from the engine's diverterLoop on each outbound
|
||||||
|
// UDP packet from a target process. It:
|
||||||
|
//
|
||||||
|
// 1. Lazy-opens the SOCKS5 UDP association on first call.
|
||||||
|
// 2. Records the flow keyed by (dstIP,dstPort) so the relay-response
|
||||||
|
// reader can route the reply back to the right Discord port.
|
||||||
|
// 3. Encapsulates the payload in a SOCKS5 UDP datagram (RFC 1928 §7)
|
||||||
|
// and forwards it to the relay endpoint.
|
||||||
|
//
|
||||||
|
// Returns nil on success or any error encountered (caller may log
|
||||||
|
// but should generally drop the packet on failure — UDP loss is
|
||||||
|
// expected at the wire).
|
||||||
|
func (u *UDPProxy) Forward(srcIP net.IP, srcPort uint16, dstIP net.IP, dstPort uint16, payload []byte) error {
|
||||||
|
srcV4 := srcIP.To4()
|
||||||
|
dstV4 := dstIP.To4()
|
||||||
|
if srcV4 == nil || dstV4 == nil {
|
||||||
|
return errors.New("UDPProxy.Forward: src/dst must be IPv4")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.ensureAssociated(); err != nil {
|
||||||
|
return fmt.Errorf("ensure assoc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record/refresh flow for the return path
|
||||||
|
var dKey flowDstKey
|
||||||
|
copy(dKey.ip[:], dstV4)
|
||||||
|
dKey.port = dstPort
|
||||||
|
|
||||||
|
u.flowMu.Lock()
|
||||||
|
fl, ok := u.flowsByDst[dKey]
|
||||||
|
if !ok {
|
||||||
|
fl = &udpFlow{}
|
||||||
|
u.flowsByDst[dKey] = fl
|
||||||
|
}
|
||||||
|
copy(fl.realDstIP[:], dstV4)
|
||||||
|
fl.realDstPort = dstPort
|
||||||
|
copy(fl.discordSrcIP[:], srcV4)
|
||||||
|
fl.discordSrcPort = srcPort
|
||||||
|
fl.lastUsed = time.Now()
|
||||||
|
u.flowMu.Unlock()
|
||||||
|
|
||||||
|
// Encap and send to relay
|
||||||
|
envelope, err := socks5.EncapUDPv4(dstIP, dstPort, payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encap: %w", err)
|
||||||
|
}
|
||||||
|
n, err := u.relayConn.WriteTo(envelope, u.relayAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write to relay: %w", err)
|
||||||
|
}
|
||||||
|
atomic.AddUint64(&u.fwdPackets, 1)
|
||||||
|
atomic.AddUint64(&u.fwdBytes, uint64(n))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns counters for diagnostics: forwarded outbound packets,
|
||||||
|
// inbound packets received from relay, inbound packets successfully
|
||||||
|
// reinjected to Discord. All atomic; safe to read concurrently.
|
||||||
|
func (u *UDPProxy) Stats() (fwdPkts, fwdBytes, recvPkts, injectedPkts uint64) {
|
||||||
|
return atomic.LoadUint64(&u.fwdPackets),
|
||||||
|
atomic.LoadUint64(&u.fwdBytes),
|
||||||
|
atomic.LoadUint64(&u.recvPackets),
|
||||||
|
atomic.LoadUint64(&u.injectedPackets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAssociated opens the SOCKS5 UDP association on first use and
|
||||||
|
// reuses it forever (until Close). The relay endpoint stays valid as
|
||||||
|
// long as the control TCP is open, per RFC 1928 §6.
|
||||||
|
func (u *UDPProxy) ensureAssociated() error {
|
||||||
|
u.ctrlMu.Lock()
|
||||||
|
defer u.ctrlMu.Unlock()
|
||||||
|
if u.ctrlConn != nil && u.relayAddr != nil && u.relayConn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
relay, ctrl, err := socks5.AssociateUDP(ctx, u.cfg.SOCKS5)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind a local UDP socket to talk to the relay. Bind on 0.0.0.0:0
|
||||||
|
// so the kernel picks an ephemeral port; we'll use this socket as
|
||||||
|
// both the writer (Forward) AND the reader (relayReadLoop).
|
||||||
|
pc, err := net.ListenPacket("udp4", ":0")
|
||||||
|
if err != nil {
|
||||||
|
ctrl.Close()
|
||||||
|
return fmt.Errorf("listen relay socket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.ctrlConn = ctrl
|
||||||
|
u.relayAddr = relay
|
||||||
|
u.relayConn = pc
|
||||||
|
|
||||||
|
log.Printf("%sSOCKS5 UDP ASSOCIATE relay=%s local=%s", u.cfg.LogPrefix, relay, pc.LocalAddr())
|
||||||
|
|
||||||
|
// Spawn the reader goroutine.
|
||||||
|
u.wg.Add(1)
|
||||||
|
go u.relayReadLoop()
|
||||||
|
|
||||||
|
// Spawn a control-conn watcher: if the proxy closes the control
|
||||||
|
// TCP for any reason, our relay endpoint is invalidated. Mark
|
||||||
|
// state for re-association on next Forward.
|
||||||
|
u.wg.Add(1)
|
||||||
|
go u.ctrlWatcher()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UDPProxy) ctrlWatcher() {
|
||||||
|
defer u.wg.Done()
|
||||||
|
// Read forever from ctrlConn; per RFC 1928 §6 the proxy doesn't
|
||||||
|
// send anything on this conn after the UDP ASSOCIATE reply, so
|
||||||
|
// any read-completion (with or without bytes) means the conn is
|
||||||
|
// gone. This is a fire-and-forget watcher — it doesn't actively
|
||||||
|
// re-associate; ensureAssociated() will do that on next Forward.
|
||||||
|
one := make([]byte, 1)
|
||||||
|
for {
|
||||||
|
// Use a generous read deadline so we wake up periodically to
|
||||||
|
// honor ctx cancellation.
|
||||||
|
_ = u.ctrlConn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
_, err := u.ctrlConn.Read(one)
|
||||||
|
if err == nil {
|
||||||
|
continue // unexpected data; keep monitoring
|
||||||
|
}
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
select {
|
||||||
|
case <-u.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Real error — control conn is dead. Tear down so next Forward
|
||||||
|
// re-associates.
|
||||||
|
log.Printf("%scontrol TCP closed: %v — relay invalidated", u.cfg.LogPrefix, err)
|
||||||
|
u.ctrlMu.Lock()
|
||||||
|
if u.ctrlConn != nil {
|
||||||
|
u.ctrlConn.Close()
|
||||||
|
u.ctrlConn = nil
|
||||||
|
}
|
||||||
|
if u.relayConn != nil {
|
||||||
|
u.relayConn.Close()
|
||||||
|
u.relayConn = nil
|
||||||
|
}
|
||||||
|
u.relayAddr = nil
|
||||||
|
u.ctrlMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayReadLoop reads inbound datagrams from the relay socket.
|
||||||
|
// Datagrams from the relay are SOCKS5 UDP envelopes (RFC 1928 §7);
|
||||||
|
// we decap, look up the corresponding Discord flow by the envelope's
|
||||||
|
// DST.ADDR/DST.PORT (which contains the ORIGIN of the response), and
|
||||||
|
// reinject a fabricated IPv4+UDP packet as inbound via WinDivert.
|
||||||
|
func (u *UDPProxy) relayReadLoop() {
|
||||||
|
defer u.wg.Done()
|
||||||
|
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-u.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
// Snapshot relay conn under lock; if torn down by ctrlWatcher
|
||||||
|
// we need to bail out.
|
||||||
|
u.ctrlMu.Lock()
|
||||||
|
pc := u.relayConn
|
||||||
|
relay := u.relayAddr
|
||||||
|
u.ctrlMu.Unlock()
|
||||||
|
if pc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = pc.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
n, fromAddr, err := pc.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Likely closed — exit.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atomic.AddUint64(&u.recvPackets, 1)
|
||||||
|
|
||||||
|
// Sanity-check source: relay datagrams come from the relay's
|
||||||
|
// known address. Ignore anything else (in particular some
|
||||||
|
// SOCKS5 implementations bind 0.0.0.0; we accept any port match
|
||||||
|
// loosely, but require IP match when available).
|
||||||
|
fromUDP, ok := fromAddr.(*net.UDPAddr)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if relay != nil && relay.IP != nil && !relay.IP.Equal(net.IPv4zero) {
|
||||||
|
if !fromUDP.IP.Equal(relay.IP) || fromUDP.Port != relay.Port {
|
||||||
|
// Not from our relay — drop.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srcIP, srcPort, payload, derr := socks5.DecapUDPv4(buf[:n])
|
||||||
|
if derr != nil {
|
||||||
|
log.Printf("%sdecap error: %v", u.cfg.LogPrefix, derr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the Discord flow by (origin IP, origin port)
|
||||||
|
v4 := srcIP.To4()
|
||||||
|
if v4 == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var key flowDstKey
|
||||||
|
copy(key.ip[:], v4)
|
||||||
|
key.port = srcPort
|
||||||
|
|
||||||
|
u.flowMu.RLock()
|
||||||
|
fl, ok := u.flowsByDst[key]
|
||||||
|
u.flowMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
// No active flow for this origin; drop.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the flow as recently used (touched by inbound).
|
||||||
|
u.flowMu.Lock()
|
||||||
|
fl.lastUsed = time.Now()
|
||||||
|
u.flowMu.Unlock()
|
||||||
|
|
||||||
|
// Fabricate IPv4+UDP packet:
|
||||||
|
// src = real_origin (the proxy's relay tells us this in the envelope)
|
||||||
|
// dst = local LAN IP we bound on
|
||||||
|
// srcPort = real origin port
|
||||||
|
// dstPort = Discord ephemeral port (so kernel matches the connect()-bound socket)
|
||||||
|
discordIP := net.IPv4(fl.discordSrcIP[0], fl.discordSrcIP[1], fl.discordSrcIP[2], fl.discordSrcIP[3])
|
||||||
|
// Some Discord sockets bind to local LAN IP, others bind 0.0.0.0
|
||||||
|
// (which the SOCKET layer reports as 0.0.0.0). When discord's
|
||||||
|
// reported srcIP is 0.0.0.0 the kernel's connect-bound socket
|
||||||
|
// will still match dst=our LocalIP. But to be safe for the
|
||||||
|
// non-zero case (sockets bound to specific local IP), use the
|
||||||
|
// recorded discord side IP if it is non-zero; otherwise fall
|
||||||
|
// back to LocalIP.
|
||||||
|
dstIP := discordIP
|
||||||
|
if discordIP.Equal(net.IPv4zero) {
|
||||||
|
dstIP = u.cfg.LocalIP
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt, berr := divert.BuildIPv4UDPInbound(srcIP, dstIP, srcPort, fl.discordSrcPort, payload)
|
||||||
|
if berr != nil {
|
||||||
|
log.Printf("%sbuild packet error: %v", u.cfg.LogPrefix, berr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinject as inbound. WinDivert flag bits we set: IPChecksum
|
||||||
|
// (we computed it), UDPChecksum (we computed it). Outbound bit
|
||||||
|
// stays clear — kernel delivers via inbound path.
|
||||||
|
if _, serr := u.cfg.Injector.Send(pkt, UDPInjectAddr{Outbound: false}); serr != nil {
|
||||||
|
log.Printf("%sinject error: %v", u.cfg.LogPrefix, serr)
|
||||||
|
} else {
|
||||||
|
atomic.AddUint64(&u.injectedPackets, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLoop garbage-collects stale udpFlow entries. UDP "flows" are
|
||||||
|
// stateless — there's no FIN-equivalent — so we rely on idle timeout.
|
||||||
|
// 5 minutes matches RFC 4787 NAT requirements (REQ-5).
|
||||||
|
func (u *UDPProxy) sweepLoop() {
|
||||||
|
defer u.wg.Done()
|
||||||
|
tk := time.NewTicker(time.Minute)
|
||||||
|
defer tk.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-u.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tk.C:
|
||||||
|
cutoff := time.Now().Add(-u.IdleTTL)
|
||||||
|
u.flowMu.Lock()
|
||||||
|
for k, f := range u.flowsByDst {
|
||||||
|
if f.lastUsed.Before(cutoff) {
|
||||||
|
delete(u.flowsByDst, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.flowMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tears down the UDPProxy: cancels reader goroutines, closes
|
||||||
|
// the relay UDP socket and the SOCKS5 control TCP. Safe to call
|
||||||
|
// multiple times.
|
||||||
|
func (u *UDPProxy) Close() error {
|
||||||
|
u.cancel()
|
||||||
|
u.ctrlMu.Lock()
|
||||||
|
if u.relayConn != nil {
|
||||||
|
_ = u.relayConn.Close()
|
||||||
|
u.relayConn = nil
|
||||||
|
}
|
||||||
|
if u.ctrlConn != nil {
|
||||||
|
_ = u.ctrlConn.Close()
|
||||||
|
u.ctrlConn = nil
|
||||||
|
}
|
||||||
|
u.relayAddr = nil
|
||||||
|
u.ctrlMu.Unlock()
|
||||||
|
u.wg.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlowCount returns the current number of tracked UDP flows. Test
|
||||||
|
// helper.
|
||||||
|
func (u *UDPProxy) FlowCount() int {
|
||||||
|
u.flowMu.RLock()
|
||||||
|
defer u.flowMu.RUnlock()
|
||||||
|
return len(u.flowsByDst)
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package redirect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.okcu.io/root/drover-go/internal/socks5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeInjector captures injected packets for assertions.
|
||||||
|
type fakeInjector struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
packets [][]byte
|
||||||
|
addrs []UDPInjectAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeInjector) Send(buf []byte, addr UDPInjectAddr) (int, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
cp := make([]byte, len(buf))
|
||||||
|
copy(cp, buf)
|
||||||
|
f.packets = append(f.packets, cp)
|
||||||
|
f.addrs = append(f.addrs, addr)
|
||||||
|
f.mu.Unlock()
|
||||||
|
return len(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeInjector) packetsLen() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.packets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeInjector) get(idx int) ([]byte, UDPInjectAddr) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.packets[idx], f.addrs[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// startUDPRelayProxy starts a fake SOCKS5 proxy with UDP ASSOCIATE
|
||||||
|
// support. It echoes any datagram it receives on the relay back to
|
||||||
|
// the sender, with the SOCKS5 envelope's DST.ADDR/DST.PORT preserved.
|
||||||
|
// The "echoOrigin" return-path is what the real upstream relay does:
|
||||||
|
// when an upstream UDP server responds, the proxy puts that server's
|
||||||
|
// addr in DST.ADDR/DST.PORT for the inbound envelope.
|
||||||
|
func startUDPRelayProxy(t *testing.T) (tcpAddr string, relay *net.UDPConn) {
|
||||||
|
tcpLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { tcpLn.Close() })
|
||||||
|
|
||||||
|
relay, err = net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { relay.Close() })
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, err := tcpLn.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
// Greet
|
||||||
|
io.ReadFull(c, buf[:2])
|
||||||
|
nm := int(buf[1])
|
||||||
|
io.ReadFull(c, buf[:nm])
|
||||||
|
c.Write([]byte{0x05, 0x00})
|
||||||
|
// UDP ASSOCIATE
|
||||||
|
io.ReadFull(c, buf[:4])
|
||||||
|
if buf[1] != 0x03 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atyp := buf[3]
|
||||||
|
switch atyp {
|
||||||
|
case 1:
|
||||||
|
io.ReadFull(c, buf[:4])
|
||||||
|
case 3:
|
||||||
|
io.ReadFull(c, buf[:1])
|
||||||
|
io.ReadFull(c, buf[:int(buf[0])])
|
||||||
|
}
|
||||||
|
io.ReadFull(c, buf[:2])
|
||||||
|
// Reply with relay addr
|
||||||
|
ra := relay.LocalAddr().(*net.UDPAddr)
|
||||||
|
rep := []byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}
|
||||||
|
copy(rep[4:8], ra.IP.To4())
|
||||||
|
binary.BigEndian.PutUint16(rep[8:10], uint16(ra.Port))
|
||||||
|
c.Write(rep)
|
||||||
|
// Hold open
|
||||||
|
io.Copy(io.Discard, c)
|
||||||
|
}(c)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return tcpLn.Addr().String(), relay
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUDPProxy_ForwardEncapsulates(t *testing.T) {
|
||||||
|
tcpAddr, relay := startUDPRelayProxy(t)
|
||||||
|
inj := &fakeInjector{}
|
||||||
|
|
||||||
|
u, err := NewUDP(UDPConfig{
|
||||||
|
SOCKS5: socks5.Config{ProxyAddr: tcpAddr},
|
||||||
|
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||||
|
Injector: inj,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { u.Close() })
|
||||||
|
|
||||||
|
// Forward a packet and verify the relay receives it encapsulated.
|
||||||
|
srcIP := net.IPv4(127, 0, 0, 1)
|
||||||
|
dstIP := net.IPv4(140, 82, 121, 4)
|
||||||
|
payload := []byte("hello voice")
|
||||||
|
require.NoError(t, u.Forward(srcIP, 50100, dstIP, 50007, payload))
|
||||||
|
|
||||||
|
// Read from the relay to verify the SOCKS5 envelope.
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
_ = relay.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
n, _, err := relay.ReadFromUDP(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := buf[:n]
|
||||||
|
gotIP, gotPort, gotPayload, err := socks5.DecapUDPv4(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "140.82.121.4", gotIP.String())
|
||||||
|
assert.Equal(t, uint16(50007), gotPort)
|
||||||
|
assert.Equal(t, payload, gotPayload)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, u.FlowCount(), "should have one tracked flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUDPProxy_RelayResponseInjectsBackToDiscord(t *testing.T) {
|
||||||
|
tcpAddr, relay := startUDPRelayProxy(t)
|
||||||
|
inj := &fakeInjector{}
|
||||||
|
|
||||||
|
u, err := NewUDP(UDPConfig{
|
||||||
|
SOCKS5: socks5.Config{ProxyAddr: tcpAddr},
|
||||||
|
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||||
|
Injector: inj,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { u.Close() })
|
||||||
|
|
||||||
|
// Establish a flow by forwarding one packet
|
||||||
|
discordSrcIP := net.IPv4(127, 0, 0, 1)
|
||||||
|
discordSrcPort := uint16(50100)
|
||||||
|
realDstIP := net.IPv4(140, 82, 121, 4)
|
||||||
|
realDstPort := uint16(50007)
|
||||||
|
require.NoError(t, u.Forward(discordSrcIP, discordSrcPort, realDstIP, realDstPort, []byte("hi")))
|
||||||
|
|
||||||
|
// Drain the encapsulated forward
|
||||||
|
drainBuf := make([]byte, 1500)
|
||||||
|
_ = relay.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
_, clientRelayAddr, err := relay.ReadFromUDP(drainBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Simulate upstream UDP server response: relay sends back an
|
||||||
|
// envelope where DST.ADDR/DST.PORT = real upstream origin.
|
||||||
|
respPayload := []byte("voice response")
|
||||||
|
envelope, err := socks5.EncapUDPv4(realDstIP, realDstPort, respPayload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = relay.WriteToUDP(envelope, clientRelayAddr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The proxy's relayReadLoop should receive, decap, and inject.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && inj.packetsLen() == 0 {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, inj.packetsLen(), "expected one injected packet")
|
||||||
|
|
||||||
|
pkt, addr := inj.get(0)
|
||||||
|
assert.False(t, addr.Outbound, "injected as inbound")
|
||||||
|
|
||||||
|
// Parse the fabricated IPv4+UDP packet
|
||||||
|
require.GreaterOrEqual(t, len(pkt), 28)
|
||||||
|
// Verify proto=UDP
|
||||||
|
assert.Equal(t, byte(17), pkt[9], "IPv4 proto field")
|
||||||
|
srcIP := net.IPv4(pkt[12], pkt[13], pkt[14], pkt[15])
|
||||||
|
dstIP := net.IPv4(pkt[16], pkt[17], pkt[18], pkt[19])
|
||||||
|
srcPort := binary.BigEndian.Uint16(pkt[20:22])
|
||||||
|
dstPort := binary.BigEndian.Uint16(pkt[22:24])
|
||||||
|
|
||||||
|
assert.Equal(t, "140.82.121.4", srcIP.String(), "fabricated src = real upstream origin")
|
||||||
|
assert.Equal(t, "127.0.0.1", dstIP.String(), "fabricated dst = Discord-side IP")
|
||||||
|
assert.Equal(t, realDstPort, srcPort, "fabricated src port = real upstream port")
|
||||||
|
assert.Equal(t, discordSrcPort, dstPort, "fabricated dst port = Discord ephemeral port")
|
||||||
|
|
||||||
|
// Payload after IPv4(20)+UDP(8) headers
|
||||||
|
assert.Equal(t, respPayload, pkt[28:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUDPProxy_NoFlowDropsResponse(t *testing.T) {
|
||||||
|
tcpAddr, relay := startUDPRelayProxy(t)
|
||||||
|
inj := &fakeInjector{}
|
||||||
|
|
||||||
|
u, err := NewUDP(UDPConfig{
|
||||||
|
SOCKS5: socks5.Config{ProxyAddr: tcpAddr},
|
||||||
|
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||||
|
Injector: inj,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { u.Close() })
|
||||||
|
|
||||||
|
// Force association without registering any flow.
|
||||||
|
require.NoError(t, u.ensureAssociated())
|
||||||
|
|
||||||
|
// Read the local relay socket's port and substitute 127.0.0.1 for
|
||||||
|
// 0.0.0.0 (kernel binds wildcard but Windows refuses to send TO
|
||||||
|
// 0.0.0.0:N — it requires a routable destination).
|
||||||
|
localAddr := u.relayConn.LocalAddr().(*net.UDPAddr)
|
||||||
|
dst := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: localAddr.Port}
|
||||||
|
|
||||||
|
// Send a "stray" relay datagram with an origin we never registered.
|
||||||
|
envelope, _ := socks5.EncapUDPv4(net.IPv4(8, 8, 8, 8), 53, []byte("dns"))
|
||||||
|
_, err = relay.WriteToUDP(envelope, dst)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Give the reader time to process and drop.
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
assert.Equal(t, 0, inj.packetsLen(), "stray response should be dropped, not injected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUDPProxy_RejectsIPv6(t *testing.T) {
|
||||||
|
inj := &fakeInjector{}
|
||||||
|
u, err := NewUDP(UDPConfig{
|
||||||
|
SOCKS5: socks5.Config{ProxyAddr: "127.0.0.1:0"},
|
||||||
|
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||||
|
Injector: inj,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { u.Close() })
|
||||||
|
|
||||||
|
v6 := net.ParseIP("::1")
|
||||||
|
err = u.Forward(net.IPv4(1, 2, 3, 4), 1000, v6, 80, []byte("x"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewUDP_RejectsNilInjector(t *testing.T) {
|
||||||
|
_, err := NewUDP(UDPConfig{
|
||||||
|
LocalIP: net.IPv4(127, 0, 0, 1),
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewUDP_RejectsNonIPv4LocalIP(t *testing.T) {
|
||||||
|
_, err := NewUDP(UDPConfig{
|
||||||
|
LocalIP: net.ParseIP("::1"),
|
||||||
|
Injector: &fakeInjector{},
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package socks5
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssociateUDP opens a TCP control conn to the upstream SOCKS5 proxy,
|
||||||
|
// runs greeting + (optional) auth + UDP ASSOCIATE (CMD=03), and returns:
|
||||||
|
//
|
||||||
|
// - the relay UDP endpoint (host:port the proxy bound for our datagrams)
|
||||||
|
// - the kept-open control TCP (caller MUST keep open for the lifetime
|
||||||
|
// of the UDP association — closing it tears down the relay on the
|
||||||
|
// proxy side per RFC 1928 §6).
|
||||||
|
//
|
||||||
|
// The given ctx bounds dial + handshake; once AssociateUDP returns,
|
||||||
|
// ctrl has its deadline cleared.
|
||||||
|
//
|
||||||
|
// If the proxy replies BND.ADDR == 0.0.0.0 (some implementations do
|
||||||
|
// this to mean "use the same IP you connected to"), we substitute the
|
||||||
|
// proxy host's resolved IP.
|
||||||
|
func AssociateUDP(ctx context.Context, cfg Config) (relay *net.UDPAddr, ctrl net.Conn, err error) {
|
||||||
|
d := net.Dialer{}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", cfg.ProxyAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("dial proxy: %w", err)
|
||||||
|
}
|
||||||
|
if dl, ok := ctx.Deadline(); ok {
|
||||||
|
_ = conn.SetDeadline(dl)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Greeting (same as TCP CONNECT path)
|
||||||
|
if cfg.UseAuth {
|
||||||
|
if _, werr := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); werr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("greet write: %w", werr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, werr := conn.Write([]byte{0x05, 0x01, 0x00}); werr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("greet write: %w", werr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rep [2]byte
|
||||||
|
if _, rerr := io.ReadFull(conn, rep[:]); rerr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("greet read: %w", rerr)
|
||||||
|
}
|
||||||
|
if rep[0] != 0x05 {
|
||||||
|
return nil, nil, fmt.Errorf("greet: server version %#x is not SOCKS5", rep[0])
|
||||||
|
}
|
||||||
|
if rep[1] == 0xff {
|
||||||
|
return nil, nil, errors.New("greet: proxy rejected all offered auth methods")
|
||||||
|
}
|
||||||
|
method := rep[1]
|
||||||
|
|
||||||
|
if method == 0x02 {
|
||||||
|
if !cfg.UseAuth {
|
||||||
|
return nil, nil, errors.New("proxy requires auth but Config.UseAuth is false")
|
||||||
|
}
|
||||||
|
if len(cfg.Login) > 255 || len(cfg.Password) > 255 {
|
||||||
|
return nil, nil, errors.New("login or password too long")
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, 3+len(cfg.Login)+len(cfg.Password))
|
||||||
|
buf = append(buf, 0x01, byte(len(cfg.Login)))
|
||||||
|
buf = append(buf, []byte(cfg.Login)...)
|
||||||
|
buf = append(buf, byte(len(cfg.Password)))
|
||||||
|
buf = append(buf, []byte(cfg.Password)...)
|
||||||
|
if _, werr := conn.Write(buf); werr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("auth write: %w", werr)
|
||||||
|
}
|
||||||
|
if _, rerr := io.ReadFull(conn, rep[:]); rerr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("auth read: %w", rerr)
|
||||||
|
}
|
||||||
|
if rep[1] != 0x00 {
|
||||||
|
return nil, nil, errors.New("auth: invalid login or password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDP ASSOCIATE request: VER=05 CMD=03 RSV=00 ATYP=01 DST.ADDR=0.0.0.0 DST.PORT=0
|
||||||
|
req := []byte{0x05, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||||
|
if _, werr := conn.Write(req); werr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("udp-associate write: %w", werr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We accept ATYP=01 (IPv4) replies only — sufficient for our use
|
||||||
|
// case (mihomo + standard proxies). Reading 10 bytes covers exactly
|
||||||
|
// that case: VER REP RSV ATYP BND.ADDR(4) BND.PORT(2).
|
||||||
|
reply := make([]byte, 10)
|
||||||
|
if _, rerr := io.ReadFull(conn, reply); rerr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("udp-associate read: %w", rerr)
|
||||||
|
}
|
||||||
|
if reply[0] != 0x05 {
|
||||||
|
return nil, nil, fmt.Errorf("udp-associate: server version %#x is not SOCKS5", reply[0])
|
||||||
|
}
|
||||||
|
if reply[1] != 0x00 {
|
||||||
|
return nil, nil, fmt.Errorf("udp-associate: REP=%#02x", reply[1])
|
||||||
|
}
|
||||||
|
if reply[3] != 0x01 {
|
||||||
|
return nil, nil, fmt.Errorf("udp-associate: unsupported BND.ATYP=%#02x (need IPv4)", reply[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
bndIP := net.IPv4(reply[4], reply[5], reply[6], reply[7]).To4()
|
||||||
|
bndPort := binary.BigEndian.Uint16(reply[8:10])
|
||||||
|
|
||||||
|
// Per RFC 1928 §6 / common practice: BND.ADDR=0.0.0.0 means "use
|
||||||
|
// the same address you used to reach me". Substitute proxy host's
|
||||||
|
// IP from the established TCP conn's RemoteAddr.
|
||||||
|
if bndIP.Equal(net.IPv4zero.To4()) {
|
||||||
|
if ra, ok := conn.RemoteAddr().(*net.TCPAddr); ok && ra.IP != nil {
|
||||||
|
if v4 := ra.IP.To4(); v4 != nil {
|
||||||
|
bndIP = v4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear deadline so caller can use ctrl as-is (keepalive only).
|
||||||
|
_ = conn.SetDeadline(time.Time{})
|
||||||
|
|
||||||
|
return &net.UDPAddr{IP: bndIP, Port: int(bndPort)}, conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncapUDPv4 wraps an outbound UDP payload in the SOCKS5 UDP datagram
|
||||||
|
// envelope (RFC 1928 §7) for ATYP=01 (IPv4). The returned buffer has
|
||||||
|
// the form:
|
||||||
|
//
|
||||||
|
// RSV(2)=0000 | FRAG(1)=00 | ATYP(1)=01 | DST.ADDR(4) | DST.PORT(2) | DATA
|
||||||
|
//
|
||||||
|
// The 10-byte prefix tells the relay where to forward the datagram.
|
||||||
|
// Returns an error if dstIP is not IPv4.
|
||||||
|
func EncapUDPv4(dstIP net.IP, dstPort uint16, payload []byte) ([]byte, error) {
|
||||||
|
v4 := dstIP.To4()
|
||||||
|
if v4 == nil {
|
||||||
|
return nil, errors.New("EncapUDPv4: dst must be IPv4")
|
||||||
|
}
|
||||||
|
out := make([]byte, 10+len(payload))
|
||||||
|
out[0] = 0x00 // RSV
|
||||||
|
out[1] = 0x00 // RSV
|
||||||
|
out[2] = 0x00 // FRAG (no fragmentation)
|
||||||
|
out[3] = 0x01 // ATYP IPv4
|
||||||
|
copy(out[4:8], v4)
|
||||||
|
binary.BigEndian.PutUint16(out[8:10], dstPort)
|
||||||
|
copy(out[10:], payload)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecapUDPv4 parses an inbound SOCKS5 UDP datagram (RFC 1928 §7) for
|
||||||
|
// ATYP=01 (IPv4). On the inbound path the relay puts the ORIGIN's
|
||||||
|
// addr/port in DST.ADDR/DST.PORT — i.e. for us, the original DST that
|
||||||
|
// answered (e.g. the Discord voice server). The returned (srcIP,
|
||||||
|
// srcPort) reflect that origin; payload is the original UDP body.
|
||||||
|
//
|
||||||
|
// Errors when:
|
||||||
|
// - buf shorter than 10 bytes (truncated header)
|
||||||
|
// - FRAG != 0 (we don't reassemble fragments)
|
||||||
|
// - ATYP != 1 (we only handle IPv4 in this path)
|
||||||
|
func DecapUDPv4(buf []byte) (srcIP net.IP, srcPort uint16, payload []byte, err error) {
|
||||||
|
if len(buf) < 10 {
|
||||||
|
return nil, 0, nil, errors.New("DecapUDPv4: truncated header")
|
||||||
|
}
|
||||||
|
if buf[2] != 0x00 {
|
||||||
|
return nil, 0, nil, fmt.Errorf("DecapUDPv4: FRAG=%d not supported", buf[2])
|
||||||
|
}
|
||||||
|
if buf[3] != 0x01 {
|
||||||
|
return nil, 0, nil, fmt.Errorf("DecapUDPv4: ATYP=%#02x not IPv4", buf[3])
|
||||||
|
}
|
||||||
|
srcIP = net.IPv4(buf[4], buf[5], buf[6], buf[7])
|
||||||
|
srcPort = binary.BigEndian.Uint16(buf[8:10])
|
||||||
|
payload = buf[10:]
|
||||||
|
return srcIP, srcPort, payload, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package socks5
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeUDPProxy is a minimal SOCKS5 server that handles greet+(optional auth)
|
||||||
|
// then UDP ASSOCIATE — replying with a relay endpoint we control.
|
||||||
|
type fakeUDPProxy struct {
|
||||||
|
tcpAddr string
|
||||||
|
relay *net.UDPConn // bound on 127.0.0.1, ephemeral port
|
||||||
|
useAuth bool
|
||||||
|
login string
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func startFakeUDPProxy(t *testing.T, useAuth bool, login, password string) *fakeUDPProxy {
|
||||||
|
tcpLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { tcpLn.Close() })
|
||||||
|
|
||||||
|
relay, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { relay.Close() })
|
||||||
|
|
||||||
|
p := &fakeUDPProxy{
|
||||||
|
tcpAddr: tcpLn.Addr().String(),
|
||||||
|
relay: relay,
|
||||||
|
useAuth: useAuth, login: login, password: password,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, err := tcpLn.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go p.handle(c)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeUDPProxy) handle(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
|
||||||
|
// Greet
|
||||||
|
io.ReadFull(c, buf[:2])
|
||||||
|
nm := int(buf[1])
|
||||||
|
io.ReadFull(c, buf[:nm])
|
||||||
|
if p.useAuth {
|
||||||
|
c.Write([]byte{0x05, 0x02})
|
||||||
|
// Auth subneg: 01 ULEN UNAME PLEN PASS
|
||||||
|
io.ReadFull(c, buf[:2])
|
||||||
|
ulen := int(buf[1])
|
||||||
|
io.ReadFull(c, buf[:ulen])
|
||||||
|
login := string(buf[:ulen])
|
||||||
|
io.ReadFull(c, buf[:1])
|
||||||
|
plen := int(buf[0])
|
||||||
|
io.ReadFull(c, buf[:plen])
|
||||||
|
pwd := string(buf[:plen])
|
||||||
|
if login != p.login || pwd != p.password {
|
||||||
|
c.Write([]byte{0x01, 0x01})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Write([]byte{0x01, 0x00})
|
||||||
|
} else {
|
||||||
|
c.Write([]byte{0x05, 0x00})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDP ASSOCIATE: 05 03 00 ATYP ...
|
||||||
|
io.ReadFull(c, buf[:4])
|
||||||
|
if buf[1] != 0x03 {
|
||||||
|
// Not UDP ASSOCIATE; reject.
|
||||||
|
c.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atyp := buf[3]
|
||||||
|
switch atyp {
|
||||||
|
case 1:
|
||||||
|
io.ReadFull(c, buf[:4])
|
||||||
|
case 3:
|
||||||
|
io.ReadFull(c, buf[:1])
|
||||||
|
io.ReadFull(c, buf[:int(buf[0])])
|
||||||
|
}
|
||||||
|
io.ReadFull(c, buf[:2]) // port
|
||||||
|
|
||||||
|
// Reply with relay's local addr
|
||||||
|
relayAddr := p.relay.LocalAddr().(*net.UDPAddr)
|
||||||
|
rep := []byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}
|
||||||
|
v4 := relayAddr.IP.To4()
|
||||||
|
copy(rep[4:8], v4)
|
||||||
|
binary.BigEndian.PutUint16(rep[8:10], uint16(relayAddr.Port))
|
||||||
|
c.Write(rep)
|
||||||
|
_ = c.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
// Hold the conn open until peer closes (RFC 1928 §6 — control TCP
|
||||||
|
// must remain open for the relay to stay valid).
|
||||||
|
io.Copy(io.Discard, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssociateUDP_NoAuth(t *testing.T) {
|
||||||
|
p := startFakeUDPProxy(t, false, "", "")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
relay, ctrl, err := AssociateUDP(ctx, Config{ProxyAddr: p.tcpAddr})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer ctrl.Close()
|
||||||
|
|
||||||
|
expected := p.relay.LocalAddr().(*net.UDPAddr)
|
||||||
|
assert.Equal(t, expected.Port, relay.Port)
|
||||||
|
assert.Equal(t, "127.0.0.1", relay.IP.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssociateUDP_WithAuth(t *testing.T) {
|
||||||
|
p := startFakeUDPProxy(t, true, "user", "pass")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
relay, ctrl, err := AssociateUDP(ctx, Config{
|
||||||
|
ProxyAddr: p.tcpAddr,
|
||||||
|
UseAuth: true,
|
||||||
|
Login: "user",
|
||||||
|
Password: "pass",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer ctrl.Close()
|
||||||
|
|
||||||
|
require.NotNil(t, relay)
|
||||||
|
assert.Greater(t, relay.Port, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssociateUDP_BadAuth(t *testing.T) {
|
||||||
|
p := startFakeUDPProxy(t, true, "user", "pass")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, _, err := AssociateUDP(ctx, Config{
|
||||||
|
ProxyAddr: p.tcpAddr,
|
||||||
|
UseAuth: true,
|
||||||
|
Login: "wrong",
|
||||||
|
Password: "wrong",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncapDecapUDPv4_Roundtrip(t *testing.T) {
|
||||||
|
dstIP := net.IPv4(140, 82, 121, 4)
|
||||||
|
payload := []byte("voice payload bytes")
|
||||||
|
|
||||||
|
envelope, err := EncapUDPv4(dstIP, 50007, payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify wire layout (RFC 1928 §7)
|
||||||
|
assert.Equal(t, byte(0x00), envelope[0], "RSV[0]")
|
||||||
|
assert.Equal(t, byte(0x00), envelope[1], "RSV[1]")
|
||||||
|
assert.Equal(t, byte(0x00), envelope[2], "FRAG")
|
||||||
|
assert.Equal(t, byte(0x01), envelope[3], "ATYP=IPv4")
|
||||||
|
assert.Equal(t, []byte{140, 82, 121, 4}, envelope[4:8])
|
||||||
|
assert.Equal(t, uint16(50007), binary.BigEndian.Uint16(envelope[8:10]))
|
||||||
|
assert.Equal(t, payload, envelope[10:])
|
||||||
|
|
||||||
|
// Round-trip via DecapUDPv4
|
||||||
|
srcIP, srcPort, gotPayload, err := DecapUDPv4(envelope)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "140.82.121.4", srcIP.String())
|
||||||
|
assert.Equal(t, uint16(50007), srcPort)
|
||||||
|
assert.Equal(t, payload, gotPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncapUDPv4_NotIPv4(t *testing.T) {
|
||||||
|
v6 := net.ParseIP("::1")
|
||||||
|
_, err := EncapUDPv4(v6, 1, []byte("x"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecapUDPv4_Errors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
buf []byte
|
||||||
|
}{
|
||||||
|
{"too_short", []byte{0, 0, 0, 1, 1, 2, 3}},
|
||||||
|
{"frag_nonzero", []byte{0, 0, 1 /* frag */, 1, 1, 2, 3, 4, 0, 80, 'x'}},
|
||||||
|
{"atyp_not_ipv4", []byte{0, 0, 0, 4 /* IPv6 */, 1, 2, 3, 4, 0, 80, 'x'}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
_, _, _, err := DecapUDPv4(c.buf)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user