pivot: replace WinDivert engine with embedded sing-box + wintun
After 5+ hours of WinDivert NETWORK-layer NAT-rewrite debugging
(streamdump pattern, SOCKET-layer SYN preemption, lazy PID resolution,
UDP ASSOCIATE relay + manual reinject), Discord voice still wouldn't
connect. The fundamental issue is that WinDivert reinjected UDP
packets don't always reach connect()-bound application sockets — the
demux happens at a layer above the reinject point.
dvp/force-proxy avoids this entirely via DLL injection (above the
kernel demux). We avoid it the other way: embed sing-box, let it run
TUN inbound + per-process routing rule + SOCKS5 outbound. TUN packets
are read by sing-box from kernel as a normal flow; the application
socket sees a normal flow back. No reinject hairpin, no SYN race, no
spoofing concerns.
What this commit does:
- Drops internal/divert, internal/engine, internal/redirect,
internal/socks5, internal/procscan, plus cmd/drover/{proxy,
debugflow}_*.go subcommands (all WinDivert-only).
- Adds internal/sboxrun — embed sing-box.exe (1.12.25) + wintun.dll
(0.14.1) via //go:embed, install to %PROGRAMDATA%\Drover\sboxrun\
with SHA256 verify, generate JSON config from form, spawn as
subprocess, manage lifecycle.
- Wires sboxrun into internal/gui/app.go: StartEngine/StopEngine
now call sboxrun.Engine instead of windivert engine.
- Fixes Wails binding: StartEngine(cfg) now passes the form config
(was zero-arg, hit ProxyHost-required validation silently).
Manual test: Discord chat + voice work end-to-end through mihomo
upstream. Yandex Music / svchost / etc continue direct via
process_name routing rule.
Binary grew from 12 MB → 49 MB (37 MB sing-box embedded), but ships
fully self-contained. AV-friendly: wintun is Microsoft-signed, no
DLL injection.
WinDivert work preserved on experimental/windivert branch in case we
ever want to come back to that path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func runDebugFlow(_ context.Context) error {
|
||||
return fmt.Errorf("debug-flow requires Windows")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
// Package app wires the Wails application (Go ↔ JS bindings).
|
||||
package app
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package bypass implements DPI bypass via fake QUIC injection.
|
||||
package bypass
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package config loads and validates the TOML configuration.
|
||||
package config
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package divert wraps WinDivert for kernel-level packet capture.
|
||||
package divert
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package engine orchestrates the packet processing pipeline.
|
||||
package engine
|
||||
@@ -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=<local_LAN_IP> (kernel fills the
|
||||
// interface address). Including src in the key would cause those
|
||||
// flows to miss the lookup. Source port + destination + proto is a
|
||||
// sufficient discriminator on a single host.
|
||||
type flowKey struct {
|
||||
dst [4]byte
|
||||
sport uint16
|
||||
dport uint16
|
||||
proto uint8 // 6=TCP, 17=UDP
|
||||
}
|
||||
|
||||
// New constructs an engine. No I/O yet.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
-10
@@ -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),
|
||||
e, err := sboxrun.New(sboxrun.Config{
|
||||
ProxyHost: cfg.Host,
|
||||
ProxyPort: cfg.Port,
|
||||
UseAuth: cfg.Auth,
|
||||
Login: cfg.Login,
|
||||
Password: cfg.Password,
|
||||
Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"},
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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'.
|
||||
}
|
||||
|
||||
|
||||
@@ -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']() }
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package procscan resolves process IDs via Toolhelp32.
|
||||
package procscan
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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 // <workdir>\config.json
|
||||
LogPath string // <workdir>\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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package service installs the Windows service and exposes the IPC named pipe.
|
||||
package service
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package socks5 implements a SOCKS5 client (CONNECT + UDP ASSOCIATE, RFC 1928 + 1929).
|
||||
package socks5
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package tray manages the system tray icon.
|
||||
package tray
|
||||
Reference in New Issue
Block a user