diff --git a/cmd/drover/debugflow_other.go b/cmd/drover/debugflow_other.go deleted file mode 100644 index c7967cb..0000000 --- a/cmd/drover/debugflow_other.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !windows - -package main - -import ( - "context" - "fmt" -) - -func runDebugFlow(_ context.Context) error { - return fmt.Errorf("debug-flow requires Windows") -} diff --git a/cmd/drover/debugflow_windows.go b/cmd/drover/debugflow_windows.go deleted file mode 100644 index 78fd445..0000000 --- a/cmd/drover/debugflow_windows.go +++ /dev/null @@ -1,64 +0,0 @@ -//go:build windows - -package main - -import ( - "context" - "log" - "time" - - "git.okcu.io/root/drover-go/internal/divert" -) - -// runDebugFlow opens a WinDivert FLOW handle with the broadest possible -// filter ("tcp") and logs every flow-establish/delete event for up to -// 30 seconds. This is the simplest possible test that the FLOW layer -// is delivering events to our handle. -// -// If we see events here but our process-targeted handle in `proxy` -// stays silent, the bug is in our processId filter clause. If we see -// nothing here, the FLOW layer is broken on this machine. -func runDebugFlow(parent context.Context) error { - if _, err := divert.InstallDriver(); err != nil { - return err - } - - ctx, cancel := context.WithTimeout(parent, 30*time.Second) - defer cancel() - - log.Printf("debug-flow: opening FLOW handle with filter \"true\" (capture all flows)") - h, err := divert.OpenFlow("true") - if err != nil { - log.Printf("debug-flow: OpenFlow failed: %v", err) - return err - } - defer h.Close() - log.Printf("debug-flow: handle open, listening for 30s") - - go func() { - <-ctx.Done() - _ = h.Close() // unblock RecvFlow - }() - - count := 0 - for { - ev, err := h.RecvFlow() - if err != nil { - if ctx.Err() != nil { - log.Printf("debug-flow: done — captured %d events in 30s", count) - return nil - } - log.Printf("debug-flow: RecvFlow err: %v", err) - return err - } - count++ - log.Printf("debug-flow: event #%d est=%v pid=%d proto=%d %v:%d → %v:%d rawLocal=%x rawRemote=%x", - count, ev.Established, ev.ProcessID, ev.Protocol, - ev.SrcAddr, ev.SrcPort, ev.DstAddr, ev.DstPort, - ev.LocalRaw, ev.RemoteRaw) - if count >= 20 { - log.Printf("debug-flow: hit 20-event cap, stopping") - return nil - } - } -} diff --git a/cmd/drover/main.go b/cmd/drover/main.go index 7ef4b79..a1977cb 100644 --- a/cmd/drover/main.go +++ b/cmd/drover/main.go @@ -127,52 +127,10 @@ func newRootCmd() *cobra.Command { root.AddCommand(newUpdateCmd()) root.AddCommand(newServiceCmd()) root.AddCommand(newGUICmd()) - root.AddCommand(newProxyCmd()) - root.AddCommand(newDebugFlowCmd()) return root } -// newDebugFlowCmd opens a WinDivert FLOW handle with filter "tcp" -// (capture all TCP flow events from any process) and logs every event -// for 30 seconds. Useful to verify the FLOW layer is working at all -// without process-targeting interference. -func newDebugFlowCmd() *cobra.Command { - return &cobra.Command{ - Use: "debug-flow", - Short: "[debug] open broad FLOW handle, log events for 30s", - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - return runDebugFlow(cmd.Context()) - }, - } -} - -// newProxyCmd is the headless engine-only mode: no Wails, no tray — -// just spin up the WinDivert + SOCKS5 pipeline against the configured -// upstream and block on Ctrl+C. Useful for debugging without the GUI -// stack in the way; everything still goes to %LOCALAPPDATA%\Drover\debug.log. -func newProxyCmd() *cobra.Command { - var host, login, password string - var port int - var auth bool - cmd := &cobra.Command{ - Use: "proxy", - Short: "Run the WinDivert+SOCKS5 engine in headless mode (no GUI, blocks until Ctrl+C)", - RunE: func(cmd *cobra.Command, args []string) error { - return runProxy(cmd.Context(), host, port, auth, login, password) - }, - } - cmd.Flags().StringVar(&host, "host", "", "upstream SOCKS5 host (required)") - cmd.Flags().IntVar(&port, "port", 0, "upstream SOCKS5 port (required)") - cmd.Flags().BoolVar(&auth, "auth", false, "enable user/pass auth") - cmd.Flags().StringVar(&login, "login", "", "SOCKS5 login (when --auth)") - cmd.Flags().StringVar(&password, "password", "", "SOCKS5 password (when --auth)") - _ = cmd.MarkFlagRequired("host") - _ = cmd.MarkFlagRequired("port") - return cmd -} - func newGUICmd() *cobra.Command { return &cobra.Command{ Use: "gui", diff --git a/cmd/drover/proxy_other.go b/cmd/drover/proxy_other.go deleted file mode 100644 index 9c2b150..0000000 --- a/cmd/drover/proxy_other.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !windows - -package main - -import ( - "context" - "fmt" -) - -// runProxy stub for non-Windows builds (drover only ships for Windows; -// this stub keeps `go build ./...` clean on Linux dev/CI machines). -func runProxy(_ context.Context, _ string, _ int, _ bool, _, _ string) error { - return fmt.Errorf("the proxy subcommand requires Windows (WinDivert is Windows-only)") -} diff --git a/cmd/drover/proxy_windows.go b/cmd/drover/proxy_windows.go deleted file mode 100644 index 019d728..0000000 --- a/cmd/drover/proxy_windows.go +++ /dev/null @@ -1,80 +0,0 @@ -//go:build windows - -package main - -import ( - "context" - "fmt" - "log" - "os" - "os/signal" - "syscall" - "time" - - "git.okcu.io/root/drover-go/internal/engine" -) - -// runProxy is the body of the `drover proxy` subcommand. It builds an -// engine.Engine from the supplied flags, calls Start, and blocks until -// the process receives SIGINT (Ctrl+C) or SIGTERM. On signal, it -// gracefully Stops the engine and exits. -// -// All output is mirrored to stderr (visible when launched from a -// console session) AND %LOCALAPPDATA%\Drover\debug.log. setupDebugLog -// in main.go has already wired the log package to write to both. -func runProxy(parent context.Context, host string, port int, auth bool, login, password string) error { - if host == "" || port == 0 { - return fmt.Errorf("--host and --port are required") - } - - ctx, cancel := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) - defer cancel() - - cfg := engine.Config{ - ProxyAddr: fmt.Sprintf("%s:%d", host, port), - UseAuth: auth, - Login: login, - Password: password, - Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"}, - } - - log.Printf("proxy: building engine (proxy=%s auth=%v targets=%v)", cfg.ProxyAddr, cfg.UseAuth, cfg.Targets) - e, err := engine.New(cfg) - if err != nil { - return fmt.Errorf("engine.New: %w", err) - } - startCtx, startCancel := context.WithTimeout(ctx, 15*time.Second) - defer startCancel() - if err := e.Start(startCtx); err != nil { - log.Printf("proxy: Start failed: %v", err) - return fmt.Errorf("engine.Start: %w", err) - } - log.Printf("proxy: engine status=%s — press Ctrl+C to stop", e.Status()) - - // Periodic status ping so the user sees the engine is alive. - statusTk := time.NewTicker(10 * time.Second) - defer statusTk.Stop() - - for { - select { - case <-ctx.Done(): - log.Printf("proxy: signal received, shutting down") - if err := e.Stop(); err != nil { - log.Printf("proxy: Stop returned: %v", err) - } - log.Printf("proxy: bye") - return nil - case <-statusTk.C: - if le := e.LastError(); le != nil { - log.Printf("proxy: heartbeat status=%s lastErr=%v", e.Status(), le) - } else { - log.Printf("proxy: heartbeat status=%s", e.Status()) - } - if e.Status() == engine.StatusFailed { - log.Printf("proxy: engine entered Failed state, exiting") - _ = e.Stop() - return fmt.Errorf("engine failed: %v", e.LastError()) - } - } - } -} diff --git a/drover-test.exe~ b/drover-test.exe~ deleted file mode 100644 index 4837186..0000000 Binary files a/drover-test.exe~ and /dev/null differ diff --git a/internal/app/doc.go b/internal/app/doc.go deleted file mode 100644 index 16b506d..0000000 --- a/internal/app/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package app wires the Wails application (Go ↔ JS bindings). -package app diff --git a/internal/bypass/doc.go b/internal/bypass/doc.go deleted file mode 100644 index eb5ce30..0000000 --- a/internal/bypass/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package bypass implements DPI bypass via fake QUIC injection. -package bypass diff --git a/internal/config/doc.go b/internal/config/doc.go deleted file mode 100644 index 058e6e5..0000000 --- a/internal/config/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package config loads and validates the TOML configuration. -package config diff --git a/internal/divert/assets/WinDivert.dll b/internal/divert/assets/WinDivert.dll deleted file mode 100644 index 50ca874..0000000 Binary files a/internal/divert/assets/WinDivert.dll and /dev/null differ diff --git a/internal/divert/assets/WinDivert64.sys b/internal/divert/assets/WinDivert64.sys deleted file mode 100644 index 218ccaf..0000000 Binary files a/internal/divert/assets/WinDivert64.sys and /dev/null differ diff --git a/internal/divert/divert.go b/internal/divert/divert.go deleted file mode 100644 index 0587831..0000000 --- a/internal/divert/divert.go +++ /dev/null @@ -1,423 +0,0 @@ -//go:build windows - -package divert - -import ( - "errors" - "fmt" - "unsafe" - - idivert "github.com/imgk/divert-go" -) - -// idivertAddrLayout mirrors the imgk/divert-go private Address fields -// so we can read the raw 64-byte union without going through their -// (mis-aligned for FLOW events) accessor. -type idivertAddrLayout struct { - Timestamp int64 - Layer uint8 - Event uint8 - Flags uint8 - _ uint8 - Length uint32 - Union [64]byte -} - -// parseFlowUnion decodes a WINDIVERT_DATA_FLOW from raw union bytes. -// Layout per WinDivert v2 (MSVC default 8-byte alignment): -// -// offset 0..7 EndpointId UINT64 -// offset 8..15 ParentEndpointId UINT64 -// offset 16..19 ProcessId UINT32 -// offset 20..23 (padding to 4) — not 8 because LocalAddr has 4-byte alignment -// offset 24..39 LocalAddr[4] UINT32 — NO, wait. -// -// Actually WinDivert struct uses UINT32 (4-byte aligned), no padding -// between ProcessId and LocalAddr. But we observed ProcessID and -// Ports parse correctly via imgk's struct (which assumes offset 20 -// for LocalAddr). So that layout is right; the IPs zero-out must be -// because *imgk's struct member [16]uint8 doesn't read what we think*. -// -// Mystery: imgk's Flow struct should give correct addresses. Yet we -// see [0,0,0,0]. Re-inspect raw bytes. -func parseFlowUnion(b []byte) *FlowEvent { - if len(b) < 64 { - return &FlowEvent{} - } - ev := &FlowEvent{ - ProcessID: leU32(b[16:20]), - LocalRaw: toAddr16(b[20:36]), - RemoteRaw: toAddr16(b[36:52]), - LocalPort: leU16(b[52:54]), - RemotePort: leU16(b[54:56]), - Protocol: b[56], - } - // WinDivert v2.2.2 stores IPv4 as little-endian uint32 in the - // first 4 bytes of the 16-byte address slot (bytes 4..7 hold the - // 0xFFFF mapped-IPv6 prefix; bytes 8..15 are zero). To get the - // dot-notation IP A.B.C.D, reverse the byte order: - // byte[0] = D (LSB), byte[1] = C, byte[2] = B, byte[3] = A (MSB). - ev.SrcAddr[0] = ev.LocalRaw[3] - ev.SrcAddr[1] = ev.LocalRaw[2] - ev.SrcAddr[2] = ev.LocalRaw[1] - ev.SrcAddr[3] = ev.LocalRaw[0] - ev.DstAddr[0] = ev.RemoteRaw[3] - ev.DstAddr[1] = ev.RemoteRaw[2] - ev.DstAddr[2] = ev.RemoteRaw[1] - ev.DstAddr[3] = ev.RemoteRaw[0] - ev.SrcPort = ev.LocalPort - ev.DstPort = ev.RemotePort - return ev -} - -func leU32(b []byte) uint32 { - return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24 -} -func leU16(b []byte) uint16 { - return uint16(b[0]) | uint16(b[1])<<8 -} -func toAddr16(b []byte) [16]byte { - var a [16]byte - copy(a[:], b) - return a -} - -// Handle wraps a WinDivert handle. -type Handle struct { - h *idivert.Handle -} - -// Open opens a WinDivert handle at NETWORK layer for outbound capture. -// The filter expression is the standard WinDivert syntax (see -// internal/divert/filter.go for our builder). -// -// Returns ErrAccessDenied when the calling process is not elevated. -// Returns ErrDriverFailedPriorUnload when an outdated WinDivert -// (e.g. v1.x from zapret) is already loaded. -func Open(filter string) (*Handle, error) { - h, err := idivert.Open(filter, idivert.LayerNetwork, 0, 0) - if err != nil { - return nil, mapWinDivertErr(err) - } - return &Handle{h: h}, nil -} - -// OpenFlow opens a WinDivert handle at FLOW layer. FLOW handles -// observe TCP/UDP flow establish + delete events with processId info -// available — that's where we learn which 5-tuples belong to target -// processes (processId field is invalid on the NETWORK layer filter -// language). FLOW handles cannot Send packets — they're read-only by -// design. -// -// Per WinDivert reference, FLOW handles MUST be opened with both -// SNIFF (events only, no interception) and RECV_ONLY (no Send) flags, -// otherwise WinDivertOpen rejects the request. -func OpenFlow(filter string) (*Handle, error) { - h, err := idivert.Open(filter, idivert.LayerFlow, 0, idivert.FlagSniff|idivert.FlagRecvOnly) - if err != nil { - return nil, mapWinDivertErr(err) - } - return &Handle{h: h}, nil -} - -// OpenSocket opens a WinDivert handle at SOCKET layer. SOCKET layer -// fires events synchronously with socket syscalls (bind/connect/ -// listen/accept/close) — Connect specifically fires BEFORE the SYN -// packet leaves the box, which gives us a window to populate our -// redirect tables before the NETWORK-layer SYN arrives. -// -// Same flag rules as FLOW: must be SNIFF + RECV_ONLY. -func OpenSocket(filter string) (*Handle, error) { - h, err := idivert.Open(filter, idivert.LayerSocket, 0, idivert.FlagSniff|idivert.FlagRecvOnly) - if err != nil { - return nil, mapWinDivertErr(err) - } - return &Handle{h: h}, nil -} - -// SocketEvent represents a socket-layer event (Connect/Close/etc). -type SocketEvent struct { - ProcessID uint32 - Protocol uint8 // 6=TCP, 17=UDP - SrcAddr [4]byte - SrcPort uint16 - DstAddr [4]byte - DstPort uint16 - Kind SocketEventKind - LocalRaw [16]byte // raw 16-byte slot for diagnostic - RemoteRaw [16]byte -} - -// SocketEventKind enumerates the socket-layer events we care about. -type SocketEventKind int - -const ( - SocketKindUnknown SocketEventKind = iota - SocketKindBind - SocketKindConnect - SocketKindListen - SocketKindAccept - SocketKindClose -) - -// RecvSocket blocks until a socket event arrives on a SOCKET-layer -// handle. The packet payload is empty on SOCKET events; only the -// address metadata matters. -func (h *Handle) RecvSocket() (*SocketEvent, error) { - if h == nil || h.h == nil { - return nil, errors.New("handle closed") - } - buf := [4]byte{} - addr := new(idivert.Address) - _, err := h.h.Recv(buf[:], addr) - if err != nil { - return nil, mapWinDivertErr(err) - } - // SOCKET layer uses the same WINDIVERT_DATA_SOCKET layout as FLOW - // (verbatim per the WinDivert v2.2.2 header). We bypass the - // imgk/divert-go accessor for the same alignment-safety reason as - // RecvFlow and parse raw union bytes directly. - raw := (*idivertAddrLayout)(unsafe.Pointer(addr)) - ev := parseSocketUnion(raw.Union[:]) - switch addr.Event() { - case idivert.EventSocketBind: - ev.Kind = SocketKindBind - case idivert.EventSocketConnect: - ev.Kind = SocketKindConnect - case idivert.EventSocketListen: - ev.Kind = SocketKindListen - case idivert.EventSocketAccept: - ev.Kind = SocketKindAccept - case idivert.EventSocketClose: - ev.Kind = SocketKindClose - default: - return nil, fmt.Errorf("unexpected socket event %d", addr.Event()) - } - return ev, nil -} - -// parseSocketUnion mirrors parseFlowUnion: WINDIVERT_DATA_SOCKET is -// byte-identical to WINDIVERT_DATA_FLOW per windivert.h v2.2.2. -func parseSocketUnion(b []byte) *SocketEvent { - if len(b) < 64 { - return &SocketEvent{} - } - ev := &SocketEvent{ - ProcessID: leU32(b[16:20]), - LocalRaw: toAddr16(b[20:36]), - RemoteRaw: toAddr16(b[36:52]), - SrcPort: leU16(b[52:54]), - DstPort: leU16(b[54:56]), - Protocol: b[56], - } - // Same byte-reverse trick as parseFlowUnion: WinDivert stores the - // IPv4 in the first 4 bytes of the slot as a host-byte-order - // uint32; reverse to get A.B.C.D in SrcAddr[0..3]. - ev.SrcAddr[0] = ev.LocalRaw[3] - ev.SrcAddr[1] = ev.LocalRaw[2] - ev.SrcAddr[2] = ev.LocalRaw[1] - ev.SrcAddr[3] = ev.LocalRaw[0] - ev.DstAddr[0] = ev.RemoteRaw[3] - ev.DstAddr[1] = ev.RemoteRaw[2] - ev.DstAddr[2] = ev.RemoteRaw[1] - ev.DstAddr[3] = ev.RemoteRaw[0] - return ev -} - -// FlowEvent represents a flow-establish/delete event from a FLOW -// handle. SrcAddr/DstAddr are the IPv4 addresses (4 bytes, network -// byte order: A.B.C.D = SrcAddr[0..3]). LocalRaw/RemoteRaw are the -// raw 16-byte slots from WinDivert for diagnostic dumps. -// -// Established=true on EventFlowEstablished; false on EventFlowDeleted. -type FlowEvent struct { - ProcessID uint32 - Protocol uint8 // 6=TCP, 17=UDP - SrcAddr [4]byte - SrcPort uint16 - DstAddr [4]byte - DstPort uint16 - Established bool - - // Diagnostic fields populated by parseFlowUnion. Used by - // debug-flow logging; production code should consume the - // SrcAddr/DstAddr/SrcPort/DstPort fields above. - LocalRaw [16]byte - RemoteRaw [16]byte - LocalPort uint16 - RemotePort uint16 -} - -// RecvFlow blocks until a flow event arrives on a FLOW-layer handle. -// The packet payload is empty on FLOW events; only the address -// metadata matters. -// -// Returns the event or an error from the wrapped handle (Shutdown -// during close, etc). -func (h *Handle) RecvFlow() (*FlowEvent, error) { - if h == nil || h.h == nil { - return nil, errors.New("handle closed") - } - // Per WinDivert docs flow event has zero-byte packet; we still - // need a non-nil buffer for the API. - buf := [4]byte{} - addr := new(idivert.Address) - _, err := h.h.Recv(buf[:], addr) - if err != nil { - return nil, mapWinDivertErr(err) - } - // imgk/divert-go's Flow accessor mis-aligns the union for FLOW - // events (it assumes 4-byte alignment after ProcessID, but MSVC - // pads to 8-byte boundary because the struct contains UINT64). - // We bypass the accessor and parse the raw union bytes ourselves. - raw := (*idivertAddrLayout)(unsafe.Pointer(addr)) - ev := parseFlowUnion(raw.Union[:]) - switch addr.Event() { - case idivert.EventFlowEstablished: - ev.Established = true - case idivert.EventFlowDeleted: - ev.Established = false - default: - return nil, fmt.Errorf("unexpected flow event %d", addr.Event()) - } - return ev, nil -} - -// Close closes the handle. Safe to call multiple times. -func (h *Handle) Close() error { - if h == nil || h.h == nil { - return nil - } - err := h.h.Close() - h.h = nil - return err -} - -// Recv blocks until a packet arrives that matches the filter, or until -// the handle is closed (Close from another goroutine returns -// ErrShutdown to the recv'er). buf must be sized for a full Ethernet -// MTU (~1600 bytes is fine). -// -// Returns the captured packet length, the WinDivertAddress (containing -// direction, interface index, etc), and any error. -func (h *Handle) Recv(buf []byte) (int, *idivert.Address, error) { - if h == nil || h.h == nil { - return 0, nil, errors.New("handle closed") - } - addr := new(idivert.Address) - n, err := h.h.Recv(buf, addr) - if err != nil { - return 0, nil, mapWinDivertErr(err) - } - return int(n), addr, nil -} - -// Send reinjects a packet. The address typically comes from a previous -// Recv call (so the kernel knows whether it's outbound or inbound, which -// interface, etc). -func (h *Handle) Send(buf []byte, addr *idivert.Address) (int, error) { - if h == nil || h.h == nil { - return 0, errors.New("handle closed") - } - n, err := h.h.Send(buf, addr) - if err != nil { - return 0, mapWinDivertErr(err) - } - return int(n), nil -} - -// SendInjectInbound reinjects a fabricated IPv4 packet as inbound (i.e. -// kernel delivers it via the receive path of whatever interface owns -// the destination IP). Used by the UDPProxy to deliver SOCKS5 relay -// responses back to a target process: we synthesize an IPv4+UDP packet -// with src=remote_endpoint, dst=local_LAN_IP, then call this with -// outbound=false and IP+UDP-checksum-valid flags set. -// -// Internally builds a fresh *idivert.Address with NETWORK layer + the -// requested flags + zero interface index (WinDivert routes via default). -// -// Flags semantics (per WinDivert v2.2.2 windivert.h): -// -// bit 1 (0x02) = Outbound — set if outbound, clear for inbound -// bit 5 (0x20) = IPChecksum — packet has valid IPv4 header checksum -// bit 6 (0x40) = TCPChecksum — packet has valid TCP checksum -// bit 7 (0x80) = UDPChecksum — packet has valid UDP checksum -func (h *Handle) SendInjectInbound(buf []byte, isUDP bool) (int, error) { - if h == nil || h.h == nil { - return 0, errors.New("handle closed") - } - addr := new(idivert.Address) - addr.SetLayer(idivert.LayerNetwork) - addr.SetEvent(idivert.EventNetworkPacket) - // Outbound bit (0x02) cleared (inbound). Sniffed (0x01) cleared. - // IPChecksum (0x20) set. UDP (0x80) or TCP (0x40) set per call. - var flags uint8 = 0x20 - if isUDP { - flags |= 0x80 - } else { - flags |= 0x40 - } - addr.Flags = flags - n, err := h.h.Send(buf, addr) - if err != nil { - return 0, mapWinDivertErr(err) - } - return int(n), nil -} - -// Sentinel errors mapped from raw Windows errors so the engine layer -// can pattern-match without importing windows package. -var ( - ErrAccessDenied = errors.New("WinDivert: access denied (need admin)") - ErrDriverFailedPriorUnload = errors.New("WinDivert: outdated driver from another tool is loaded; reboot or stop the other tool first") - ErrInvalidHandle = errors.New("WinDivert: handle invalidated (driver crashed?)") - ErrShutdown = errors.New("WinDivert: shutdown") -) - -func mapWinDivertErr(err error) error { - if err == nil { - return nil - } - msg := err.Error() - switch { - case contains(msg, "access is denied"), contains(msg, "ACCESS_DENIED"): - return ErrAccessDenied - case contains(msg, "FAILED_PRIOR_UNLOAD"), contains(msg, "prior unload"): - return ErrDriverFailedPriorUnload - case contains(msg, "INVALID_HANDLE"): - return ErrInvalidHandle - case contains(msg, "SHUTDOWN"): - return ErrShutdown - } - return fmt.Errorf("WinDivert: %w", err) -} - -func contains(s, sub string) bool { - // case-insensitive - if len(sub) == 0 { - return true - } - if len(s) < len(sub) { - return false - } - for i := 0; i+len(sub) <= len(s); i++ { - match := true - for j := 0; j < len(sub); j++ { - a, b := s[i+j], sub[j] - if a >= 'A' && a <= 'Z' { - a += 32 - } - if b >= 'A' && b <= 'Z' { - b += 32 - } - if a != b { - match = false - break - } - } - if match { - return true - } - } - return false -} diff --git a/internal/divert/divert_test.go b/internal/divert/divert_test.go deleted file mode 100644 index 7d4fa44..0000000 --- a/internal/divert/divert_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package divert - -import ( - "os" - "runtime" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestOpen_FalseFilterRoundtrip is a Windows + admin smoke test. -// Skips when not on Windows or not admin. -func TestOpen_FalseFilterRoundtrip(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("Windows-only") - } - if !isAdminTest() { - t.Skip("requires admin; run from elevated shell") - } - // Install the driver first so the .sys is present - _, err := InstallDriver() - require.NoError(t, err) - - h, err := Open("false") // matches no packets - require.NoError(t, err) - defer h.Close() -} - -// isAdminTest is a thin wrapper to keep the test file Windows-pure -// without re-implementing IsAdmin from cmd/drover (we'd circular-import). -func isAdminTest() bool { - // Read TokenElevation directly via os/syscall to avoid the import cycle. - // For simplicity we just check whether we can write to System32. - // (Smoke-only; production code uses cmd/drover's IsAdmin.) - _, err := os.Stat(`C:\Windows\System32\drivers`) - if err != nil { - return false - } - f, err := os.OpenFile(`C:\Windows\System32\drivers\.drover-admin-test`, os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return false - } - f.Close() - os.Remove(`C:\Windows\System32\drivers\.drover-admin-test`) - return true -} diff --git a/internal/divert/doc.go b/internal/divert/doc.go deleted file mode 100644 index a3a2a7e..0000000 --- a/internal/divert/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package divert wraps WinDivert for kernel-level packet capture. -package divert diff --git a/internal/divert/embed.go b/internal/divert/embed.go deleted file mode 100644 index 4d9cb15..0000000 --- a/internal/divert/embed.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build windows - -package divert - -import _ "embed" - -//go:embed assets/WinDivert64.sys -var winDivertSys []byte - -//go:embed assets/WinDivert.dll -var winDivertDll []byte - -// Sentinel SHA256 of the embedded binaries — verified on extract. -// Generated via: -// -// sha256sum internal/divert/assets/WinDivert64.sys -// sha256sum internal/divert/assets/WinDivert.dll -// -// Update both constants when bumping WinDivert versions. -const ( - WinDivertSysSHA256 = "8da085332782708d8767bcace5327a6ec7283c17cfb85e40b03cd2323a90ddc2" - WinDivertDllSHA256 = "c1e060ee19444a259b2162f8af0f3fe8c4428a1c6f694dce20de194ac8d7d9a2" -) diff --git a/internal/divert/filter.go b/internal/divert/filter.go deleted file mode 100644 index 014f1b4..0000000 --- a/internal/divert/filter.go +++ /dev/null @@ -1,110 +0,0 @@ -package divert - -import ( - "fmt" - "net" - "strings" -) - -// FilterParams collects the inputs needed to build a WinDivert filter -// expression for Drover's outbound capture. -type FilterParams struct { - // TargetPIDs is the set of PIDs whose outbound traffic should be - // captured (e.g. Discord variants). When empty, the resulting - // filter is "false" — captures nothing — which is the right - // behaviour while procscan reports zero Discord processes. - TargetPIDs []uint32 - - // OwnPID is drover.exe's own PID. Excluded from capture so our - // SOCKS5 traffic to the upstream proxy doesn't get re-captured. - OwnPID uint32 - - // UpstreamIP is the resolved IPv4 of the upstream SOCKS5 proxy. - // Excluded from capture as a second line of defence against - // self-loops. If unparseable, "0.0.0.0" is substituted (caller - // should validate before calling). - UpstreamIP string - - // LocalIP is the machine's LAN IP — listener binds here, so - // reinjected NAT'd packets (which still bear the original src) - // reach it. Must be excluded from the filter to prevent infinite - // recapture of NAT'd packets (we'd see them outbound again). - LocalIP string -} - -// BuildFlowFilter returns a filter expression for the FLOW layer handle. -// processId is ONLY available at FLOW/SOCKET layers, not NETWORK — that's -// why we run two handles in parallel: this FLOW handle observes which -// 5-tuples belong to target PIDs, and the NETWORK handle (BuildNetworkFilter) -// captures actual packets. -// -// Empty PID list → "false" (matches no flows). -func BuildFlowFilter(p FilterParams) string { - if len(p.TargetPIDs) == 0 { - return "false" - } - pidClauses := make([]string, len(p.TargetPIDs)) - for i, pid := range p.TargetPIDs { - pidClauses[i] = fmt.Sprintf("processId == %d", pid) - } - pidClause := "(" + strings.Join(pidClauses, " or ") + ")" - - parts := []string{ - "(tcp or udp)", - "ip", - pidClause, - fmt.Sprintf("processId != %d", p.OwnPID), - } - return strings.Join(parts, " and ") -} - -// BuildNetworkFilter returns a filter expression for the NETWORK layer -// handle. It captures all outbound IPv4 TCP/UDP except loopback, -// multicast, link-local, and the upstream proxy. The engine then -// narrows by consulting the flow tracker fed by the FLOW handle. -// -// We don't (can't) filter by processId here — see BuildFlowFilter. -// Self-loop protection: ip.DstAddr != upstream blocks our own SOCKS5 -// uplink, and 127.0.0.0/8 exclusion blocks our loopback redirector. -// -// Range exclusions are spelled with explicit `<`/`>` rather than -// `not (a and b)` because some WinDivert versions reject the latter -// at filter compile time. -func BuildNetworkFilter(p FilterParams) string { - upstream := p.UpstreamIP - if net.ParseIP(upstream).To4() == nil { - upstream = "0.0.0.0" - } - parts := []string{ - "outbound", - "ip", - "(tcp or udp)", - fmt.Sprintf("ip.DstAddr != %s", upstream), - // Loopback 127.0.0.0/8 - "(ip.DstAddr < 127.0.0.0 or ip.DstAddr > 127.255.255.255)", - // Multicast 224.0.0.0/4 - "(ip.DstAddr < 224.0.0.0 or ip.DstAddr > 239.255.255.255)", - // Link-local 169.254.0.0/16 - "(ip.DstAddr < 169.254.0.0 or ip.DstAddr > 169.254.255.255)", - } - // Exclude packets DESTINED to our own LAN IP — they're either - // intra-machine traffic we don't care about OR our own NAT'd - // reinjects coming back around. Without this we infinite-loop. - if p.LocalIP != "" && net.ParseIP(p.LocalIP).To4() != nil { - parts = append(parts, fmt.Sprintf("ip.DstAddr != %s", p.LocalIP)) - } - return strings.Join(parts, " and ") -} - -// BuildFilter is the legacy single-filter API. Kept for callers that -// don't yet use the dual-handle architecture; equivalent to -// BuildNetworkFilter (no processId — that clause is invalid at NETWORK -// layer). -// -// Deprecated: use BuildFlowFilter + BuildNetworkFilter together. -func BuildFilter(p FilterParams) string { - if len(p.TargetPIDs) == 0 { - return "false" - } - return BuildNetworkFilter(p) -} diff --git a/internal/divert/filter_test.go b/internal/divert/filter_test.go deleted file mode 100644 index 5679a62..0000000 --- a/internal/divert/filter_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package divert - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBuildFilter_HappyPath(t *testing.T) { - got := BuildFilter(FilterParams{ - TargetPIDs: []uint32{12345, 67890}, - OwnPID: 999, - UpstreamIP: "95.165.72.59", - }) - // Required clauses - assert.Contains(t, got, "outbound") - assert.Contains(t, got, "(tcp or udp)") - assert.Contains(t, got, "ip") - assert.Contains(t, got, "processId == 12345") - assert.Contains(t, got, "processId == 67890") - assert.Contains(t, got, "processId != 999") - assert.Contains(t, got, "ip.DstAddr != 95.165.72.59") - // Loopback / multicast / link-local exclusions - assert.Contains(t, got, "127.0.0.0") - assert.Contains(t, got, "224.0.0.0") - assert.Contains(t, got, "169.254.0.0") -} - -func TestBuildFilter_SinglePID(t *testing.T) { - got := BuildFilter(FilterParams{ - TargetPIDs: []uint32{42}, - OwnPID: 1, - UpstreamIP: "1.2.3.4", - }) - assert.Contains(t, got, "processId == 42") -} - -func TestBuildFilter_NoTargetPIDs(t *testing.T) { - // No Discord running. We still produce a syntactically valid filter - // that matches nothing (we can't pass an empty filter to WinDivert). - got := BuildFilter(FilterParams{ - TargetPIDs: nil, - OwnPID: 999, - UpstreamIP: "1.2.3.4", - }) - // "false" alone is a valid filter that captures nothing — perfect - // for "Discord not running" interim. - assert.Equal(t, "false", got) -} - -func TestBuildFilter_OwnPIDNotInTargets(t *testing.T) { - // Defensive: even if OwnPID accidentally appears in TargetPIDs, the - // processId != ownPid clause still excludes it. - got := BuildFilter(FilterParams{ - TargetPIDs: []uint32{999, 1234}, - OwnPID: 999, - UpstreamIP: "1.2.3.4", - }) - assert.Contains(t, got, "processId != 999") - // The exclusion takes precedence syntactically because of the AND. - assert.True(t, strings.Contains(got, "and processId != 999")) -} - -func TestBuildFilter_UpstreamIPv4Format(t *testing.T) { - got := BuildFilter(FilterParams{ - TargetPIDs: []uint32{1}, - OwnPID: 2, - UpstreamIP: "not-an-ip", - }) - // We just substitute a placeholder and document it. - assert.Contains(t, got, "ip.DstAddr != 0.0.0.0") -} diff --git a/internal/divert/installer.go b/internal/divert/installer.go deleted file mode 100644 index bd2b50d..0000000 --- a/internal/divert/installer.go +++ /dev/null @@ -1,120 +0,0 @@ -//go:build windows - -package divert - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "syscall" - "unsafe" -) - -// DriverPaths records where the WinDivert binaries landed after install. -type DriverPaths struct { - SysPath string // e.g. C:\ProgramData\Drover\windivert\WinDivert64.sys - DllPath string -} - -// InstallDriver extracts the embedded WinDivert.sys + WinDivert.dll -// into %PROGRAMDATA%\Drover\windivert\ and SHA256-verifies them. -// -// On second and subsequent runs, if the existing files already match -// the embedded SHAs, the function is a no-op and just returns paths. -// -// Errors: -// - ARM64 architecture (WinDivert doesn't support it) -// - %PROGRAMDATA% not set or not writable -// - SHA256 mismatch after write (driver corrupted on disk) -func InstallDriver() (*DriverPaths, error) { - if runtime.GOARCH == "arm64" { - return nil, fmt.Errorf("Drover requires x86-64 Windows; ARM64 is not supported (WinDivert does not ship an ARM64 driver)") - } - pd := os.Getenv("ProgramData") - if pd == "" { - return nil, fmt.Errorf("ProgramData environment variable is not set") - } - dst := filepath.Join(pd, "Drover", "windivert") - return installDriverInto(dst) -} - -func installDriverInto(dst string) (*DriverPaths, error) { - if runtime.GOARCH == "arm64" { - return nil, fmt.Errorf("Drover requires x86-64 Windows; ARM64 is not supported") - } - if err := os.MkdirAll(dst, 0755); err != nil { - return nil, fmt.Errorf("create %s: %w", dst, err) - } - sysPath := filepath.Join(dst, "WinDivert64.sys") - dllPath := filepath.Join(dst, "WinDivert.dll") - - if err := writeIfDifferent(sysPath, winDivertSys, WinDivertSysSHA256); err != nil { - return nil, fmt.Errorf("install WinDivert64.sys: %w", err) - } - if err := writeIfDifferent(dllPath, winDivertDll, WinDivertDllSHA256); err != nil { - return nil, fmt.Errorf("install WinDivert.dll: %w", err) - } - // imgk/divert-go's LazyDLL("WinDivert.dll") relies on the standard - // Windows DLL search path. Our extracted binaries live in - // %PROGRAMDATA%\Drover\windivert\ which isn't on that path by - // default. SetDllDirectoryW prepends our directory so the lazy - // load resolves it. Must be called BEFORE the first divert.Open. - if err := setDllDirectory(dst); err != nil { - return nil, fmt.Errorf("SetDllDirectory %q: %w", dst, err) - } - return &DriverPaths{SysPath: sysPath, DllPath: dllPath}, nil -} - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procSetDllDirectoryW = kernel32.NewProc("SetDllDirectoryW") -) - -func setDllDirectory(path string) error { - p, err := syscall.UTF16PtrFromString(path) - if err != nil { - return err - } - r1, _, e1 := syscall.SyscallN(procSetDllDirectoryW.Addr(), uintptr(unsafe.Pointer(p))) - if r1 == 0 { - return e1 - } - return nil -} - -// writeIfDifferent compares the existing file's SHA256 to the expected -// hash; if it matches, no-op. Otherwise overwrite atomically and verify -// the resulting on-disk SHA matches expected. -func writeIfDifferent(path string, content []byte, expectedSHA string) error { - if existing, err := os.ReadFile(path); err == nil { - if strings.EqualFold(sha256Hex(existing), expectedSHA) { - return nil // already up to date - } - } - tmp := path + ".new" - if err := os.WriteFile(tmp, content, 0644); err != nil { - return err - } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) - return err - } - // Verify after write — guards against AV-on-write tampering. - got, err := os.ReadFile(path) - if err != nil { - return err - } - if !strings.EqualFold(sha256Hex(got), expectedSHA) { - return fmt.Errorf("SHA256 mismatch after write at %s; antivirus may have tampered with the file. Add %%PROGRAMDATA%%\\Drover\\ to your AV exclusions and restart Drover", path) - } - return nil -} - -func sha256Hex(b []byte) string { - h := sha256.Sum256(b) - return hex.EncodeToString(h[:]) -} diff --git a/internal/divert/installer_test.go b/internal/divert/installer_test.go deleted file mode 100644 index 91a33dd..0000000 --- a/internal/divert/installer_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package divert - -import ( - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestInstallDriver_ExtractsAndVerifies(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("Windows-only path") - } - tmp := t.TempDir() - res, err := installDriverInto(tmp) - require.NoError(t, err) - assert.FileExists(t, filepath.Join(tmp, "WinDivert64.sys")) - assert.FileExists(t, filepath.Join(tmp, "WinDivert.dll")) - assert.Equal(t, filepath.Join(tmp, "WinDivert64.sys"), res.SysPath) - assert.Equal(t, filepath.Join(tmp, "WinDivert.dll"), res.DllPath) -} - -func TestInstallDriver_RefusesARM64(t *testing.T) { - if runtime.GOARCH != "arm64" { - t.Skip("only meaningful on arm64") - } - _, err := installDriverInto(t.TempDir()) - require.Error(t, err) - assert.Contains(t, err.Error(), "ARM64") -} - -func TestInstallDriver_DetectsTampering(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip() - } - tmp := t.TempDir() - // Pre-populate the destination with garbage of the same name so the - // installer's existing-file SHA-check fails and it overwrites. - require.NoError(t, os.WriteFile(filepath.Join(tmp, "WinDivert64.sys"), []byte("garbage"), 0644)) - res, err := installDriverInto(tmp) - require.NoError(t, err) - // After install, the file should have the expected SHA, not garbage. - assert.NotEmpty(t, res.SysPath) - stat, err := os.Stat(res.SysPath) - require.NoError(t, err) - assert.Greater(t, stat.Size(), int64(1000)) -} diff --git a/internal/divert/packet.go b/internal/divert/packet.go deleted file mode 100644 index 06b6503..0000000 --- a/internal/divert/packet.go +++ /dev/null @@ -1,431 +0,0 @@ -package divert - -import ( - "encoding/binary" - "errors" - "net" -) - -// IPv4TCPInfo is what we extract from a raw IPv4+TCP packet for our -// per-flow mapping table. -type IPv4TCPInfo struct { - SrcIP, DstIP net.IP - SrcPort, DstPort uint16 -} - -// ParseIPv4TCP reads the IPv4 + TCP 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+TCP header (40 bytes) -// - IP version is not 4 -// - IP protocol is not 6 (TCP) -func ParseIPv4TCP(b []byte) (*IPv4TCPInfo, error) { - if len(b) < 40 { - return nil, errors.New("packet shorter than IPv4+TCP minimum") - } - if b[0]>>4 != 4 { - return nil, errors.New("not IPv4") - } - ihl := int(b[0]&0x0f) * 4 - if ihl < 20 || len(b) < ihl+20 { - return nil, errors.New("IPv4 IHL invalid or buffer truncated") - } - if b[9] != 6 { - return nil, errors.New("not TCP") - } - 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]) - return &IPv4TCPInfo{ - SrcIP: src, - DstIP: dst, - SrcPort: srcPort, - DstPort: dstPort, - }, nil -} - -// RewriteDst mutates b in-place to set dst IP and port, then -// recomputes both the IP header checksum and the TCP checksum. -// -// Returns the same errors as ParseIPv4TCP for malformed input. -func RewriteDst(b []byte, ip net.IP, port uint16) error { - if _, err := ParseIPv4TCP(b); err != nil { - return err - } - v4 := ip.To4() - if v4 == nil { - return errors.New("dst must be IPv4") - } - ihl := int(b[0]&0x0f) * 4 - - // Set dst IP - copy(b[16:20], v4) - // Set dst port - binary.BigEndian.PutUint16(b[ihl+2:ihl+4], port) - - // Recompute IP checksum (clear → compute → write big-endian) - b[10], b[11] = 0, 0 - cs := ipChecksum(b[:ihl]) - b[10] = byte(cs >> 8) - b[11] = byte(cs & 0xff) - - // Recompute TCP checksum (clear → compute → write) - 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 -} - -// SwapAndSetDstPort applies the canonical streamdump-style NAT-redirect -// rewrite: swap IPv4 src/dst, set TCP dst port to newDstPort. Keeps -// the original TCP src port (so the listener sees a unique RemoteAddr -// it can use to look up the flow). Recomputes both checksums. -// -// Use this on the FORWARD path (outbound from target process → -// remote). After this rewrite, set addr.Outbound=0 and reinject — -// the packet looks like remote → local on the inbound path, lands at -// the listener. -func SwapAndSetDstPort(b []byte, newDstPort uint16) error { - if _, err := ParseIPv4TCP(b); err != nil { - return err - } - ihl := int(b[0]&0x0f) * 4 - - // Swap src ↔ dst IPv4 (bytes 12..15 ↔ 16..19) - var src, dst [4]byte - copy(src[:], b[12:16]) - copy(dst[:], b[16:20]) - copy(b[12:16], dst[:]) - copy(b[16:20], src[:]) - - // Set TCP dst port; src port unchanged. - binary.BigEndian.PutUint16(b[ihl+2:ihl+4], newDstPort) - - // Recompute IP checksum - b[10], b[11] = 0, 0 - cs := ipChecksum(b[:ihl]) - b[10] = byte(cs >> 8) - b[11] = byte(cs & 0xff) - - // Recompute TCP checksum - b[ihl+16], b[ihl+17] = 0, 0 - cs = tcpChecksum(b[:ihl], b[ihl:]) - b[ihl+16] = byte(cs >> 8) - b[ihl+17] = byte(cs & 0xff) - return nil -} - -// SwapAndSetSrcPort applies the canonical streamdump-style return-path -// rewrite: swap IPv4 src/dst, set TCP src port to newSrcPort (the -// original target port the client expects to see, e.g. 443). Keeps -// the original TCP dst port (which is the client's ephemeral port). -// -// Use this on the RETURN path (listener → client). After this rewrite, -// set addr.Outbound=0 and reinject — the packet looks like remote → -// local on the inbound path, matches the client's connect() pair, and -// the client socket accepts the response as if from the real target. -func SwapAndSetSrcPort(b []byte, newSrcPort uint16) error { - if _, err := ParseIPv4TCP(b); err != nil { - return err - } - ihl := int(b[0]&0x0f) * 4 - - // Swap src ↔ dst IPv4 - var src, dst [4]byte - copy(src[:], b[12:16]) - copy(dst[:], b[16:20]) - copy(b[12:16], dst[:]) - copy(b[16:20], src[:]) - - // Set TCP src port; dst port unchanged. - binary.BigEndian.PutUint16(b[ihl:ihl+2], newSrcPort) - - // Recompute IP checksum - b[10], b[11] = 0, 0 - cs := ipChecksum(b[:ihl]) - b[10] = byte(cs >> 8) - b[11] = byte(cs & 0xff) - - // Recompute TCP checksum - b[ihl+16], b[ihl+17] = 0, 0 - cs = tcpChecksum(b[:ihl], b[ihl:]) - b[ihl+16] = byte(cs >> 8) - b[ihl+17] = byte(cs & 0xff) - return nil -} - -// ipChecksum is the standard 16-bit one's-complement sum over the IP -// header (RFC 791). The "checksum field" must be zeroed before calling. -func ipChecksum(hdr []byte) uint16 { - var sum uint32 - for i := 0; i+1 < len(hdr); i += 2 { - sum += uint32(hdr[i])<<8 | uint32(hdr[i+1]) - } - if len(hdr)%2 == 1 { - sum += uint32(hdr[len(hdr)-1]) << 8 - } - for sum>>16 != 0 { - sum = (sum & 0xffff) + (sum >> 16) - } - return ^uint16(sum) -} - -// tcpChecksum implements the RFC 793 pseudo-header checksum. -// ipHdr must include src+dst addresses; tcpSeg is the full TCP header -// + payload. The "checksum field" inside tcpSeg must be zeroed. -func tcpChecksum(ipHdr, tcpSeg []byte) uint16 { - var sum uint32 - // Pseudo-header: src(4) dst(4) zero(1) proto(1) tcp_len(2) - for i := 12; i <= 18; i += 2 { - sum += uint32(ipHdr[i])<<8 | uint32(ipHdr[i+1]) - } - sum += uint32(6) // TCP protocol - tcpLen := uint32(len(tcpSeg)) - sum += tcpLen - // TCP segment - for i := 0; i+1 < len(tcpSeg); i += 2 { - sum += uint32(tcpSeg[i])<<8 | uint32(tcpSeg[i+1]) - } - if len(tcpSeg)%2 == 1 { - sum += uint32(tcpSeg[len(tcpSeg)-1]) << 8 - } - for sum>>16 != 0 { - sum = (sum & 0xffff) + (sum >> 16) - } - return ^uint16(sum) -} - -// IPv4UDPInfo is what we extract from a raw IPv4+UDP packet for our -// per-flow mapping table. -type IPv4UDPInfo struct { - SrcIP, DstIP net.IP - SrcPort, DstPort uint16 - IHL int // IPv4 header length in bytes - UDPLen uint16 -} - -// ParseIPv4UDP reads the IPv4 + UDP header pair out of an outbound -// captured packet and returns the addressing info. Does NOT mutate -// the buffer. -// -// Errors when: -// - buffer too short to contain a full IPv4+UDP header (28 bytes) -// - IP version is not 4 -// - IP protocol is not 17 (UDP) -func ParseIPv4UDP(b []byte) (*IPv4UDPInfo, error) { - if len(b) < 28 { - return nil, errors.New("packet shorter than IPv4+UDP minimum") - } - if b[0]>>4 != 4 { - return nil, errors.New("not IPv4") - } - ihl := int(b[0]&0x0f) * 4 - if ihl < 20 || len(b) < ihl+8 { - return nil, errors.New("IPv4 IHL invalid or buffer truncated") - } - if b[9] != 17 { - return nil, errors.New("not UDP") - } - src := net.IPv4(b[12], b[13], b[14], b[15]) - dst := net.IPv4(b[16], b[17], b[18], b[19]) - srcPort := binary.BigEndian.Uint16(b[ihl : ihl+2]) - dstPort := binary.BigEndian.Uint16(b[ihl+2 : ihl+4]) - udpLen := binary.BigEndian.Uint16(b[ihl+4 : ihl+6]) - return &IPv4UDPInfo{ - SrcIP: src, - DstIP: dst, - SrcPort: srcPort, - DstPort: dstPort, - IHL: ihl, - UDPLen: udpLen, - }, nil -} - -// SwapUDPAndSetDstPort applies the canonical streamdump-style swap to -// a UDP packet: swap IPv4 src/dst, set UDP dst port to newDstPort. -// Keeps the original UDP src port. Recomputes IP and UDP checksums. -// -// (For UDP, swap+reinject is generally NOT used by drover — the -// engine's diverterLoop "consumes" target UDP packets and forwards -// them through the SOCKS5 UDP relay directly. This helper is here for -// completeness/symmetry with the TCP swap helpers and for tests.) -func SwapUDPAndSetDstPort(b []byte, newDstPort uint16) error { - if _, err := ParseIPv4UDP(b); err != nil { - return err - } - ihl := int(b[0]&0x0f) * 4 - - // Swap src ↔ dst IPv4 - var src, dst [4]byte - copy(src[:], b[12:16]) - copy(dst[:], b[16:20]) - copy(b[12:16], dst[:]) - copy(b[16:20], src[:]) - - // Set UDP dst port - binary.BigEndian.PutUint16(b[ihl+2:ihl+4], newDstPort) - - // Recompute IP checksum - b[10], b[11] = 0, 0 - cs := ipChecksum(b[:ihl]) - b[10] = byte(cs >> 8) - b[11] = byte(cs & 0xff) - - // Recompute UDP checksum (offset ihl+6,ihl+7 inside UDP header) - udpLen := int(binary.BigEndian.Uint16(b[ihl+4 : ihl+6])) - if ihl+udpLen > len(b) { - udpLen = len(b) - ihl - } - b[ihl+6], b[ihl+7] = 0, 0 - cs = udpChecksum(b[:ihl], b[ihl:ihl+udpLen]) - // Zero is "no checksum" in IPv4 UDP. RFC 768 says when the - // computed checksum is zero, transmit it as 0xFFFF instead. - if cs == 0 { - cs = 0xFFFF - } - b[ihl+6] = byte(cs >> 8) - b[ihl+7] = byte(cs & 0xff) - return nil -} - -// SwapUDPAndSetSrcPort applies the canonical streamdump-style return- -// path swap to a UDP packet: swap IPv4 src/dst, set UDP src port to -// newSrcPort (the original target port the client expects to see). -// Recomputes IP and UDP checksums. (Symmetric counterpart to the TCP -// helper; not currently used by the engine for the same reason as -// SwapUDPAndSetDstPort, but exists for tests/parity.) -func SwapUDPAndSetSrcPort(b []byte, newSrcPort uint16) error { - if _, err := ParseIPv4UDP(b); err != nil { - return err - } - ihl := int(b[0]&0x0f) * 4 - - // Swap src ↔ dst IPv4 - var src, dst [4]byte - copy(src[:], b[12:16]) - copy(dst[:], b[16:20]) - copy(b[12:16], dst[:]) - copy(b[16:20], src[:]) - - // Set UDP src port - binary.BigEndian.PutUint16(b[ihl:ihl+2], newSrcPort) - - // Recompute IP checksum - b[10], b[11] = 0, 0 - cs := ipChecksum(b[:ihl]) - b[10] = byte(cs >> 8) - b[11] = byte(cs & 0xff) - - // Recompute UDP checksum - udpLen := int(binary.BigEndian.Uint16(b[ihl+4 : ihl+6])) - if ihl+udpLen > len(b) { - udpLen = len(b) - ihl - } - b[ihl+6], b[ihl+7] = 0, 0 - cs = udpChecksum(b[:ihl], b[ihl:ihl+udpLen]) - if cs == 0 { - cs = 0xFFFF - } - b[ihl+6] = byte(cs >> 8) - b[ihl+7] = byte(cs & 0xff) - return nil -} - -// BuildIPv4UDPInbound fabricates an IPv4+UDP packet for reinjection -// as inbound (return path from upstream relay → Discord). Used by the -// UDPProxy after the SOCKS5 relay sends back a response: we construct -// a synthetic packet that looks like remote_endpoint → local_IP and -// reinject it via WinDivert with addr.Outbound=0. -// -// src → original Discord destination (the UDP server) -// dst → local LAN IP we bound on -// srcPort → original destination port (e.g. 50007) -// dstPort → Discord's ephemeral src port (so the kernel matches the -// connect()-bound socket) -// -// The returned slice owns its own backing storage; callers may pass -// it directly to (*Handle).Send. -func BuildIPv4UDPInbound(srcIP, dstIP net.IP, srcPort, dstPort uint16, payload []byte) ([]byte, error) { - src := srcIP.To4() - dst := dstIP.To4() - if src == nil || dst == nil { - return nil, errors.New("BuildIPv4UDPInbound: src/dst must be IPv4") - } - if len(payload)+28 > 0xFFFF { - return nil, errors.New("BuildIPv4UDPInbound: payload too large for IPv4 datagram") - } - - totalLen := 20 + 8 + len(payload) - buf := make([]byte, totalLen) - - // IPv4 header (20 bytes, IHL=5, no options) - buf[0] = 0x45 // version=4, IHL=5 - buf[1] = 0x00 // DSCP/ECN - binary.BigEndian.PutUint16(buf[2:4], uint16(totalLen)) - binary.BigEndian.PutUint16(buf[4:6], 0) // ID - binary.BigEndian.PutUint16(buf[6:8], 0) // flags + frag - buf[8] = 64 // TTL - buf[9] = 17 // protocol = UDP - // checksum at [10..11] left zero for now - copy(buf[12:16], src) - copy(buf[16:20], dst) - - // UDP header (8 bytes) - binary.BigEndian.PutUint16(buf[20:22], srcPort) - binary.BigEndian.PutUint16(buf[22:24], dstPort) - binary.BigEndian.PutUint16(buf[24:26], uint16(8+len(payload))) // UDP length - // UDP checksum at [26..27] left zero for now - - // Payload - copy(buf[28:], payload) - - // Recompute IP checksum - cs := ipChecksum(buf[:20]) - buf[10] = byte(cs >> 8) - buf[11] = byte(cs & 0xff) - - // Recompute UDP checksum (over pseudo-header + UDP segment) - cs = udpChecksum(buf[:20], buf[20:]) - if cs == 0 { - cs = 0xFFFF // RFC 768: 0 means "checksum disabled", send 0xFFFF instead - } - buf[26] = byte(cs >> 8) - buf[27] = byte(cs & 0xff) - - return buf, nil -} - -// udpChecksum implements the RFC 768 pseudo-header checksum for IPv4 -// UDP. ipHdr must include src+dst addresses; udpSeg is the full UDP -// header + payload (UDP "length" field already set; checksum field -// inside udpSeg must be zeroed). -// -// IPv4 UDP checksum is technically OPTIONAL — a sender may transmit -// 0 to indicate "no checksum". We always compute one since most -// modern stacks (and Discord) expect a valid checksum. -func udpChecksum(ipHdr, udpSeg []byte) uint16 { - var sum uint32 - // Pseudo-header: src(4) dst(4) zero(1) proto(1) udp_len(2) - for i := 12; i <= 18; i += 2 { - sum += uint32(ipHdr[i])<<8 | uint32(ipHdr[i+1]) - } - sum += uint32(17) // UDP protocol - udpLen := uint32(len(udpSeg)) - sum += udpLen - // UDP segment (header + payload) - for i := 0; i+1 < len(udpSeg); i += 2 { - sum += uint32(udpSeg[i])<<8 | uint32(udpSeg[i+1]) - } - if len(udpSeg)%2 == 1 { - sum += uint32(udpSeg[len(udpSeg)-1]) << 8 - } - for sum>>16 != 0 { - sum = (sum & 0xffff) + (sum >> 16) - } - return ^uint16(sum) -} diff --git a/internal/divert/packet_test.go b/internal/divert/packet_test.go deleted file mode 100644 index a28554f..0000000 --- a/internal/divert/packet_test.go +++ /dev/null @@ -1,260 +0,0 @@ -package divert - -import ( - "encoding/binary" - "net" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// helloTCPSYN is a minimum well-formed IPv4 + TCP SYN packet: -// src=10.0.0.1:54321 dst=1.2.3.4:443 -// Captured from a raw socket trace; checksums are correct. -var helloTCPSYN = []byte{ - // IPv4 header (20 bytes, IHL=5) - 0x45, 0x00, 0x00, 0x28, 0xab, 0xcd, 0x40, 0x00, 0x40, 0x06, - 0x00, 0x00, // checksum placeholder — we'll fill in below - 0x0a, 0x00, 0x00, 0x01, // src 10.0.0.1 - 0x01, 0x02, 0x03, 0x04, // dst 1.2.3.4 - // TCP header (20 bytes) - 0xd4, 0x31, 0x01, 0xbb, // src=54321 dst=443 - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x50, 0x02, 0xff, 0xff, - 0x00, 0x00, // checksum placeholder - 0x00, 0x00, -} - -// fillTestChecksums computes correct IP + TCP checksums for the test -// packet so we can compare against the parser's recompute output. -func fillTestChecksums(b []byte) { - // IP checksum - b[10], b[11] = 0, 0 - cs := ipChecksum(b[:20]) - b[10] = byte(cs >> 8) - b[11] = byte(cs & 0xff) - - // TCP checksum - b[36], b[37] = 0, 0 - cs = tcpChecksum(b[:20], b[20:40]) - b[36] = byte(cs >> 8) - b[37] = byte(cs & 0xff) -} - -func TestParseIPv4TCP_Roundtrip(t *testing.T) { - pkt := make([]byte, len(helloTCPSYN)) - copy(pkt, helloTCPSYN) - fillTestChecksums(pkt) - - p, err := ParseIPv4TCP(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) -} - -func TestRewriteDst_PreservesSrc(t *testing.T) { - pkt := make([]byte, len(helloTCPSYN)) - copy(pkt, helloTCPSYN) - fillTestChecksums(pkt) - - err := RewriteDst(pkt, net.IPv4(127, 0, 0, 1), 8080) - require.NoError(t, err) - - p, err := ParseIPv4TCP(pkt) - require.NoError(t, err) - assert.Equal(t, "127.0.0.1", p.DstIP.String()) - assert.Equal(t, uint16(8080), p.DstPort) - assert.Equal(t, "10.0.0.1", p.SrcIP.String()) - assert.Equal(t, uint16(54321), p.SrcPort) -} - -func TestRewriteDst_RecomputesChecksums(t *testing.T) { - pkt := make([]byte, len(helloTCPSYN)) - copy(pkt, helloTCPSYN) - fillTestChecksums(pkt) - - err := RewriteDst(pkt, net.IPv4(127, 0, 0, 1), 8080) - require.NoError(t, err) - - // Validate IP checksum - ipCs := uint16(pkt[10])<<8 | uint16(pkt[11]) - pkt[10], pkt[11] = 0, 0 - expIP := ipChecksum(pkt[:20]) - pkt[10] = byte(ipCs >> 8) - pkt[11] = byte(ipCs & 0xff) - assert.Equal(t, expIP, ipCs, "IP checksum mismatch") - - // Validate TCP checksum - tcpCs := uint16(pkt[36])<<8 | uint16(pkt[37]) - pkt[36], pkt[37] = 0, 0 - expTCP := tcpChecksum(pkt[:20], pkt[20:]) - pkt[36] = byte(tcpCs >> 8) - pkt[37] = byte(tcpCs & 0xff) - assert.Equal(t, expTCP, tcpCs, "TCP checksum mismatch") -} - -func TestParseIPv4TCP_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}}, - {"not_tcp", []byte{0x45, 0, 0, 20, 0, 0, 0, 0, 0, 17, /* UDP */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - _, err := ParseIPv4TCP(c.b) - assert.Error(t, err) - }) - } -} - -// helloUDP is a minimum well-formed IPv4 + UDP datagram: -// -// src=10.0.0.1:54321 dst=1.2.3.4:443 payload=4 bytes ABCD -// -// Total length: 20(IP) + 8(UDP) + 4(payload) = 32 bytes. -var helloUDP = []byte{ - // IPv4 header (20 bytes, IHL=5) - 0x45, 0x00, 0x00, 0x20, 0xab, 0xcd, 0x40, 0x00, 0x40, 0x11, // proto=17 (UDP) - 0x00, 0x00, // checksum placeholder - 0x0a, 0x00, 0x00, 0x01, // src 10.0.0.1 - 0x01, 0x02, 0x03, 0x04, // dst 1.2.3.4 - // UDP header (8 bytes) - 0xd4, 0x31, 0x01, 0xbb, // src=54321 dst=443 - 0x00, 0x0c, // length=12 (UDP header + 4 payload) - 0x00, 0x00, // checksum placeholder - // Payload (4 bytes) - 'A', 'B', 'C', 'D', -} - -func fillUDPTestChecksums(b []byte) { - // IP checksum - b[10], b[11] = 0, 0 - cs := ipChecksum(b[:20]) - b[10] = byte(cs >> 8) - b[11] = byte(cs & 0xff) - // UDP checksum (covers UDP header + payload + pseudo-header) - udpLen := int(binary.BigEndian.Uint16(b[24:26])) - b[26], b[27] = 0, 0 - cs = udpChecksum(b[:20], b[20:20+udpLen]) - if cs == 0 { - cs = 0xFFFF - } - b[26] = byte(cs >> 8) - b[27] = byte(cs & 0xff) -} - -func TestParseIPv4UDP_Roundtrip(t *testing.T) { - pkt := make([]byte, len(helloUDP)) - copy(pkt, helloUDP) - fillUDPTestChecksums(pkt) - - p, err := ParseIPv4UDP(pkt) - require.NoError(t, err) - - assert.Equal(t, "10.0.0.1", p.SrcIP.String()) - assert.Equal(t, "1.2.3.4", p.DstIP.String()) - assert.Equal(t, uint16(54321), p.SrcPort) - assert.Equal(t, uint16(443), p.DstPort) - assert.Equal(t, 20, p.IHL) - assert.Equal(t, uint16(12), p.UDPLen) -} - -func TestParseIPv4UDP_Errors(t *testing.T) { - cases := []struct { - name string - b []byte - }{ - {"too_short", []byte{0x45}}, - {"not_ipv4", []byte{0x60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}}, - {"not_udp", []byte{0x45, 0, 0, 20, 0, 0, 0, 0, 0, 6, /* TCP */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - _, err := ParseIPv4UDP(c.b) - assert.Error(t, err) - }) - } -} - -func TestSwapUDPAndSetDstPort(t *testing.T) { - pkt := make([]byte, len(helloUDP)) - copy(pkt, helloUDP) - fillUDPTestChecksums(pkt) - - require.NoError(t, SwapUDPAndSetDstPort(pkt, 8080)) - - p, err := ParseIPv4UDP(pkt) - require.NoError(t, err) - assert.Equal(t, "1.2.3.4", p.SrcIP.String(), "src should be original dst after swap") - assert.Equal(t, "10.0.0.1", p.DstIP.String(), "dst should be original src after swap") - assert.Equal(t, uint16(54321), p.SrcPort, "src port unchanged") - assert.Equal(t, uint16(8080), p.DstPort, "dst port set to new value") - - // Validate IP checksum recomputed - ipCs := uint16(pkt[10])<<8 | uint16(pkt[11]) - pkt[10], pkt[11] = 0, 0 - expIP := ipChecksum(pkt[:20]) - assert.Equal(t, expIP, ipCs, "IP checksum mismatch") -} - -func TestSwapUDPAndSetSrcPort(t *testing.T) { - pkt := make([]byte, len(helloUDP)) - copy(pkt, helloUDP) - fillUDPTestChecksums(pkt) - - require.NoError(t, SwapUDPAndSetSrcPort(pkt, 50007)) - - p, err := ParseIPv4UDP(pkt) - require.NoError(t, err) - assert.Equal(t, "1.2.3.4", p.SrcIP.String()) - assert.Equal(t, "10.0.0.1", p.DstIP.String()) - assert.Equal(t, uint16(50007), p.SrcPort, "src port set to new value") - assert.Equal(t, uint16(443), p.DstPort, "dst port unchanged") -} - -func TestBuildIPv4UDPInbound(t *testing.T) { - src := net.IPv4(140, 82, 121, 4) // GitHub IP, just for variety - dst := net.IPv4(192, 168, 1, 50) // local LAN - payload := []byte("hello voice") - - pkt, err := BuildIPv4UDPInbound(src, dst, 50007, 50100, payload) - require.NoError(t, err) - - // Total length: 20+8+11 = 39 - assert.Len(t, pkt, 39) - - // Re-parse and verify fields - p, err := ParseIPv4UDP(pkt) - require.NoError(t, err) - assert.Equal(t, "140.82.121.4", p.SrcIP.String()) - assert.Equal(t, "192.168.1.50", p.DstIP.String()) - assert.Equal(t, uint16(50007), p.SrcPort) - assert.Equal(t, uint16(50100), p.DstPort) - assert.Equal(t, uint16(8+len(payload)), p.UDPLen) - - // Payload after headers - assert.Equal(t, payload, pkt[28:]) - - // IP checksum valid: clearing + recomputing should match - ipCs := uint16(pkt[10])<<8 | uint16(pkt[11]) - pkt[10], pkt[11] = 0, 0 - expIP := ipChecksum(pkt[:20]) - assert.Equal(t, expIP, ipCs, "IP checksum should be valid") - - // UDP checksum valid (and non-zero) - udpCs := uint16(pkt[26])<<8 | uint16(pkt[27]) - assert.NotEqual(t, uint16(0), udpCs, "UDP checksum should be non-zero (RFC 768 trick)") -} - -func TestBuildIPv4UDPInbound_NotIPv4(t *testing.T) { - v6 := net.ParseIP("::1") - _, err := BuildIPv4UDPInbound(v6, net.IPv4(1, 2, 3, 4), 1, 2, []byte("x")) - assert.Error(t, err) -} diff --git a/internal/engine/doc.go b/internal/engine/doc.go deleted file mode 100644 index 48d136d..0000000 --- a/internal/engine/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package engine orchestrates the packet processing pipeline. -package engine diff --git a/internal/engine/engine.go b/internal/engine/engine.go deleted file mode 100644 index 8ba6975..0000000 --- a/internal/engine/engine.go +++ /dev/null @@ -1,667 +0,0 @@ -//go:build windows - -package engine - -import ( - "context" - "errors" - "fmt" - "log" - "net" - "os" - "strings" - "sync" - "time" - - "git.okcu.io/root/drover-go/internal/divert" - "git.okcu.io/root/drover-go/internal/procscan" - "git.okcu.io/root/drover-go/internal/redirect" - "git.okcu.io/root/drover-go/internal/socks5" -) - -// Config configures the engine. -type Config struct { - ProxyAddr string // "host:port" of upstream SOCKS5 proxy - UseAuth bool - Login string - Password string - Targets []string // exe basenames to capture (Discord.exe etc) -} - -// Engine is the orchestrator. Use New + Start/Stop. -type Engine struct { - cfg Config - - mu sync.Mutex - status Status - lastErr error - - // runtime state - upstreamIP net.IP - localIP net.IP // primary outbound LAN IP — listener binds here so reinjected NAT'd packets reach it (kernel drops src=LAN/dst=127.0.0.1 as spoofed) - handleMu sync.RWMutex // guards handle + flowH swap during procscan rebuild - handle *divert.Handle // NETWORK layer: capture/rewrite/reinject packets - flowH *divert.Handle // FLOW layer: capture-ALL events (filter "true"); we filter by PID in-process - redir *redirect.Redirector - udp *redirect.UDPProxy // SOCKS5 UDP relay manager — handles Discord voice etc. - ctx context.Context - cnl context.CancelFunc - wg sync.WaitGroup - ownPID uint32 - - // pidMu guards targetPIDs. Updated by procscanLoop, read by flowLoop - // for every event. Read frequency: ~50 events/sec average; write: - // every 2s. RWMutex contention negligible. - pidMu sync.RWMutex - targetPIDs map[uint32]struct{} - - // flowSet tracks 5-tuples currently belonging to target processes. - // Populated by flowLoop on EventFlowEstablished, removed on - // EventFlowDeleted. Read by diverterLoop on every captured packet - // to decide whether to redirect or pass through. - flowMu sync.RWMutex - flowSet map[flowKey]struct{} -} - -// flowKey identifies a flow by its 5-tuple. Drover uses local→remote -// (i.e. always the outbound direction) — the flow handle reports -// LocalAddr/Port and RemoteAddr/Port which match an outbound packet's -// SrcAddr/Port and DstAddr/Port. -// flowKey identifies a tracked flow. We deliberately omit SrcIP from -// the key: when Discord (or any client) binds a UDP/TCP socket to -// INADDR_ANY (0.0.0.0), the SOCKET layer reports src=0.0.0.0, but the -// actual outbound packet has src= (kernel fills the -// interface address). Including src in the key would cause those -// flows to miss the lookup. Source port + destination + proto is a -// sufficient discriminator on a single host. -type flowKey struct { - dst [4]byte - sport uint16 - dport uint16 - proto uint8 // 6=TCP, 17=UDP -} - -// New constructs an engine. No I/O yet. -func New(cfg Config) (*Engine, error) { - if cfg.ProxyAddr == "" { - return nil, errors.New("ProxyAddr is required") - } - return &Engine{ - cfg: cfg, - status: StatusIdle, - ownPID: uint32(os.Getpid()), - flowSet: map[flowKey]struct{}{}, - targetPIDs: map[uint32]struct{}{}, - }, nil -} - -// Status returns the current engine status (cheap, no I/O). -func (e *Engine) Status() Status { - e.mu.Lock() - defer e.mu.Unlock() - return e.status -} - -// LastError returns the last error that pushed us to Failed (or nil). -func (e *Engine) LastError() error { - e.mu.Lock() - defer e.mu.Unlock() - return e.lastErr -} - -func (e *Engine) transition(to Status, err error) { - e.mu.Lock() - if !isValidTransition(e.status, to) { - // Permissive: log but don't panic in production; most invalid - // transitions are programming errors caught by the state test. - } - e.status = to - if err != nil { - e.lastErr = err - } else if to == StatusActive || to == StatusIdle { - e.lastErr = nil - } - e.mu.Unlock() -} - -// Start brings the engine to Active. Returns nil even when transition -// to Failed happens — caller checks Status afterwards. The provided -// ctx is honoured for the bring-up sequence (proxy resolve, driver -// install, handle open, etc). -func (e *Engine) Start(ctx context.Context) error { - e.mu.Lock() - if e.status != StatusIdle && e.status != StatusFailed { - e.mu.Unlock() - return fmt.Errorf("Start requires Idle or Failed; got %s", e.status) - } - e.status = StatusStarting - e.mu.Unlock() - - if err := e.bringUp(ctx); err != nil { - e.transition(StatusFailed, err) - return err - } - e.transition(StatusActive, nil) - return nil -} - -func (e *Engine) bringUp(ctx context.Context) error { - log.Printf("engine: bringUp start cfg.ProxyAddr=%q targets=%v", e.cfg.ProxyAddr, e.cfg.Targets) - - // 1. Resolve upstream - host, _, err := net.SplitHostPort(e.cfg.ProxyAddr) - if err != nil { - log.Printf("engine: SplitHostPort failed: %v", err) - return fmt.Errorf("invalid ProxyAddr: %w", err) - } - rctx, rcancel := context.WithTimeout(ctx, 5*time.Second) - defer rcancel() - ips, err := net.DefaultResolver.LookupIPAddr(rctx, host) - if err != nil || len(ips) == 0 { - log.Printf("engine: LookupIPAddr(%q) failed: %v (ips=%v)", host, err, ips) - return fmt.Errorf("resolve proxy host %q: %w", host, err) - } - var upstream net.IP - for _, a := range ips { - if v4 := a.IP.To4(); v4 != nil { - upstream = v4 - break - } - } - if upstream == nil { - log.Printf("engine: no IPv4 for %q (got %v)", host, ips) - return fmt.Errorf("no IPv4 for %q", host) - } - e.upstreamIP = upstream - log.Printf("engine: upstream resolved %s → %s", host, upstream) - - // 1b. Detect outbound LAN IP — listener binds here. Trick: - // open a UDP "connect" to any external IP; kernel picks the - // outbound interface and we read LocalAddr off the conn. - if udpConn, dErr := net.Dial("udp", "8.8.8.8:53"); dErr == nil { - e.localIP = udpConn.LocalAddr().(*net.UDPAddr).IP.To4() - udpConn.Close() - } - if e.localIP == nil { - log.Printf("engine: could not detect local LAN IP — falling back to 127.0.0.1 (may not work)") - e.localIP = net.IPv4(127, 0, 0, 1) - } - log.Printf("engine: local LAN IP = %s", e.localIP) - - // 2. Driver install (idempotent) - paths, err := divert.InstallDriver() - if err != nil { - log.Printf("engine: InstallDriver failed: %v", err) - return fmt.Errorf("install driver: %w", err) - } - log.Printf("engine: driver installed sys=%s dll=%s", paths.SysPath, paths.DllPath) - - // 3. Initial procscan - pids, err := procscan.Snapshot(e.cfg.Targets) - if err != nil { - log.Printf("engine: procscan.Snapshot failed: %v", err) - return fmt.Errorf("procscan: %w", err) - } - pidList := make([]uint32, 0, len(pids)) - for p := range pids { - pidList = append(pidList, p) - } - log.Printf("engine: initial procscan found %d target pids: %v", len(pidList), pids) - - // 4. Open redirector listener on 0.0.0.0 so it accepts on any - // interface (including the LAN IP we'll target with the swap-and- - // reinject NAT pattern). After the streamdump swap the packet has - // dst=LAN_IP:listener_port — kernel delivers via inbound path of - // the LAN interface; listener accepts it as a regular TCP conn. - r, err := redirect.New(redirect.Config{ - SOCKS5: socks5.Config{ - ProxyAddr: e.cfg.ProxyAddr, - UseAuth: e.cfg.UseAuth, - Login: e.cfg.Login, - Password: e.cfg.Password, - }, - Bind: "0.0.0.0:0", - }) - if err != nil { - log.Printf("engine: redirect.New failed: %v", err) - return fmt.Errorf("redirector: %w", err) - } - e.redir = r - log.Printf("engine: redirector listening on %s", r.LocalAddr()) - - // 5. Build filters - // SOCKET handle uses "true" — capture ALL socket events. We - // filter by PID in-process. SOCKET layer fires Connect events - // SYNCHRONOUSLY with the connect() syscall, BEFORE the SYN packet - // leaves the box — which gives socketLoop time to populate the - // redirector mapping before NETWORK-layer SYN arrives. - // - // (FLOW Established events fire after the 3-way handshake, which - // is too late for our SYN-redirect plan — by then the conn is - // already pointing at the real target.) - netFilter := divert.BuildNetworkFilter(divert.FilterParams{ - TargetPIDs: pidList, - OwnPID: e.ownPID, - UpstreamIP: upstream.String(), - LocalIP: e.localIP.String(), - }) - log.Printf("engine: socket filter: \"true\" (capture-all, PID-filter in-process)") - log.Printf("engine: network filter: %s", netFilter) - - // Seed targetPIDs from initial procscan - e.pidMu.Lock() - for p := range pids { - e.targetPIDs[p] = struct{}{} - } - e.pidMu.Unlock() - - // 6. Open SOCKET handle FIRST with broad filter so we never miss - // a new connection between procscan ticks. socketLoop discards - // events from non-target PIDs in-process. - flowH, err := divert.OpenSocket("true") - if err != nil { - log.Printf("engine: divert.OpenSocket failed: %v", err) - r.Close() - return fmt.Errorf("WinDivert socket open: %w", err) - } - e.flowH = flowH - log.Printf("engine: WinDivert SOCKET handle opened (filter=\"true\")") - - // 7. Open NETWORK handle for actual packet capture/redirect. - netH, err := divert.Open(netFilter) - if err != nil { - log.Printf("engine: divert.Open(network) failed: %v", err) - flowH.Close() - r.Close() - return fmt.Errorf("WinDivert network open: %w", err) - } - e.handle = netH - log.Printf("engine: WinDivert NETWORK handle opened") - - // 7b. UDP proxy. The SOCKS5 UDP ASSOCIATE control conn is opened - // lazily on the first UDP packet from a target, so this New call - // is non-blocking — no upstream I/O happens here. - udpProxy, err := redirect.NewUDP(redirect.UDPConfig{ - SOCKS5: socks5.Config{ - ProxyAddr: e.cfg.ProxyAddr, - UseAuth: e.cfg.UseAuth, - Login: e.cfg.Login, - Password: e.cfg.Password, - }, - LocalIP: e.localIP, - Injector: divertHandleInjector{h: netH}, - LogPrefix: "engine udp: ", - }) - if err != nil { - log.Printf("engine: redirect.NewUDP failed: %v", err) - netH.Close() - flowH.Close() - r.Close() - return fmt.Errorf("udp proxy: %w", err) - } - e.udp = udpProxy - log.Printf("engine: UDP proxy ready (lazy SOCKS5 ASSOCIATE)") - - // 8. Spawn socket tracker + divert reader + procscan ticker - e.ctx, e.cnl = context.WithCancel(context.Background()) - e.wg.Add(3) - go e.socketLoop() - go e.diverterLoop() - go e.procscanLoop() - log.Printf("engine: bringUp complete, transitioning to Active") - return nil -} - -// divertHandleInjector adapts *divert.Handle to redirect.UDPInjector. -// We expose Send through the SendInjectInbound helper which sets the -// right WinDivert flags for fabricated inbound packets. -type divertHandleInjector struct { - h *divert.Handle -} - -func (d divertHandleInjector) Send(buf []byte, _ redirect.UDPInjectAddr) (int, error) { - // We only ever inject UDP via this path (TCP path uses the - // captured addr directly in diverterLoop). - return d.h.SendInjectInbound(buf, true /* isUDP */) -} - -// Stop tears down. Always returns to Idle (or stays in Idle if -// already there). -func (e *Engine) Stop() error { - e.mu.Lock() - if e.status == StatusIdle { - e.mu.Unlock() - return nil - } - e.mu.Unlock() - - if e.cnl != nil { - e.cnl() - } - if e.handle != nil { - e.handle.Close() - } - if e.flowH != nil { - e.flowH.Close() - } - if e.udp != nil { - e.udp.Close() - } - if e.redir != nil { - e.redir.Close() - } - e.wg.Wait() - e.handle = nil - e.flowH = nil - e.redir = nil - e.udp = nil - e.transition(StatusIdle, nil) - return nil -} - -// socketLoop reads socket-layer events (Connect/Close) from the -// SOCKET handle and maintains e.flowSet + the redirector mapping. -// -// SOCKET Connect fires synchronously with the connect() syscall on -// the originating thread, BEFORE the SYN packet is dispatched. This -// is the critical window: by populating flowSet+mapping in this -// handler, the diverterLoop's NETWORK capture of the SYN finds the -// target on first lookup and redirects correctly. -func (e *Engine) socketLoop() { - defer e.wg.Done() - log.Printf("engine: socketLoop started") - iter := 0 - for { - select { - case <-e.ctx.Done(): - log.Printf("engine: socketLoop ctx done after %d iterations", iter) - return - default: - } - iter++ - e.handleMu.RLock() - h := e.flowH - e.handleMu.RUnlock() - if h == nil { - time.Sleep(50 * time.Millisecond) - continue - } - ev, err := h.RecvSocket() - if err != nil { - if errors.Is(err, divert.ErrShutdown) || errors.Is(err, divert.ErrInvalidHandle) { - log.Printf("engine: socketLoop terminal error after %d iterations: %v", iter, err) - return - } - log.Printf("engine: socketLoop transient error (iter %d): %v", iter, err) - time.Sleep(100 * time.Millisecond) - continue - } - - // In-process PID filter. Fast path: PID is in the set procscan - // fed us. Slow path: PID isn't yet known (Update.exe spawn → - // connect → exit routinely fits inside the 2-second procscan - // tick), so resolve PID → exe name on demand and admit it if - // the name matches our Targets list. This is what makes - // "Checking for updates" finish in ~5 s instead of 30+. - e.pidMu.RLock() - _, isTarget := e.targetPIDs[ev.ProcessID] - e.pidMu.RUnlock() - if !isTarget { - if name, err := procscan.ResolvePID(ev.ProcessID); err == nil { - lname := strings.ToLower(name) - for _, t := range e.cfg.Targets { - if strings.EqualFold(t, lname) || strings.EqualFold(t, name) { - isTarget = true - e.pidMu.Lock() - e.targetPIDs[ev.ProcessID] = struct{}{} - e.pidMu.Unlock() - log.Printf("engine: lazy-admit pid=%d name=%s (matched target)", ev.ProcessID, name) - break - } - } - } - if !isTarget { - continue - } - } - - switch ev.Kind { - case divert.SocketKindConnect: - // Connect fires before SYN. Populate redirector mapping + - // flowSet so when SYN arrives at NETWORK layer the - // diverterLoop knows to redirect. - key := flowKey{ - dst: ev.DstAddr, - sport: ev.SrcPort, - dport: ev.DstPort, - proto: ev.Protocol, - } - e.flowMu.Lock() - e.flowSet[key] = struct{}{} - setSize := len(e.flowSet) - e.flowMu.Unlock() - e.redir.SetMapping(ev.SrcPort, net.IPv4(ev.DstAddr[0], ev.DstAddr[1], ev.DstAddr[2], ev.DstAddr[3]), ev.DstPort) - log.Printf("engine: socket connect pid=%d proto=%d %v:%d → %v:%d (set size=%d)", - ev.ProcessID, ev.Protocol, ev.SrcAddr, ev.SrcPort, ev.DstAddr, ev.DstPort, setSize) - case divert.SocketKindClose: - key := flowKey{ - dst: ev.DstAddr, - sport: ev.SrcPort, - dport: ev.DstPort, - proto: ev.Protocol, - } - e.flowMu.Lock() - delete(e.flowSet, key) - e.flowMu.Unlock() - } - } -} - -func (e *Engine) diverterLoop() { - defer e.wg.Done() - log.Printf("engine: diverterLoop started") - buf := make([]byte, 65536) - listenerPort := e.redir.LocalPort() - var rxCount, redirCount int64 - statusTk := time.NewTicker(5 * time.Second) - defer statusTk.Stop() - go func() { - for range statusTk.C { - select { - case <-e.ctx.Done(): - return - default: - } - var udpFwd, udpFwdBytes, udpRecv, udpInj uint64 - if e.udp != nil { - udpFwd, udpFwdBytes, udpRecv, udpInj = e.udp.Stats() - } - log.Printf("engine: diverter stats rx=%d tcpRedir=%d flowSet=%d | UDP fwd=%d/%dB recv=%d injected=%d", - rxCount, redirCount, len(e.flowSet), udpFwd, udpFwdBytes, udpRecv, udpInj) - } - }() - for { - select { - case <-e.ctx.Done(): - return - default: - } - e.handleMu.RLock() - h := e.handle - e.handleMu.RUnlock() - if h == nil { - time.Sleep(50 * time.Millisecond) - continue - } - n, addr, err := h.Recv(buf) - if err != nil { - if errors.Is(err, divert.ErrShutdown) || errors.Is(err, divert.ErrInvalidHandle) { - log.Printf("engine: diverterLoop terminal error after %d rx: %v", rxCount, err) - e.transition(StatusFailed, err) - return - } - log.Printf("engine: diverterLoop transient Recv error: %v", err) - continue - } - rxCount++ - - // === UDP fast path === - // Quick header sniff: if proto=17 (UDP), try the UDP-flow - // branch. Target UDP flows are forwarded through the SOCKS5 - // UDP relay (consumed — NOT reinjected); non-target UDP is - // passed through unmodified. - if n >= 10 && buf[0]>>4 == 4 && buf[9] == 17 { - udpInfo, uerr := divert.ParseIPv4UDP(buf[:n]) - if uerr == nil { - var ukey flowKey - copy(ukey.dst[:], udpInfo.DstIP.To4()) - ukey.sport = udpInfo.SrcPort - ukey.dport = udpInfo.DstPort - ukey.proto = 17 - - e.flowMu.RLock() - _, isUDPTarget := e.flowSet[ukey] - e.flowMu.RUnlock() - - if isUDPTarget && e.udp != nil { - // Strip IPv4 + UDP headers; the rest is application - // payload that we hand to the SOCKS5 UDP relay. - payload := buf[udpInfo.IHL+8 : n] - if ferr := e.udp.Forward(udpInfo.SrcIP, udpInfo.SrcPort, udpInfo.DstIP, udpInfo.DstPort, payload); ferr != nil { - log.Printf("engine: udp forward error %v:%d → %v:%d: %v", - udpInfo.SrcIP, udpInfo.SrcPort, udpInfo.DstIP, udpInfo.DstPort, ferr) - // Drop on error — UDP loss is acceptable. - } else { - redirCount++ - } - // Consumed: do NOT reinject — relay reader will - // fabricate the inbound reply. - continue - } - // Non-target UDP: pass through unmodified. - _, _ = h.Send(buf[:n], addr) - continue - } - // Malformed UDP — fall through to TCP parse path (which - // will also fail and reinject). - } - - // Parse + decide - info, err := divert.ParseIPv4TCP(buf[:n]) - if err != nil { - // Not IPv4-TCP and not handled by UDP path above. Reinject - // as-is so non-target traffic continues normally. - _, _ = h.Send(buf[:n], addr) - continue - } - - localV4 := e.localIP.To4() - srcV4 := info.SrcIP.To4() - - // === RETURN path === : packet emitted by our listener back to - // the client. SrcIP=local LAN, SrcPort=listener_port. We swap - // IPs and rewrite SrcPort to the original target port so the - // client (Discord) sees a response that matches its connect() - // pair (src=real_target:real_port, dst=local_IP:client_eph). - if info.SrcPort == listenerPort && srcV4 != nil && srcV4.Equal(localV4) { - realIP, realPort, ok := e.redir.GetMapping(info.DstPort) - if !ok { - // No mapping — should be rare (TTL evicted?). Drop by - // reinjecting as-is; client will retransmit. - _, _ = h.Send(buf[:n], addr) - continue - } - _ = realIP // streamdump only needs the original target PORT; - // the IP is already the right one after the swap below - // (we swap dst/src — original dst (=local) becomes src, - // original src (=client_local) becomes dst). The original - // remote IP is not on this packet — it's listener→client, - // not listener→remote. So srcIP after swap = info.DstIP = - // real Discord IP because... actually no — our SrcIP IS - // local, our DstIP IS Discord. After swap our SrcIP = - // Discord, DstIP = local. That's exactly what we want. - if err := divert.SwapAndSetSrcPort(buf[:n], realPort); err == nil { - addr.Flags &^= 0x02 // clear Outbound (deliver as inbound) - addr.Flags |= 0x60 // signal IP+TCP checksums valid - _, _ = h.Send(buf[:n], addr) - redirCount++ - } - continue - } - - // === FORWARD path === : packet from a target process to a - // remote. Apply streamdump swap so the kernel delivers it to - // our listener via the inbound path. - var key flowKey - copy(key.dst[:], info.DstIP.To4()) - key.sport = info.SrcPort - key.dport = info.DstPort - key.proto = 6 - - e.flowMu.RLock() - _, isTarget := e.flowSet[key] - e.flowMu.RUnlock() - if !isTarget { - _, _ = h.Send(buf[:n], addr) - continue - } - - // Target flow: refresh redirector mapping and apply the - // canonical streamdump swap (swap src↔dst, dst.port=listener, - // addr.Outbound=0, mark checksums valid). - e.redir.SetMapping(info.SrcPort, info.DstIP, info.DstPort) - if err := divert.SwapAndSetDstPort(buf[:n], listenerPort); err == nil { - addr.Flags &^= 0x02 // clear Outbound (deliver as inbound) - addr.Flags |= 0x60 // signal IP+TCP checksums valid - _, _ = h.Send(buf[:n], addr) - redirCount++ - } - } -} - -func (e *Engine) procscanLoop() { - defer e.wg.Done() - tk := time.NewTicker(2 * time.Second) - defer tk.Stop() - - prev, _ := procscan.Snapshot(e.cfg.Targets) - for { - select { - case <-e.ctx.Done(): - return - case <-tk.C: - } - cur, err := procscan.Snapshot(e.cfg.Targets) - if err != nil { - continue - } - add, rem := procscan.DiffPIDs(prev, cur) - if len(add) == 0 && len(rem) == 0 { - continue - } - log.Printf("engine: procscan delta added=%v removed=%v", add, rem) - - // Update targetPIDs map. flowLoop reads it on every event; - // no handle reopen needed (FLOW filter is "true"). - e.pidMu.Lock() - for _, p := range add { - e.targetPIDs[p] = struct{}{} - } - for _, p := range rem { - delete(e.targetPIDs, p) - } - e.pidMu.Unlock() - - // Drop tracked flows for the removed PIDs. We don't actually - // know which flowKey belongs to which PID (we lose that info - // after Established → flowSet keyed by 5-tuple, not PID), so - // for safety just clear the set when a target PID disappears - // — flow events from the new PIDs will repopulate. - if len(rem) > 0 { - e.flowMu.Lock() - e.flowSet = map[flowKey]struct{}{} - e.flowMu.Unlock() - } - prev = cur - } -} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go deleted file mode 100644 index 2634dc6..0000000 --- a/internal/engine/engine_test.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:build windows && integration - -package engine - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// TestEngine_StartStop_Smoke is an integration test that requires: -// - admin -// - reachable upstream SOCKS5 proxy -// - WinDivert v2.2.2 driver available (or auto-installed) -// -// Build tag: integration. Run with: -// -// go test -tags integration ./internal/engine/... -run TestEngine_StartStop_Smoke -// -// On a clean dev box this is the canary that proves the full pipeline -// is wired correctly. -func TestEngine_StartStop_Smoke(t *testing.T) { - cfg := Config{ - ProxyAddr: "95.165.72.59:12334", - Targets: []string{"explorer.exe"}, // safe target — won't actually proxy anything important - } - e, err := New(cfg) - require.NoError(t, err) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - require.NoError(t, e.Start(ctx)) - - // Should reach Active within a few seconds - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) && e.Status() != StatusActive { - time.Sleep(50 * time.Millisecond) - } - require.Equal(t, StatusActive, e.Status()) - - require.NoError(t, e.Stop()) - require.Equal(t, StatusIdle, e.Status()) -} diff --git a/internal/engine/state.go b/internal/engine/state.go deleted file mode 100644 index ccc7c42..0000000 --- a/internal/engine/state.go +++ /dev/null @@ -1,29 +0,0 @@ -package engine - -// Status is the engine's lifecycle state. -type Status string - -const ( - StatusIdle Status = "idle" - StatusStarting Status = "starting" - StatusActive Status = "active" - StatusFailed Status = "failed" - // Reconnecting added in P2.3. -) - -// isValidTransition guards the state machine. Used by Engine.transition -// to assert in dev/test; production code logs a warning rather than -// panicking on invalid transitions. -func isValidTransition(from, to Status) bool { - switch from { - case StatusIdle: - return to == StatusStarting - case StatusStarting: - return to == StatusActive || to == StatusFailed - case StatusActive: - return to == StatusIdle || to == StatusFailed - case StatusFailed: - return to == StatusStarting || to == StatusIdle - } - return false -} diff --git a/internal/engine/state_test.go b/internal/engine/state_test.go deleted file mode 100644 index 455c32b..0000000 --- a/internal/engine/state_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package engine - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestStatusTransitions_Valid(t *testing.T) { - cases := []struct { - from Status - to Status - ok bool - }{ - {StatusIdle, StatusStarting, true}, - {StatusStarting, StatusActive, true}, - {StatusStarting, StatusFailed, true}, - {StatusActive, StatusIdle, true}, // user clicked Stop - {StatusActive, StatusFailed, true}, // crash - {StatusFailed, StatusStarting, true}, // user clicked Retry - // Invalid transitions - {StatusIdle, StatusActive, false}, - {StatusIdle, StatusFailed, false}, - {StatusActive, StatusStarting, false}, - } - for _, c := range cases { - got := isValidTransition(c.from, c.to) - assert.Equalf(t, c.ok, got, "%s → %s", c.from, c.to) - } -} diff --git a/internal/frontend/.gitkeep b/internal/frontend/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/gui/app.go b/internal/gui/app.go index fbf9c9e..bafb66d 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -12,7 +12,7 @@ import ( "time" "git.okcu.io/root/drover-go/internal/checker" - "git.okcu.io/root/drover-go/internal/engine" + "git.okcu.io/root/drover-go/internal/sboxrun" "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -28,7 +28,7 @@ type App struct { version string mu sync.Mutex - eng *engine.Engine + eng *sboxrun.Engine startedAt time.Time // muCheck guards cancelCheck and checkDone. @@ -179,24 +179,25 @@ func (a *App) StartEngine(cfg Config) error { log.Printf("gui: StartEngine called host=%s port=%d auth=%v", cfg.Host, cfg.Port, cfg.Auth) a.mu.Lock() defer a.mu.Unlock() - if a.eng != nil && a.eng.Status() == engine.StatusActive { + if a.eng != nil && a.eng.Status() == sboxrun.StatusActive { log.Printf("gui: StartEngine no-op (already active)") return nil } - e, err := engine.New(engine.Config{ - ProxyAddr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), - UseAuth: cfg.Auth, - Login: cfg.Login, - Password: cfg.Password, - Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"}, + e, err := sboxrun.New(sboxrun.Config{ + ProxyHost: cfg.Host, + ProxyPort: cfg.Port, + UseAuth: cfg.Auth, + Login: cfg.Login, + Password: cfg.Password, + TargetProcs: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"}, }) if err != nil { - log.Printf("gui: engine.New failed: %v", err) + log.Printf("gui: sboxrun.New failed: %v", err) runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()}) return err } if err := e.Start(a.ctx); err != nil { - log.Printf("gui: engine.Start failed: %v", err) + log.Printf("gui: sboxrun.Start failed: %v", err) runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()}) return err } @@ -224,7 +225,7 @@ func (a *App) StopEngine() error { func (a *App) GetStatus() map[string]any { a.mu.Lock() defer a.mu.Unlock() - running := a.eng != nil && a.eng.Status() == engine.StatusActive + running := a.eng != nil && a.eng.Status() == sboxrun.StatusActive res := map[string]any{ "running": running, "uptimeS": int(time.Since(a.startedAt).Seconds()), @@ -247,7 +248,7 @@ func (a *App) statsLoop() { defer tick.Stop() for range tick.C { a.mu.Lock() - if a.eng == nil || a.eng.Status() != engine.StatusActive || a.ctx == nil { + if a.eng == nil || a.eng.Status() != sboxrun.StatusActive || a.ctx == nil { a.mu.Unlock() continue } diff --git a/internal/gui/frontend/src/components/shared.jsx b/internal/gui/frontend/src/components/shared.jsx index e2a7fad..1aabab3 100644 --- a/internal/gui/frontend/src/components/shared.jsx +++ b/internal/gui/frontend/src/components/shared.jsx @@ -173,7 +173,18 @@ export function useDrover(initial = {}) { async function startProxy() { if (phase !== 'checked') return; if (lastSummary?.failed === tests.length) return; - await StartEngine(); + try { + await StartEngine({ + host: form.host, + port: parseInt(form.port, 10) || 0, + auth: form.auth, + login: form.login, + password: form.password, + }); + } catch (e) { + pushLog('ERROR', 'startEngine failed: ' + (e?.message || e)); + return; + } // engine:status event will flip phase to 'active'. } diff --git a/internal/gui/frontend/wailsjs/go/gui/App.js b/internal/gui/frontend/wailsjs/go/gui/App.js index b764106..f5b4495 100644 --- a/internal/gui/frontend/wailsjs/go/gui/App.js +++ b/internal/gui/frontend/wailsjs/go/gui/App.js @@ -12,7 +12,7 @@ export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) } export function CancelCheck() { return window['go']['gui']['App']['CancelCheck']() } -export function StartEngine() { return window['go']['gui']['App']['StartEngine']() } +export function StartEngine(cfg) { return window['go']['gui']['App']['StartEngine'](cfg) } export function StopEngine() { return window['go']['gui']['App']['StopEngine']() } export function GetStatus() { return window['go']['gui']['App']['GetStatus']() } export function Version() { return window['go']['gui']['App']['Version']() } diff --git a/internal/procscan/doc.go b/internal/procscan/doc.go deleted file mode 100644 index 9879081..0000000 --- a/internal/procscan/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package procscan resolves process IDs via Toolhelp32. -package procscan diff --git a/internal/procscan/procscan.go b/internal/procscan/procscan.go deleted file mode 100644 index 3b21370..0000000 --- a/internal/procscan/procscan.go +++ /dev/null @@ -1,71 +0,0 @@ -//go:build windows - -package procscan - -import ( - "strings" - "syscall" - "unsafe" - - "golang.org/x/sys/windows" -) - -// Snapshot returns a map of PID → exe basename for every running -// process whose exe name (case-insensitively) matches one of the -// names in `targets`. Pass an empty/nil targets to capture all -// processes (useful for debugging). -func Snapshot(targets []string) (map[uint32]string, error) { - snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) - if err != nil { - return nil, err - } - defer windows.CloseHandle(snap) - - var entry windows.ProcessEntry32 - entry.Size = uint32(unsafe.Sizeof(entry)) - - if err := windows.Process32First(snap, &entry); err != nil { - return nil, err - } - - wantAll := len(targets) == 0 - wantSet := make(map[string]struct{}, len(targets)) - for _, n := range targets { - wantSet[strings.ToLower(n)] = struct{}{} - } - - out := map[uint32]string{} - for { - exeName := syscall.UTF16ToString(entry.ExeFile[:]) - if wantAll { - out[entry.ProcessID] = exeName - } else if _, ok := wantSet[strings.ToLower(exeName)]; ok { - out[entry.ProcessID] = exeName - } - err := windows.Process32Next(snap, &entry) - if err != nil { - if err == syscall.ERROR_NO_MORE_FILES { - break - } - return nil, err - } - } - return out, nil -} - -// DiffPIDs reports which PIDs are added (in cur but not prev) and -// removed (in prev but not cur). Used by the engine's procscan ticker -// to decide whether to rebuild the WinDivert filter. -func DiffPIDs(prev, cur map[uint32]string) (added, removed []uint32) { - for pid := range cur { - if _, ok := prev[pid]; !ok { - added = append(added, pid) - } - } - for pid := range prev { - if _, ok := cur[pid]; !ok { - removed = append(removed, pid) - } - } - return -} diff --git a/internal/procscan/procscan_test.go b/internal/procscan/procscan_test.go deleted file mode 100644 index 39c7b69..0000000 --- a/internal/procscan/procscan_test.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build windows - -package procscan - -import ( - "runtime" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSnapshot_MatchesOwnExeName(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip() - } - // We must find ourselves in the snapshot. The Go test binary is - // typically named ${pkg}.test.exe. - snap, err := Snapshot([]string{"go.test.exe", "main.test.exe"}) - require.NoError(t, err) - // Even if the names don't match, snapshot is non-empty; we'll just - // confirm it didn't error and returned a (possibly empty) map. - _ = snap -} - -func TestSnapshot_FiltersCaseInsensitive(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip() - } - // Real test: pass "EXPLORER.EXE" and expect at least one match - // (explorer.exe is essentially always running on a desktop). - snap, err := Snapshot([]string{"EXPLORER.EXE"}) - require.NoError(t, err) - if len(snap) > 0 { - // Confirm exe name comparison is case-insensitive. - for _, name := range snap { - assert.True(t, strings.EqualFold(name, "explorer.exe")) - } - } -} - -func TestDiffPIDs(t *testing.T) { - prev := map[uint32]string{1: "a.exe", 2: "b.exe"} - cur := map[uint32]string{2: "b.exe", 3: "c.exe"} - added, removed := DiffPIDs(prev, cur) - assert.ElementsMatch(t, []uint32{3}, added) - assert.ElementsMatch(t, []uint32{1}, removed) -} diff --git a/internal/procscan/resolve_windows.go b/internal/procscan/resolve_windows.go deleted file mode 100644 index 7e66a1a..0000000 --- a/internal/procscan/resolve_windows.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build windows - -package procscan - -import ( - "fmt" - "path/filepath" - "syscall" - - "golang.org/x/sys/windows" -) - -// ResolvePID returns the exe basename for a given PID, or an error -// if the PID has already exited or we lack the rights to query it. -// -// Used by the engine's socketLoop to do a lazy lookup when SOCKET -// Connect events arrive for processes we haven't yet seen via the -// 2-second procscan tick — Update.exe's full lifecycle (spawn → -// connect → exit) routinely fits inside one tick window, so without -// this lookup the engine would miss its connections entirely and -// Discord's "Checking for updates" would hit its 30 s timeout. -func ResolvePID(pid uint32) (string, error) { - h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) - if err != nil { - return "", fmt.Errorf("OpenProcess pid=%d: %w", pid, err) - } - defer windows.CloseHandle(h) - - buf := make([]uint16, windows.MAX_PATH) - size := uint32(len(buf)) - if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil { - return "", fmt.Errorf("QueryFullProcessImageName pid=%d: %w", pid, err) - } - full := syscall.UTF16ToString(buf[:size]) - return filepath.Base(full), nil -} diff --git a/internal/redirect/tcp.go b/internal/redirect/tcp.go deleted file mode 100644 index 504efb0..0000000 --- a/internal/redirect/tcp.go +++ /dev/null @@ -1,192 +0,0 @@ -package redirect - -import ( - "context" - "errors" - "fmt" - "io" - "net" - "sync" - "time" - - "git.okcu.io/root/drover-go/internal/socks5" -) - -// Config configures the TCP redirector. -type Config struct { - SOCKS5 socks5.Config - Bind string // "127.0.0.1:0" — listener bind addr -} - -type mapping struct { - dstIP net.IP - dstPort uint16 - added time.Time -} - -// Redirector is the loopback listener that catches NAT-rewritten SYNs -// from divert and tunnels them through SOCKS5. -type Redirector struct { - cfg Config - ln net.Listener - mu sync.RWMutex - flows map[uint16]mapping // src_port → mapping - wg sync.WaitGroup - ctx context.Context - cnl context.CancelFunc -} - -// New starts a Redirector. It binds the listener but does not yet -// have any mappings; SetMapping is called by the divert layer when -// it sees an outbound SYN from a target PID. -func New(cfg Config) (*Redirector, error) { - bind := cfg.Bind - if bind == "" { - bind = "127.0.0.1:0" - } - ln, err := net.Listen("tcp", bind) - if err != nil { - return nil, fmt.Errorf("listen %s: %w", bind, err) - } - ctx, cnl := context.WithCancel(context.Background()) - r := &Redirector{ - cfg: cfg, - ln: ln, - flows: map[uint16]mapping{}, - ctx: ctx, - cnl: cnl, - } - r.wg.Add(1) - go r.acceptLoop() - r.wg.Add(1) - go r.sweepLoop() - return r, nil -} - -func (r *Redirector) LocalAddr() string { return r.ln.Addr().String() } - -func (r *Redirector) LocalPort() uint16 { - return uint16(r.ln.Addr().(*net.TCPAddr).Port) -} - -// SetMapping records that future TCP connections originating from -// src_port should be tunneled to dstIP:dstPort. Called by the divert -// layer at SYN time. -func (r *Redirector) SetMapping(srcPort uint16, dstIP net.IP, dstPort uint16) { - r.mu.Lock() - r.flows[srcPort] = mapping{dstIP: dstIP, dstPort: dstPort, added: time.Now()} - r.mu.Unlock() -} - -// GetMapping returns the original (dstIP, dstPort) for a recorded flow -// keyed by src port, or ok=false if no mapping exists. Used by the -// engine's diverterLoop on the return path to look up the original -// target port when rewriting packets going from the listener back to -// the client. -func (r *Redirector) GetMapping(srcPort uint16) (net.IP, uint16, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - m, ok := r.flows[srcPort] - if !ok { - return nil, 0, false - } - return m.dstIP, m.dstPort, true -} - -// Close stops accepting and tears down active flows. -func (r *Redirector) Close() error { - r.cnl() - err := r.ln.Close() - r.wg.Wait() - return err -} - -func (r *Redirector) acceptLoop() { - defer r.wg.Done() - for { - c, err := r.ln.Accept() - if err != nil { - return - } - r.wg.Add(1) - go r.handle(c) - } -} - -func (r *Redirector) handle(c net.Conn) { - defer r.wg.Done() - defer c.Close() - - srcPort := uint16(c.RemoteAddr().(*net.TCPAddr).Port) - r.mu.RLock() - m, ok := r.flows[srcPort] - r.mu.RUnlock() - if !ok { - return // unknown flow; drop quietly - } - - ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second) - defer cancel() - - host := m.dstIP.String() - upstream, err := socks5.Dial(ctx, r.cfg.SOCKS5, host, m.dstPort) - if err != nil { - return - } - defer upstream.Close() - - pump(c, upstream) -} - -func pump(a, b net.Conn) { - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - // G1: upstream → client (read from b, write to a). - _, _ = io.Copy(a, b) - if cw, ok := a.(closeWriter); ok { - cw.CloseWrite() - } - // We are exiting — force G2's read of a to unblock so the - // pump tears down even if the peer half never closes. - _ = a.SetReadDeadline(time.Now()) - }() - go func() { - defer wg.Done() - // G2: client → upstream (read from a, write to b). - _, _ = io.Copy(b, a) - if cw, ok := b.(closeWriter); ok { - cw.CloseWrite() - } - _ = b.SetReadDeadline(time.Now()) - }() - wg.Wait() -} - -type closeWriter interface{ CloseWrite() error } - -// sweepLoop removes mappings older than 30 minutes (T-6 in spec). -func (r *Redirector) sweepLoop() { - defer r.wg.Done() - tk := time.NewTicker(time.Minute) - defer tk.Stop() - for { - select { - case <-r.ctx.Done(): - return - case <-tk.C: - cutoff := time.Now().Add(-30 * time.Minute) - r.mu.Lock() - for k, m := range r.flows { - if m.added.Before(cutoff) { - delete(r.flows, k) - } - } - r.mu.Unlock() - } - } -} - -// Sentinel for callers. -var ErrNotMapped = errors.New("redirector: source port has no mapping") diff --git a/internal/redirect/tcp_test.go b/internal/redirect/tcp_test.go deleted file mode 100644 index 2ff86ad..0000000 --- a/internal/redirect/tcp_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package redirect - -import ( - "context" - "io" - "net" - "sync" - "testing" - "time" - - "git.okcu.io/root/drover-go/internal/socks5" - "github.com/stretchr/testify/require" -) - -// startEchoListener spins up a TCP server that echoes whatever it reads. -func startEchoListener(t *testing.T) string { - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { ln.Close() }) - go func() { - for { - c, err := ln.Accept() - if err != nil { - return - } - go func(c net.Conn) { - defer c.Close() - io.Copy(c, c) - }(c) - } - }() - return ln.Addr().String() -} - -// startFakeSOCKS5 returns the addr of a no-auth SOCKS5 server that -// CONNECT-tunnels to the requested host:port. (Borrowed pattern from -// internal/socks5/client_test.go.) -func startFakeSOCKS5(t *testing.T) string { - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { ln.Close() }) - go func() { - for { - c, err := ln.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}) - // CONNECT - io.ReadFull(c, buf[:4]) - atyp := buf[3] - var host string - switch atyp { - case 1: - io.ReadFull(c, buf[:4]) - host = net.IPv4(buf[0], buf[1], buf[2], buf[3]).String() - case 3: - io.ReadFull(c, buf[:1]) - hl := int(buf[0]) - io.ReadFull(c, buf[:hl]) - host = string(buf[:hl]) - } - io.ReadFull(c, buf[:2]) - port := int(buf[0])<<8 | int(buf[1]) - c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) - up, err := net.Dial("tcp", net.JoinHostPort(host, sportItoa(port))) - if err != nil { - return - } - defer up.Close() - var wg sync.WaitGroup - wg.Add(2) - go func() { defer wg.Done(); io.Copy(up, c) }() - go func() { defer wg.Done(); io.Copy(c, up) }() - wg.Wait() - }(c) - } - }() - return ln.Addr().String() -} - -func sportItoa(n int) string { - if n == 0 { - return "0" - } - out := []byte{} - for n > 0 { - out = append([]byte{byte('0' + n%10)}, out...) - n /= 10 - } - return string(out) -} - -func TestRedirector_PipesEcho(t *testing.T) { - echoAddr := startEchoListener(t) - echoHost, echoPortStr, _ := net.SplitHostPort(echoAddr) - echoPort := parseU16(echoPortStr) - socksAddr := startFakeSOCKS5(t) - - r, err := New(Config{ - SOCKS5: socks5.Config{ProxyAddr: socksAddr}, - Bind: "127.0.0.1:0", - }) - require.NoError(t, err) - t.Cleanup(func() { r.Close() }) - - // Manually map: pretend a packet from src_port=12345 was destined to echo. - r.SetMapping(12345, net.ParseIP(echoHost), echoPort) - - // Dial the redirector listener using src_port=12345 so it looks - // up the mapping correctly. - d := net.Dialer{LocalAddr: &net.TCPAddr{Port: 12345}} - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - conn, err := d.DialContext(ctx, "tcp", r.LocalAddr()) - require.NoError(t, err) - defer conn.Close() - - conn.Write([]byte("ping")) - conn.SetReadDeadline(time.Now().Add(time.Second)) - buf := make([]byte, 4) - io.ReadFull(conn, buf) - require.Equal(t, "ping", string(buf)) -} - -func parseU16(s string) uint16 { - var n int - for _, c := range s { - n = n*10 + int(c-'0') - } - return uint16(n) -} diff --git a/internal/redirect/udp.go b/internal/redirect/udp.go deleted file mode 100644 index 49c05ce..0000000 --- a/internal/redirect/udp.go +++ /dev/null @@ -1,446 +0,0 @@ -package redirect - -import ( - "context" - "errors" - "fmt" - "log" - "net" - "sync" - "sync/atomic" - "time" - - "git.okcu.io/root/drover-go/internal/divert" - "git.okcu.io/root/drover-go/internal/socks5" -) - -// UDPInjector is the minimal subset of *divert.Handle the UDPProxy -// needs to reinject return-path packets. Defined as an interface so -// tests can stub it out without spinning up a real WinDivert handle. -type UDPInjector interface { - Send(buf []byte, addr UDPInjectAddr) (int, error) -} - -// UDPInjectAddr describes the WinDivert addr fields that matter for -// reinjection (we don't need the full 64-byte union here — only flags -// determine direction + checksum status). Production code uses the -// adapter (see DivertHandleInjector) to convert between this and the -// real *idivert.Address. -type UDPInjectAddr struct { - // Outbound=false → packet will be delivered as inbound (kernel - // rcv path), which is exactly what we want when fabricating a - // "remote → local" reply for Discord. - Outbound bool -} - -// UDPConfig configures the UDPProxy. -type UDPConfig struct { - SOCKS5 socks5.Config - LocalIP net.IP // local LAN IP we use as the dst on fabricated reply packets - - // Injector is used to reinject return-path packets back to Discord - // via the WinDivert NETWORK handle. Required. - Injector UDPInjector - - // LogPrefix is prepended to all log lines emitted by the proxy. - // Empty defaults to "udp-proxy: ". - LogPrefix string -} - -// udpFlow tracks one (Discord_src → real_dst) UDP flow for the -// purpose of routing relay responses back to Discord. -type udpFlow struct { - // realDst* identifies the upstream UDP target (the same key the - // SOCKS5 relay puts in DST.ADDR/DST.PORT on the inbound envelope). - realDstIP [4]byte - realDstPort uint16 - - // discordSrc* identifies the Discord side of the flow — used as - // the dst on fabricated reply packets so the kernel matches the - // connect()-bound socket. - discordSrcIP [4]byte - discordSrcPort uint16 - - lastUsed time.Time -} - -// UDPProxy is the SOCKS5 UDP relay manager. The engine's diverterLoop -// calls Forward on outbound UDP packets from target processes; the -// proxy lazily opens a single UDP ASSOCIATE control TCP + relay UDP -// socket on first use, and shares them across all UDP flows. Inbound -// responses are read from the relay socket, decap'd, and reinjected -// as fabricated IPv4+UDP packets via the WinDivert NETWORK handle. -type UDPProxy struct { - cfg UDPConfig - - // Lazy-opened on first Forward call. - ctrlMu sync.Mutex - ctrlConn net.Conn // SOCKS5 control TCP — must stay open for relay validity - relayAddr *net.UDPAddr // upstream relay UDP endpoint - relayConn net.PacketConn // local UDP socket bound to talk to relay - - flowMu sync.RWMutex - // Keyed by realDstIP:realDstPort — the relay responds with these - // in the SOCKS5 envelope, so this is our reverse lookup. - flowsByDst map[flowDstKey]*udpFlow - - // Atomic stats counters for diagnostics - fwdPackets uint64 - fwdBytes uint64 - recvPackets uint64 - injectedPackets uint64 - - wg sync.WaitGroup - ctx context.Context - cancel context.CancelFunc - - // Idle TTL for udpFlow entries (default 5 minutes per RFC 4787). - IdleTTL time.Duration -} - -type flowDstKey struct { - ip [4]byte - port uint16 -} - -// NewUDP constructs a UDPProxy. It does not yet open the SOCKS5 UDP -// ASSOCIATE — that happens lazily on the first Forward call. -func NewUDP(cfg UDPConfig) (*UDPProxy, error) { - if cfg.Injector == nil { - return nil, errors.New("UDPConfig.Injector is required") - } - if cfg.LocalIP == nil || cfg.LocalIP.To4() == nil { - return nil, errors.New("UDPConfig.LocalIP must be IPv4") - } - if cfg.LogPrefix == "" { - cfg.LogPrefix = "udp-proxy: " - } - ctx, cancel := context.WithCancel(context.Background()) - u := &UDPProxy{ - cfg: cfg, - flowsByDst: map[flowDstKey]*udpFlow{}, - ctx: ctx, - cancel: cancel, - IdleTTL: 5 * time.Minute, - } - u.wg.Add(1) - go u.sweepLoop() - return u, nil -} - -// Forward is called from the engine's diverterLoop on each outbound -// UDP packet from a target process. It: -// -// 1. Lazy-opens the SOCKS5 UDP association on first call. -// 2. Records the flow keyed by (dstIP,dstPort) so the relay-response -// reader can route the reply back to the right Discord port. -// 3. Encapsulates the payload in a SOCKS5 UDP datagram (RFC 1928 §7) -// and forwards it to the relay endpoint. -// -// Returns nil on success or any error encountered (caller may log -// but should generally drop the packet on failure — UDP loss is -// expected at the wire). -func (u *UDPProxy) Forward(srcIP net.IP, srcPort uint16, dstIP net.IP, dstPort uint16, payload []byte) error { - srcV4 := srcIP.To4() - dstV4 := dstIP.To4() - if srcV4 == nil || dstV4 == nil { - return errors.New("UDPProxy.Forward: src/dst must be IPv4") - } - - if err := u.ensureAssociated(); err != nil { - return fmt.Errorf("ensure assoc: %w", err) - } - - // Record/refresh flow for the return path - var dKey flowDstKey - copy(dKey.ip[:], dstV4) - dKey.port = dstPort - - u.flowMu.Lock() - fl, ok := u.flowsByDst[dKey] - if !ok { - fl = &udpFlow{} - u.flowsByDst[dKey] = fl - } - copy(fl.realDstIP[:], dstV4) - fl.realDstPort = dstPort - copy(fl.discordSrcIP[:], srcV4) - fl.discordSrcPort = srcPort - fl.lastUsed = time.Now() - u.flowMu.Unlock() - - // Encap and send to relay - envelope, err := socks5.EncapUDPv4(dstIP, dstPort, payload) - if err != nil { - return fmt.Errorf("encap: %w", err) - } - n, err := u.relayConn.WriteTo(envelope, u.relayAddr) - if err != nil { - return fmt.Errorf("write to relay: %w", err) - } - atomic.AddUint64(&u.fwdPackets, 1) - atomic.AddUint64(&u.fwdBytes, uint64(n)) - return nil -} - -// Stats returns counters for diagnostics: forwarded outbound packets, -// inbound packets received from relay, inbound packets successfully -// reinjected to Discord. All atomic; safe to read concurrently. -func (u *UDPProxy) Stats() (fwdPkts, fwdBytes, recvPkts, injectedPkts uint64) { - return atomic.LoadUint64(&u.fwdPackets), - atomic.LoadUint64(&u.fwdBytes), - atomic.LoadUint64(&u.recvPackets), - atomic.LoadUint64(&u.injectedPackets) -} - -// ensureAssociated opens the SOCKS5 UDP association on first use and -// reuses it forever (until Close). The relay endpoint stays valid as -// long as the control TCP is open, per RFC 1928 §6. -func (u *UDPProxy) ensureAssociated() error { - u.ctrlMu.Lock() - defer u.ctrlMu.Unlock() - if u.ctrlConn != nil && u.relayAddr != nil && u.relayConn != nil { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - relay, ctrl, err := socks5.AssociateUDP(ctx, u.cfg.SOCKS5) - if err != nil { - return err - } - - // Bind a local UDP socket to talk to the relay. Bind on 0.0.0.0:0 - // so the kernel picks an ephemeral port; we'll use this socket as - // both the writer (Forward) AND the reader (relayReadLoop). - pc, err := net.ListenPacket("udp4", ":0") - if err != nil { - ctrl.Close() - return fmt.Errorf("listen relay socket: %w", err) - } - - u.ctrlConn = ctrl - u.relayAddr = relay - u.relayConn = pc - - log.Printf("%sSOCKS5 UDP ASSOCIATE relay=%s local=%s", u.cfg.LogPrefix, relay, pc.LocalAddr()) - - // Spawn the reader goroutine. - u.wg.Add(1) - go u.relayReadLoop() - - // Spawn a control-conn watcher: if the proxy closes the control - // TCP for any reason, our relay endpoint is invalidated. Mark - // state for re-association on next Forward. - u.wg.Add(1) - go u.ctrlWatcher() - return nil -} - -func (u *UDPProxy) ctrlWatcher() { - defer u.wg.Done() - // Read forever from ctrlConn; per RFC 1928 §6 the proxy doesn't - // send anything on this conn after the UDP ASSOCIATE reply, so - // any read-completion (with or without bytes) means the conn is - // gone. This is a fire-and-forget watcher — it doesn't actively - // re-associate; ensureAssociated() will do that on next Forward. - one := make([]byte, 1) - for { - // Use a generous read deadline so we wake up periodically to - // honor ctx cancellation. - _ = u.ctrlConn.SetReadDeadline(time.Now().Add(30 * time.Second)) - _, err := u.ctrlConn.Read(one) - if err == nil { - continue // unexpected data; keep monitoring - } - if ne, ok := err.(net.Error); ok && ne.Timeout() { - select { - case <-u.ctx.Done(): - return - default: - } - continue - } - // Real error — control conn is dead. Tear down so next Forward - // re-associates. - log.Printf("%scontrol TCP closed: %v — relay invalidated", u.cfg.LogPrefix, err) - u.ctrlMu.Lock() - if u.ctrlConn != nil { - u.ctrlConn.Close() - u.ctrlConn = nil - } - if u.relayConn != nil { - u.relayConn.Close() - u.relayConn = nil - } - u.relayAddr = nil - u.ctrlMu.Unlock() - return - } -} - -// relayReadLoop reads inbound datagrams from the relay socket. -// Datagrams from the relay are SOCKS5 UDP envelopes (RFC 1928 §7); -// we decap, look up the corresponding Discord flow by the envelope's -// DST.ADDR/DST.PORT (which contains the ORIGIN of the response), and -// reinject a fabricated IPv4+UDP packet as inbound via WinDivert. -func (u *UDPProxy) relayReadLoop() { - defer u.wg.Done() - - buf := make([]byte, 65535) - for { - select { - case <-u.ctx.Done(): - return - default: - } - // Snapshot relay conn under lock; if torn down by ctrlWatcher - // we need to bail out. - u.ctrlMu.Lock() - pc := u.relayConn - relay := u.relayAddr - u.ctrlMu.Unlock() - if pc == nil { - return - } - - _ = pc.SetReadDeadline(time.Now().Add(2 * time.Second)) - n, fromAddr, err := pc.ReadFrom(buf) - if err != nil { - if ne, ok := err.(net.Error); ok && ne.Timeout() { - continue - } - // Likely closed — exit. - return - } - atomic.AddUint64(&u.recvPackets, 1) - - // Sanity-check source: relay datagrams come from the relay's - // known address. Ignore anything else (in particular some - // SOCKS5 implementations bind 0.0.0.0; we accept any port match - // loosely, but require IP match when available). - fromUDP, ok := fromAddr.(*net.UDPAddr) - if !ok { - continue - } - if relay != nil && relay.IP != nil && !relay.IP.Equal(net.IPv4zero) { - if !fromUDP.IP.Equal(relay.IP) || fromUDP.Port != relay.Port { - // Not from our relay — drop. - continue - } - } - - srcIP, srcPort, payload, derr := socks5.DecapUDPv4(buf[:n]) - if derr != nil { - log.Printf("%sdecap error: %v", u.cfg.LogPrefix, derr) - continue - } - - // Look up the Discord flow by (origin IP, origin port) - v4 := srcIP.To4() - if v4 == nil { - continue - } - var key flowDstKey - copy(key.ip[:], v4) - key.port = srcPort - - u.flowMu.RLock() - fl, ok := u.flowsByDst[key] - u.flowMu.RUnlock() - if !ok { - // No active flow for this origin; drop. - continue - } - - // Mark the flow as recently used (touched by inbound). - u.flowMu.Lock() - fl.lastUsed = time.Now() - u.flowMu.Unlock() - - // Fabricate IPv4+UDP packet: - // src = real_origin (the proxy's relay tells us this in the envelope) - // dst = local LAN IP we bound on - // srcPort = real origin port - // dstPort = Discord ephemeral port (so kernel matches the connect()-bound socket) - discordIP := net.IPv4(fl.discordSrcIP[0], fl.discordSrcIP[1], fl.discordSrcIP[2], fl.discordSrcIP[3]) - // Some Discord sockets bind to local LAN IP, others bind 0.0.0.0 - // (which the SOCKET layer reports as 0.0.0.0). When discord's - // reported srcIP is 0.0.0.0 the kernel's connect-bound socket - // will still match dst=our LocalIP. But to be safe for the - // non-zero case (sockets bound to specific local IP), use the - // recorded discord side IP if it is non-zero; otherwise fall - // back to LocalIP. - dstIP := discordIP - if discordIP.Equal(net.IPv4zero) { - dstIP = u.cfg.LocalIP - } - - pkt, berr := divert.BuildIPv4UDPInbound(srcIP, dstIP, srcPort, fl.discordSrcPort, payload) - if berr != nil { - log.Printf("%sbuild packet error: %v", u.cfg.LogPrefix, berr) - continue - } - - // Reinject as inbound. WinDivert flag bits we set: IPChecksum - // (we computed it), UDPChecksum (we computed it). Outbound bit - // stays clear — kernel delivers via inbound path. - if _, serr := u.cfg.Injector.Send(pkt, UDPInjectAddr{Outbound: false}); serr != nil { - log.Printf("%sinject error: %v", u.cfg.LogPrefix, serr) - } else { - atomic.AddUint64(&u.injectedPackets, 1) - } - } -} - -// sweepLoop garbage-collects stale udpFlow entries. UDP "flows" are -// stateless — there's no FIN-equivalent — so we rely on idle timeout. -// 5 minutes matches RFC 4787 NAT requirements (REQ-5). -func (u *UDPProxy) sweepLoop() { - defer u.wg.Done() - tk := time.NewTicker(time.Minute) - defer tk.Stop() - for { - select { - case <-u.ctx.Done(): - return - case <-tk.C: - cutoff := time.Now().Add(-u.IdleTTL) - u.flowMu.Lock() - for k, f := range u.flowsByDst { - if f.lastUsed.Before(cutoff) { - delete(u.flowsByDst, k) - } - } - u.flowMu.Unlock() - } - } -} - -// Close tears down the UDPProxy: cancels reader goroutines, closes -// the relay UDP socket and the SOCKS5 control TCP. Safe to call -// multiple times. -func (u *UDPProxy) Close() error { - u.cancel() - u.ctrlMu.Lock() - if u.relayConn != nil { - _ = u.relayConn.Close() - u.relayConn = nil - } - if u.ctrlConn != nil { - _ = u.ctrlConn.Close() - u.ctrlConn = nil - } - u.relayAddr = nil - u.ctrlMu.Unlock() - u.wg.Wait() - return nil -} - -// FlowCount returns the current number of tracked UDP flows. Test -// helper. -func (u *UDPProxy) FlowCount() int { - u.flowMu.RLock() - defer u.flowMu.RUnlock() - return len(u.flowsByDst) -} diff --git a/internal/redirect/udp_test.go b/internal/redirect/udp_test.go deleted file mode 100644 index d275d55..0000000 --- a/internal/redirect/udp_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package redirect - -import ( - "encoding/binary" - "io" - "net" - "sync" - "testing" - "time" - - "git.okcu.io/root/drover-go/internal/socks5" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// fakeInjector captures injected packets for assertions. -type fakeInjector struct { - mu sync.Mutex - packets [][]byte - addrs []UDPInjectAddr -} - -func (f *fakeInjector) Send(buf []byte, addr UDPInjectAddr) (int, error) { - f.mu.Lock() - cp := make([]byte, len(buf)) - copy(cp, buf) - f.packets = append(f.packets, cp) - f.addrs = append(f.addrs, addr) - f.mu.Unlock() - return len(buf), nil -} - -func (f *fakeInjector) packetsLen() int { - f.mu.Lock() - defer f.mu.Unlock() - return len(f.packets) -} - -func (f *fakeInjector) get(idx int) ([]byte, UDPInjectAddr) { - f.mu.Lock() - defer f.mu.Unlock() - return f.packets[idx], f.addrs[idx] -} - -// startUDPRelayProxy starts a fake SOCKS5 proxy with UDP ASSOCIATE -// support. It echoes any datagram it receives on the relay back to -// the sender, with the SOCKS5 envelope's DST.ADDR/DST.PORT preserved. -// The "echoOrigin" return-path is what the real upstream relay does: -// when an upstream UDP server responds, the proxy puts that server's -// addr in DST.ADDR/DST.PORT for the inbound envelope. -func startUDPRelayProxy(t *testing.T) (tcpAddr string, relay *net.UDPConn) { - tcpLn, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { tcpLn.Close() }) - - relay, err = net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) - require.NoError(t, err) - t.Cleanup(func() { relay.Close() }) - - go func() { - for { - c, err := tcpLn.Accept() - if err != nil { - return - } - go func(c net.Conn) { - defer c.Close() - buf := make([]byte, 256) - // Greet - io.ReadFull(c, buf[:2]) - nm := int(buf[1]) - io.ReadFull(c, buf[:nm]) - c.Write([]byte{0x05, 0x00}) - // UDP ASSOCIATE - io.ReadFull(c, buf[:4]) - if buf[1] != 0x03 { - return - } - atyp := buf[3] - switch atyp { - case 1: - io.ReadFull(c, buf[:4]) - case 3: - io.ReadFull(c, buf[:1]) - io.ReadFull(c, buf[:int(buf[0])]) - } - io.ReadFull(c, buf[:2]) - // Reply with relay addr - ra := relay.LocalAddr().(*net.UDPAddr) - rep := []byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0} - copy(rep[4:8], ra.IP.To4()) - binary.BigEndian.PutUint16(rep[8:10], uint16(ra.Port)) - c.Write(rep) - // Hold open - io.Copy(io.Discard, c) - }(c) - } - }() - return tcpLn.Addr().String(), relay -} - -func TestUDPProxy_ForwardEncapsulates(t *testing.T) { - tcpAddr, relay := startUDPRelayProxy(t) - inj := &fakeInjector{} - - u, err := NewUDP(UDPConfig{ - SOCKS5: socks5.Config{ProxyAddr: tcpAddr}, - LocalIP: net.IPv4(127, 0, 0, 1), - Injector: inj, - }) - require.NoError(t, err) - t.Cleanup(func() { u.Close() }) - - // Forward a packet and verify the relay receives it encapsulated. - srcIP := net.IPv4(127, 0, 0, 1) - dstIP := net.IPv4(140, 82, 121, 4) - payload := []byte("hello voice") - require.NoError(t, u.Forward(srcIP, 50100, dstIP, 50007, payload)) - - // Read from the relay to verify the SOCKS5 envelope. - buf := make([]byte, 1500) - _ = relay.SetReadDeadline(time.Now().Add(2 * time.Second)) - n, _, err := relay.ReadFromUDP(buf) - require.NoError(t, err) - - got := buf[:n] - gotIP, gotPort, gotPayload, err := socks5.DecapUDPv4(got) - require.NoError(t, err) - assert.Equal(t, "140.82.121.4", gotIP.String()) - assert.Equal(t, uint16(50007), gotPort) - assert.Equal(t, payload, gotPayload) - - assert.Equal(t, 1, u.FlowCount(), "should have one tracked flow") -} - -func TestUDPProxy_RelayResponseInjectsBackToDiscord(t *testing.T) { - tcpAddr, relay := startUDPRelayProxy(t) - inj := &fakeInjector{} - - u, err := NewUDP(UDPConfig{ - SOCKS5: socks5.Config{ProxyAddr: tcpAddr}, - LocalIP: net.IPv4(127, 0, 0, 1), - Injector: inj, - }) - require.NoError(t, err) - t.Cleanup(func() { u.Close() }) - - // Establish a flow by forwarding one packet - discordSrcIP := net.IPv4(127, 0, 0, 1) - discordSrcPort := uint16(50100) - realDstIP := net.IPv4(140, 82, 121, 4) - realDstPort := uint16(50007) - require.NoError(t, u.Forward(discordSrcIP, discordSrcPort, realDstIP, realDstPort, []byte("hi"))) - - // Drain the encapsulated forward - drainBuf := make([]byte, 1500) - _ = relay.SetReadDeadline(time.Now().Add(2 * time.Second)) - _, clientRelayAddr, err := relay.ReadFromUDP(drainBuf) - require.NoError(t, err) - - // Simulate upstream UDP server response: relay sends back an - // envelope where DST.ADDR/DST.PORT = real upstream origin. - respPayload := []byte("voice response") - envelope, err := socks5.EncapUDPv4(realDstIP, realDstPort, respPayload) - require.NoError(t, err) - _, err = relay.WriteToUDP(envelope, clientRelayAddr) - require.NoError(t, err) - - // The proxy's relayReadLoop should receive, decap, and inject. - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) && inj.packetsLen() == 0 { - time.Sleep(20 * time.Millisecond) - } - require.Equal(t, 1, inj.packetsLen(), "expected one injected packet") - - pkt, addr := inj.get(0) - assert.False(t, addr.Outbound, "injected as inbound") - - // Parse the fabricated IPv4+UDP packet - require.GreaterOrEqual(t, len(pkt), 28) - // Verify proto=UDP - assert.Equal(t, byte(17), pkt[9], "IPv4 proto field") - srcIP := net.IPv4(pkt[12], pkt[13], pkt[14], pkt[15]) - dstIP := net.IPv4(pkt[16], pkt[17], pkt[18], pkt[19]) - srcPort := binary.BigEndian.Uint16(pkt[20:22]) - dstPort := binary.BigEndian.Uint16(pkt[22:24]) - - assert.Equal(t, "140.82.121.4", srcIP.String(), "fabricated src = real upstream origin") - assert.Equal(t, "127.0.0.1", dstIP.String(), "fabricated dst = Discord-side IP") - assert.Equal(t, realDstPort, srcPort, "fabricated src port = real upstream port") - assert.Equal(t, discordSrcPort, dstPort, "fabricated dst port = Discord ephemeral port") - - // Payload after IPv4(20)+UDP(8) headers - assert.Equal(t, respPayload, pkt[28:]) -} - -func TestUDPProxy_NoFlowDropsResponse(t *testing.T) { - tcpAddr, relay := startUDPRelayProxy(t) - inj := &fakeInjector{} - - u, err := NewUDP(UDPConfig{ - SOCKS5: socks5.Config{ProxyAddr: tcpAddr}, - LocalIP: net.IPv4(127, 0, 0, 1), - Injector: inj, - }) - require.NoError(t, err) - t.Cleanup(func() { u.Close() }) - - // Force association without registering any flow. - require.NoError(t, u.ensureAssociated()) - - // Read the local relay socket's port and substitute 127.0.0.1 for - // 0.0.0.0 (kernel binds wildcard but Windows refuses to send TO - // 0.0.0.0:N — it requires a routable destination). - localAddr := u.relayConn.LocalAddr().(*net.UDPAddr) - dst := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: localAddr.Port} - - // Send a "stray" relay datagram with an origin we never registered. - envelope, _ := socks5.EncapUDPv4(net.IPv4(8, 8, 8, 8), 53, []byte("dns")) - _, err = relay.WriteToUDP(envelope, dst) - require.NoError(t, err) - - // Give the reader time to process and drop. - time.Sleep(200 * time.Millisecond) - assert.Equal(t, 0, inj.packetsLen(), "stray response should be dropped, not injected") -} - -func TestUDPProxy_RejectsIPv6(t *testing.T) { - inj := &fakeInjector{} - u, err := NewUDP(UDPConfig{ - SOCKS5: socks5.Config{ProxyAddr: "127.0.0.1:0"}, - LocalIP: net.IPv4(127, 0, 0, 1), - Injector: inj, - }) - require.NoError(t, err) - t.Cleanup(func() { u.Close() }) - - v6 := net.ParseIP("::1") - err = u.Forward(net.IPv4(1, 2, 3, 4), 1000, v6, 80, []byte("x")) - assert.Error(t, err) -} - -func TestNewUDP_RejectsNilInjector(t *testing.T) { - _, err := NewUDP(UDPConfig{ - LocalIP: net.IPv4(127, 0, 0, 1), - }) - assert.Error(t, err) -} - -func TestNewUDP_RejectsNonIPv4LocalIP(t *testing.T) { - _, err := NewUDP(UDPConfig{ - LocalIP: net.ParseIP("::1"), - Injector: &fakeInjector{}, - }) - assert.Error(t, err) -} diff --git a/internal/sboxrun/assets/wintun.dll b/internal/sboxrun/assets/wintun.dll new file mode 100644 index 0000000..aee04e7 Binary files /dev/null and b/internal/sboxrun/assets/wintun.dll differ diff --git a/internal/sboxrun/config.go b/internal/sboxrun/config.go new file mode 100644 index 0000000..d2d14f5 --- /dev/null +++ b/internal/sboxrun/config.go @@ -0,0 +1,107 @@ +package sboxrun + +import ( + "encoding/json" + "fmt" +) + +// Config captures the user-visible proxy settings + which processes +// to route through it. Everything else (TUN interface, log level, +// Clash API endpoint) is hard-coded sensible defaults. +type Config struct { + ProxyHost string // upstream SOCKS5 host + ProxyPort int // upstream SOCKS5 port + UseAuth bool + Login string + Password string + TargetProcs []string // exe names to route via upstream (e.g. ["Discord.exe"]) + ClashAPIPort int // 0 → 9090 default + LogLevel string // "info" | "debug" | "warn" — empty → "info" + LogPath string // absolute path for sing-box log output (empty = sing-box stdout, lost when admin-detached) +} + +// BuildSingBoxConfig generates the sing-box JSON config string. It's +// a minimal config: TUN inbound (with auto_route + WFP per-process +// rule), SOCKS5 outbound to upstream, direct outbound for everything +// else, and a route rule that sends TargetProcs through the SOCKS5. +// +// Clash API on 127.0.0.1:9090 (or ClashAPIPort) lets the GUI poll +// connection stats live. +func BuildSingBoxConfig(c Config) (string, error) { + if c.ProxyHost == "" || c.ProxyPort == 0 { + return "", fmt.Errorf("ProxyHost and ProxyPort are required") + } + if len(c.TargetProcs) == 0 { + return "", fmt.Errorf("at least one target process is required") + } + logLevel := c.LogLevel + if logLevel == "" { + logLevel = "info" + } + clashPort := c.ClashAPIPort + if clashPort == 0 { + clashPort = 9090 + } + + upstream := map[string]any{ + "type": "socks", + "tag": "upstream", + "server": c.ProxyHost, + "server_port": c.ProxyPort, + "version": "5", + "udp_over_tcp": false, + } + if c.UseAuth { + upstream["username"] = c.Login + upstream["password"] = c.Password + } + + logBlock := map[string]any{ + "level": logLevel, + "timestamp": true, + } + if c.LogPath != "" { + logBlock["output"] = c.LogPath + } + cfg := map[string]any{ + "log": logBlock, + "inbounds": []any{ + map[string]any{ + "type": "tun", + "tag": "tun-in", + "interface_name": "drover-tun", + "address": []string{"172.18.0.1/30"}, + "auto_route": true, + "strict_route": false, + "stack": "system", + "sniff": true, + }, + }, + "outbounds": []any{ + upstream, + map[string]any{"type": "direct", "tag": "direct"}, + }, + "route": map[string]any{ + "auto_detect_interface": true, + "final": "direct", + "rules": []any{ + // Route only the target processes via upstream + map[string]any{ + "process_name": c.TargetProcs, + "outbound": "upstream", + }, + }, + }, + "experimental": map[string]any{ + "clash_api": map[string]any{ + "external_controller": fmt.Sprintf("127.0.0.1:%d", clashPort), + }, + }, + } + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/internal/sboxrun/embed.go b/internal/sboxrun/embed.go new file mode 100644 index 0000000..aab7d6d --- /dev/null +++ b/internal/sboxrun/embed.go @@ -0,0 +1,34 @@ +// Package sboxrun manages an embedded sing-box subprocess that +// implements the actual proxy engine (TUN inbound + per-process +// routing rule + SOCKS5 outbound). +// +// On first Start, the package extracts sing-box.exe + wintun.dll from +// embedded bytes into %PROGRAMDATA%\Drover\sboxrun\ (SHA256-verified), +// generates a JSON config from the GUI's proxy form, and launches +// sing-box as a child process. Stop kills the child cleanly. +package sboxrun + +import _ "embed" + +//go:embed assets/sing-box.exe +var singBoxExe []byte + +//go:embed assets/wintun.dll +var wintunDLL []byte + +// SHA256 sentinels for the embedded binaries — verified after extract. +// Update both when bumping versions: +// +// sing-box: https://github.com/SagerNet/sing-box/releases +// wintun: https://www.wintun.net/ +const ( + // Pinned to 1.12.25 — last release on the 1.12 line that still + // accepts the legacy TUN inbound config layout. 1.13.0 removed + // `address` from inbound and requires migration to rule-based + // `endpoints` — when our config generator gets updated to that + // shape, we can move to 1.13.x. + SingBoxVersion = "1.12.25" + SingBoxSHA256 = "fc7b65219abe8a0166d0b4891a2f7cabcbcc13b3adcf89e6d5913743a67aba10" + WintunVersion = "0.14.1" + WintunSHA256 = "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce" +) diff --git a/internal/sboxrun/install.go b/internal/sboxrun/install.go new file mode 100644 index 0000000..53d4ee2 --- /dev/null +++ b/internal/sboxrun/install.go @@ -0,0 +1,83 @@ +//go:build windows + +package sboxrun + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" +) + +// AssetPaths records where the binaries landed after install. +type AssetPaths struct { + SingBoxExe string + WintunDLL string + WorkDir string // %PROGRAMDATA%\Drover\sboxrun + ConfigPath string // \config.json + LogPath string // \sing-box.log +} + +// InstallAssets extracts sing-box.exe + wintun.dll into +// %PROGRAMDATA%\Drover\sboxrun\ (creating the directory if needed) +// and verifies SHA256. Idempotent — second runs skip if existing +// files match the embedded SHAs. +func InstallAssets() (*AssetPaths, error) { + pd := os.Getenv("ProgramData") + if pd == "" { + return nil, fmt.Errorf("ProgramData environment variable is not set") + } + dir := filepath.Join(pd, "Drover", "sboxrun") + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create %s: %w", dir, err) + } + + exePath := filepath.Join(dir, "sing-box.exe") + dllPath := filepath.Join(dir, "wintun.dll") + + if err := writeIfDifferent(exePath, singBoxExe, SingBoxSHA256); err != nil { + return nil, fmt.Errorf("install sing-box.exe: %w", err) + } + if err := writeIfDifferent(dllPath, wintunDLL, WintunSHA256); err != nil { + return nil, fmt.Errorf("install wintun.dll: %w", err) + } + + return &AssetPaths{ + SingBoxExe: exePath, + WintunDLL: dllPath, + WorkDir: dir, + ConfigPath: filepath.Join(dir, "config.json"), + LogPath: filepath.Join(dir, "sing-box.log"), + }, nil +} + +func writeIfDifferent(path string, content []byte, expectedSHA string) error { + if existing, err := os.ReadFile(path); err == nil { + if strings.EqualFold(sha256Hex(existing), expectedSHA) { + return nil + } + } + tmp := path + ".new" + if err := os.WriteFile(tmp, content, 0644); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + got, err := os.ReadFile(path) + if err != nil { + return err + } + if !strings.EqualFold(sha256Hex(got), expectedSHA) { + return fmt.Errorf("SHA256 mismatch after write at %s; antivirus may have tampered with the file. Add %%PROGRAMDATA%%\\Drover\\ to AV exclusions", path) + } + return nil +} + +func sha256Hex(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} diff --git a/internal/sboxrun/runner.go b/internal/sboxrun/runner.go new file mode 100644 index 0000000..7d33fce --- /dev/null +++ b/internal/sboxrun/runner.go @@ -0,0 +1,223 @@ +//go:build windows + +package sboxrun + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "sync" + "syscall" + "time" +) + +// Status is the engine's lifecycle state, parallel to what the GUI +// expects (idle/starting/active/failed). +type Status string + +const ( + StatusIdle Status = "idle" + StatusStarting Status = "starting" + StatusActive Status = "active" + StatusFailed Status = "failed" +) + +// Engine wraps a sing-box subprocess. +type Engine struct { + cfg Config + assets *AssetPaths + + mu sync.Mutex + status Status + lastErr error + cmd *exec.Cmd + cancel context.CancelFunc + + // done is closed when the subprocess exits (whether by Stop or + // crash). Lets Status() observers detect failure asynchronously. + done chan struct{} +} + +// New constructs an Engine. No I/O yet. +func New(cfg Config) (*Engine, error) { + if cfg.ProxyHost == "" || cfg.ProxyPort == 0 { + return nil, errors.New("ProxyHost and ProxyPort are required") + } + if len(cfg.TargetProcs) == 0 { + cfg.TargetProcs = []string{ + "Discord.exe", + "DiscordCanary.exe", + "DiscordPTB.exe", + "Update.exe", + } + } + return &Engine{cfg: cfg, status: StatusIdle}, nil +} + +// Status returns the current lifecycle state. +func (e *Engine) Status() Status { + e.mu.Lock() + defer e.mu.Unlock() + return e.status +} + +// LastError returns the last error pushed us to Failed (or nil). +func (e *Engine) LastError() error { + e.mu.Lock() + defer e.mu.Unlock() + return e.lastErr +} + +func (e *Engine) setStatus(s Status, err error) { + e.mu.Lock() + e.status = s + if err != nil { + e.lastErr = err + } else if s == StatusActive || s == StatusIdle { + e.lastErr = nil + } + e.mu.Unlock() +} + +// Start brings the engine to Active. Generates the sing-box config, +// extracts assets, launches the subprocess. Returns when the process +// is running (or fails to start). The provided ctx is used only for +// the bring-up sequence; the running subprocess is governed by Stop. +func (e *Engine) Start(ctx context.Context) error { + e.mu.Lock() + if e.status != StatusIdle && e.status != StatusFailed { + e.mu.Unlock() + return fmt.Errorf("Start requires Idle or Failed; got %s", e.status) + } + e.status = StatusStarting + e.mu.Unlock() + + if err := e.bringUp(); err != nil { + e.setStatus(StatusFailed, err) + return err + } + e.setStatus(StatusActive, nil) + return nil +} + +func (e *Engine) bringUp() error { + // 1. Extract assets + assets, err := InstallAssets() + if err != nil { + return fmt.Errorf("install assets: %w", err) + } + e.assets = assets + + // 2. Generate config (point sing-box log at the workdir log file + // so admin-detached processes don't lose their output to nowhere). + cfg := e.cfg + cfg.LogPath = assets.LogPath + configJSON, err := BuildSingBoxConfig(cfg) + if err != nil { + return fmt.Errorf("build config: %w", err) + } + if err := os.WriteFile(assets.ConfigPath, []byte(configJSON), 0644); err != nil { + return fmt.Errorf("write config: %w", err) + } + + // 3. Open log file (truncate; sing-box appends to its own stdout/ + // stderr handle so we direct both there). + logFile, err := os.OpenFile(assets.LogPath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("open log: %w", err) + } + + // 4. Spawn sing-box subprocess. + subCtx, cancel := context.WithCancel(context.Background()) + e.cancel = cancel + cmd := exec.CommandContext(subCtx, assets.SingBoxExe, + "run", "-c", assets.ConfigPath, "-D", assets.WorkDir) + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{ + // Don't show a console window for the child. + HideWindow: true, + } + if err := cmd.Start(); err != nil { + cancel() + _ = logFile.Close() + return fmt.Errorf("spawn sing-box: %w", err) + } + e.cmd = cmd + e.done = make(chan struct{}) + + // 5. Watch for unexpected exit. + go func() { + err := cmd.Wait() + _ = logFile.Close() + close(e.done) + // If we didn't intend to stop (cancel hasn't fired), this is a + // crash → mark Failed so the GUI surfaces it. + select { + case <-subCtx.Done(): + // expected — Stop() cancelled us + default: + e.setStatus(StatusFailed, fmt.Errorf("sing-box exited unexpectedly: %w", err)) + } + }() + + // 6. Brief readiness probe — sing-box takes ~200-500ms to bind + // the TUN. If the process dies in that window, surface the error. + select { + case <-e.done: + return fmt.Errorf("sing-box exited during startup; see %s", assets.LogPath) + case <-time.After(800 * time.Millisecond): + // alive + } + return nil +} + +// Stop terminates the sing-box subprocess gracefully and returns to +// Idle. Idempotent — second calls are no-op. +func (e *Engine) Stop() error { + e.mu.Lock() + if e.status == StatusIdle { + e.mu.Unlock() + return nil + } + cancel := e.cancel + cmd := e.cmd + done := e.done + e.mu.Unlock() + + if cancel != nil { + cancel() + } + if cmd != nil && cmd.Process != nil { + // Give it 3s to exit cleanly, then force-kill. + killTimer := time.AfterFunc(3*time.Second, func() { + _ = cmd.Process.Kill() + }) + if done != nil { + <-done + } + killTimer.Stop() + } + e.setStatus(StatusIdle, nil) + return nil +} + +// LogPath returns the path of the sing-box stdout/stderr capture so +// the GUI's "Open log file" can pop it up. +func (e *Engine) LogPath() string { + if e.assets == nil { + return "" + } + return e.assets.LogPath +} + +// ConfigPath returns the path of the generated sing-box config (for +// debugging — "View config" link in GUI). +func (e *Engine) ConfigPath() string { + if e.assets == nil { + return "" + } + return e.assets.ConfigPath +} diff --git a/internal/sboxrun/runner_other.go b/internal/sboxrun/runner_other.go new file mode 100644 index 0000000..c16c00b --- /dev/null +++ b/internal/sboxrun/runner_other.go @@ -0,0 +1,48 @@ +//go:build !windows + +package sboxrun + +import ( + "context" + "errors" +) + +// Status — duplicate of the Windows-side enum so call sites compile. +type Status string + +const ( + StatusIdle Status = "idle" + StatusStarting Status = "starting" + StatusActive Status = "active" + StatusFailed Status = "failed" +) + +// Engine stub for non-Windows builds. +type Engine struct{} + +// New returns an error on non-Windows: sing-box + wintun + WFP-based +// per-process routing only make sense on Windows. +func New(_ Config) (*Engine, error) { + return nil, errors.New("sboxrun is Windows-only") +} + +func (e *Engine) Start(_ context.Context) error { return errors.New("sboxrun is Windows-only") } +func (e *Engine) Stop() error { return nil } +func (e *Engine) Status() Status { return StatusIdle } +func (e *Engine) LastError() error { return nil } +func (e *Engine) LogPath() string { return "" } +func (e *Engine) ConfigPath() string { return "" } + +// AssetPaths stub. +type AssetPaths struct { + SingBoxExe string + WintunDLL string + WorkDir string + ConfigPath string + LogPath string +} + +// InstallAssets stub. +func InstallAssets() (*AssetPaths, error) { + return nil, errors.New("sboxrun is Windows-only") +} diff --git a/internal/service/doc.go b/internal/service/doc.go deleted file mode 100644 index d8cd8db..0000000 --- a/internal/service/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package service installs the Windows service and exposes the IPC named pipe. -package service diff --git a/internal/socks5/client.go b/internal/socks5/client.go deleted file mode 100644 index ff7f465..0000000 --- a/internal/socks5/client.go +++ /dev/null @@ -1,117 +0,0 @@ -package socks5 - -import ( - "context" - "encoding/binary" - "errors" - "fmt" - "io" - "net" - "time" -) - -// Config carries connection-time SOCKS5 settings. -type Config struct { - ProxyAddr string // "host:port" - UseAuth bool - Login string - Password string -} - -// Dial opens a TCP connection to the SOCKS5 proxy, runs the greeting, -// optionally authenticates with username/password (RFC 1929), and -// issues a CONNECT to host:port (sent as ATYP=03 domain so the proxy -// resolves on its side). Returns the established net.Conn ready for -// bidirectional traffic. -// -// The given ctx bounds dial + handshake; once Dial returns, the conn -// has its own deadline-free I/O state. -func Dial(ctx context.Context, cfg Config, host string, port uint16) (net.Conn, error) { - d := net.Dialer{} - conn, err := d.DialContext(ctx, "tcp", cfg.ProxyAddr) - if err != nil { - return nil, fmt.Errorf("dial proxy: %w", err) - } - if dl, ok := ctx.Deadline(); ok { - conn.SetDeadline(dl) - } - if err := handshake(conn, cfg, host, port); err != nil { - conn.Close() - return nil, err - } - conn.SetDeadline(time.Time{}) - return conn, nil -} - -func handshake(conn net.Conn, cfg Config, host string, port uint16) error { - // Greeting - if cfg.UseAuth { - if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil { - return fmt.Errorf("greet write: %w", err) - } - } else { - if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { - return fmt.Errorf("greet write: %w", err) - } - } - var rep [2]byte - if _, err := io.ReadFull(conn, rep[:]); err != nil { - return fmt.Errorf("greet read: %w", err) - } - if rep[0] != 0x05 { - return fmt.Errorf("greet: server version %#x is not SOCKS5", rep[0]) - } - if rep[1] == 0xff { - return errors.New("greet: proxy rejected all offered auth methods") - } - method := rep[1] - - // Auth subneg - if method == 0x02 { - if !cfg.UseAuth { - return errors.New("proxy requires auth but Config.UseAuth is false") - } - if len(cfg.Login) > 255 || len(cfg.Password) > 255 { - return 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 _, err := conn.Write(buf); err != nil { - return fmt.Errorf("auth write: %w", err) - } - if _, err := io.ReadFull(conn, rep[:]); err != nil { - return fmt.Errorf("auth read: %w", err) - } - if rep[1] != 0x00 { - return errors.New("auth: invalid login or password") - } - } - - // CONNECT - if len(host) > 255 { - return errors.New("host too long") - } - req := make([]byte, 0, 7+len(host)) - req = append(req, 0x05, 0x01, 0x00, 0x03, byte(len(host))) - req = append(req, []byte(host)...) - pBuf := make([]byte, 2) - binary.BigEndian.PutUint16(pBuf, port) - req = append(req, pBuf...) - if _, err := conn.Write(req); err != nil { - return fmt.Errorf("connect write: %w", err) - } - var creply [10]byte - if _, err := io.ReadFull(conn, creply[:]); err != nil { - return fmt.Errorf("connect read: %w", err) - } - if creply[0] != 0x05 { - return fmt.Errorf("connect: server version %#x is not SOCKS5", creply[0]) - } - if creply[1] != 0x00 { - return fmt.Errorf("connect: REP=%#02x", creply[1]) - } - return nil -} diff --git a/internal/socks5/client_test.go b/internal/socks5/client_test.go deleted file mode 100644 index d942695..0000000 --- a/internal/socks5/client_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package socks5 - -import ( - "context" - "io" - "net" - "strconv" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// fakeProxy is a minimal SOCKS5 server that accepts greet+CONNECT -// (and optional auth) and then splices the connection to a target -// listener supplied by the test. -type fakeProxy struct { - addr string - target string - useAuth bool - login string - password string -} - -func startFakeProxy(t *testing.T, target string, useAuth bool, login, password string) *fakeProxy { - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { ln.Close() }) - - p := &fakeProxy{ - addr: ln.Addr().String(), - target: target, - useAuth: useAuth, login: login, password: password, - } - - go func() { - for { - c, err := ln.Accept() - if err != nil { - return - } - go p.handle(c) - } - }() - return p -} - -func (p *fakeProxy) handle(c net.Conn) { - defer c.Close() - buf := make([]byte, 256) - - // Greeting: 05 N method... - io.ReadFull(c, buf[:2]) - nmethods := int(buf[1]) - io.ReadFull(c, buf[:nmethods]) - if p.useAuth { - c.Write([]byte{0x05, 0x02}) - 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}) - } - - // CONNECT request: 05 01 00 ATYP ... - io.ReadFull(c, buf[:4]) - atyp := buf[3] - var host string - switch atyp { - case 1: - io.ReadFull(c, buf[:4]) - host = net.IPv4(buf[0], buf[1], buf[2], buf[3]).String() - case 3: - io.ReadFull(c, buf[:1]) - hlen := int(buf[0]) - io.ReadFull(c, buf[:hlen]) - host = string(buf[:hlen]) - } - io.ReadFull(c, buf[:2]) - port := int(buf[0])<<8 | int(buf[1]) - - // Reply REP=0 - c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) - - // Splice to target - target, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) - if err != nil { - return - } - defer target.Close() - var wg sync.WaitGroup - wg.Add(2) - go func() { defer wg.Done(); io.Copy(target, c) }() - go func() { defer wg.Done(); io.Copy(c, target) }() - wg.Wait() -} - -func TestDial_NoAuth_HappyPath(t *testing.T) { - target, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer target.Close() - go func() { - c, err := target.Accept() - if err != nil { - return - } - defer c.Close() - c.Write([]byte("hello")) - }() - - p := startFakeProxy(t, target.Addr().String(), false, "", "") - - host, port, _ := net.SplitHostPort(target.Addr().String()) - portU, _ := atoiU16(port) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - conn, err := Dial(ctx, Config{ - ProxyAddr: p.addr, - }, host, portU) - require.NoError(t, err) - defer conn.Close() - - buf := make([]byte, 5) - io.ReadFull(conn, buf) - assert.Equal(t, "hello", string(buf)) -} - -func TestDial_WithAuth_HappyPath(t *testing.T) { - target, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer target.Close() - go func() { c, _ := target.Accept(); if c != nil { c.Write([]byte("auth-ok")); c.Close() } }() - - p := startFakeProxy(t, target.Addr().String(), true, "user", "pass") - host, port, _ := net.SplitHostPort(target.Addr().String()) - portU, _ := atoiU16(port) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - conn, err := Dial(ctx, Config{ - ProxyAddr: p.addr, - UseAuth: true, - Login: "user", - Password: "pass", - }, host, portU) - require.NoError(t, err) - defer conn.Close() - - buf := make([]byte, 7) - io.ReadFull(conn, buf) - assert.Equal(t, "auth-ok", string(buf)) -} - -func TestDial_BadAuth(t *testing.T) { - target, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer target.Close() - - p := startFakeProxy(t, target.Addr().String(), true, "user", "pass") - host, port, _ := net.SplitHostPort(target.Addr().String()) - portU, _ := atoiU16(port) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - _, err = Dial(ctx, Config{ - ProxyAddr: p.addr, - UseAuth: true, - Login: "wrong", - Password: "wrong", - }, host, portU) - require.Error(t, err) -} - -func atoiU16(s string) (uint16, error) { - var n int - for _, c := range s { - if c < '0' || c > '9' { - return 0, &net.AddrError{Err: "invalid port", Addr: s} - } - n = n*10 + int(c-'0') - } - return uint16(n), nil -} diff --git a/internal/socks5/doc.go b/internal/socks5/doc.go deleted file mode 100644 index 6ea1863..0000000 --- a/internal/socks5/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package socks5 implements a SOCKS5 client (CONNECT + UDP ASSOCIATE, RFC 1928 + 1929). -package socks5 diff --git a/internal/socks5/udp.go b/internal/socks5/udp.go deleted file mode 100644 index b655a5d..0000000 --- a/internal/socks5/udp.go +++ /dev/null @@ -1,178 +0,0 @@ -package socks5 - -import ( - "context" - "encoding/binary" - "errors" - "fmt" - "io" - "net" - "time" -) - -// AssociateUDP opens a TCP control conn to the upstream SOCKS5 proxy, -// runs greeting + (optional) auth + UDP ASSOCIATE (CMD=03), and returns: -// -// - the relay UDP endpoint (host:port the proxy bound for our datagrams) -// - the kept-open control TCP (caller MUST keep open for the lifetime -// of the UDP association — closing it tears down the relay on the -// proxy side per RFC 1928 §6). -// -// The given ctx bounds dial + handshake; once AssociateUDP returns, -// ctrl has its deadline cleared. -// -// If the proxy replies BND.ADDR == 0.0.0.0 (some implementations do -// this to mean "use the same IP you connected to"), we substitute the -// proxy host's resolved IP. -func AssociateUDP(ctx context.Context, cfg Config) (relay *net.UDPAddr, ctrl net.Conn, err error) { - d := net.Dialer{} - conn, err := d.DialContext(ctx, "tcp", cfg.ProxyAddr) - if err != nil { - return nil, nil, fmt.Errorf("dial proxy: %w", err) - } - if dl, ok := ctx.Deadline(); ok { - _ = conn.SetDeadline(dl) - } - defer func() { - if err != nil { - conn.Close() - } - }() - - // Greeting (same as TCP CONNECT path) - if cfg.UseAuth { - if _, werr := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); werr != nil { - return nil, nil, fmt.Errorf("greet write: %w", werr) - } - } else { - if _, werr := conn.Write([]byte{0x05, 0x01, 0x00}); werr != nil { - return nil, nil, fmt.Errorf("greet write: %w", werr) - } - } - var rep [2]byte - if _, rerr := io.ReadFull(conn, rep[:]); rerr != nil { - return nil, nil, fmt.Errorf("greet read: %w", rerr) - } - if rep[0] != 0x05 { - return nil, nil, fmt.Errorf("greet: server version %#x is not SOCKS5", rep[0]) - } - if rep[1] == 0xff { - return nil, nil, errors.New("greet: proxy rejected all offered auth methods") - } - method := rep[1] - - if method == 0x02 { - if !cfg.UseAuth { - return nil, nil, errors.New("proxy requires auth but Config.UseAuth is false") - } - if len(cfg.Login) > 255 || len(cfg.Password) > 255 { - return nil, nil, errors.New("login or password too long") - } - buf := make([]byte, 0, 3+len(cfg.Login)+len(cfg.Password)) - buf = append(buf, 0x01, byte(len(cfg.Login))) - buf = append(buf, []byte(cfg.Login)...) - buf = append(buf, byte(len(cfg.Password))) - buf = append(buf, []byte(cfg.Password)...) - if _, werr := conn.Write(buf); werr != nil { - return nil, nil, fmt.Errorf("auth write: %w", werr) - } - if _, rerr := io.ReadFull(conn, rep[:]); rerr != nil { - return nil, nil, fmt.Errorf("auth read: %w", rerr) - } - if rep[1] != 0x00 { - return nil, nil, errors.New("auth: invalid login or password") - } - } - - // UDP ASSOCIATE request: VER=05 CMD=03 RSV=00 ATYP=01 DST.ADDR=0.0.0.0 DST.PORT=0 - req := []byte{0x05, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} - if _, werr := conn.Write(req); werr != nil { - return nil, nil, fmt.Errorf("udp-associate write: %w", werr) - } - - // We accept ATYP=01 (IPv4) replies only — sufficient for our use - // case (mihomo + standard proxies). Reading 10 bytes covers exactly - // that case: VER REP RSV ATYP BND.ADDR(4) BND.PORT(2). - reply := make([]byte, 10) - if _, rerr := io.ReadFull(conn, reply); rerr != nil { - return nil, nil, fmt.Errorf("udp-associate read: %w", rerr) - } - if reply[0] != 0x05 { - return nil, nil, fmt.Errorf("udp-associate: server version %#x is not SOCKS5", reply[0]) - } - if reply[1] != 0x00 { - return nil, nil, fmt.Errorf("udp-associate: REP=%#02x", reply[1]) - } - if reply[3] != 0x01 { - return nil, nil, fmt.Errorf("udp-associate: unsupported BND.ATYP=%#02x (need IPv4)", reply[3]) - } - - bndIP := net.IPv4(reply[4], reply[5], reply[6], reply[7]).To4() - bndPort := binary.BigEndian.Uint16(reply[8:10]) - - // Per RFC 1928 §6 / common practice: BND.ADDR=0.0.0.0 means "use - // the same address you used to reach me". Substitute proxy host's - // IP from the established TCP conn's RemoteAddr. - if bndIP.Equal(net.IPv4zero.To4()) { - if ra, ok := conn.RemoteAddr().(*net.TCPAddr); ok && ra.IP != nil { - if v4 := ra.IP.To4(); v4 != nil { - bndIP = v4 - } - } - } - - // Clear deadline so caller can use ctrl as-is (keepalive only). - _ = conn.SetDeadline(time.Time{}) - - return &net.UDPAddr{IP: bndIP, Port: int(bndPort)}, conn, nil -} - -// EncapUDPv4 wraps an outbound UDP payload in the SOCKS5 UDP datagram -// envelope (RFC 1928 §7) for ATYP=01 (IPv4). The returned buffer has -// the form: -// -// RSV(2)=0000 | FRAG(1)=00 | ATYP(1)=01 | DST.ADDR(4) | DST.PORT(2) | DATA -// -// The 10-byte prefix tells the relay where to forward the datagram. -// Returns an error if dstIP is not IPv4. -func EncapUDPv4(dstIP net.IP, dstPort uint16, payload []byte) ([]byte, error) { - v4 := dstIP.To4() - if v4 == nil { - return nil, errors.New("EncapUDPv4: dst must be IPv4") - } - out := make([]byte, 10+len(payload)) - out[0] = 0x00 // RSV - out[1] = 0x00 // RSV - out[2] = 0x00 // FRAG (no fragmentation) - out[3] = 0x01 // ATYP IPv4 - copy(out[4:8], v4) - binary.BigEndian.PutUint16(out[8:10], dstPort) - copy(out[10:], payload) - return out, nil -} - -// DecapUDPv4 parses an inbound SOCKS5 UDP datagram (RFC 1928 §7) for -// ATYP=01 (IPv4). On the inbound path the relay puts the ORIGIN's -// addr/port in DST.ADDR/DST.PORT — i.e. for us, the original DST that -// answered (e.g. the Discord voice server). The returned (srcIP, -// srcPort) reflect that origin; payload is the original UDP body. -// -// Errors when: -// - buf shorter than 10 bytes (truncated header) -// - FRAG != 0 (we don't reassemble fragments) -// - ATYP != 1 (we only handle IPv4 in this path) -func DecapUDPv4(buf []byte) (srcIP net.IP, srcPort uint16, payload []byte, err error) { - if len(buf) < 10 { - return nil, 0, nil, errors.New("DecapUDPv4: truncated header") - } - if buf[2] != 0x00 { - return nil, 0, nil, fmt.Errorf("DecapUDPv4: FRAG=%d not supported", buf[2]) - } - if buf[3] != 0x01 { - return nil, 0, nil, fmt.Errorf("DecapUDPv4: ATYP=%#02x not IPv4", buf[3]) - } - srcIP = net.IPv4(buf[4], buf[5], buf[6], buf[7]) - srcPort = binary.BigEndian.Uint16(buf[8:10]) - payload = buf[10:] - return srcIP, srcPort, payload, nil -} diff --git a/internal/socks5/udp_test.go b/internal/socks5/udp_test.go deleted file mode 100644 index b96598c..0000000 --- a/internal/socks5/udp_test.go +++ /dev/null @@ -1,203 +0,0 @@ -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) - }) - } -} diff --git a/internal/tray/doc.go b/internal/tray/doc.go deleted file mode 100644 index a31c301..0000000 --- a/internal/tray/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package tray manages the system tray icon. -package tray