pivot: replace WinDivert engine with embedded sing-box + wintun
Build / test (push) Failing after 31s
Build / build-windows (push) Has been skipped

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:
2026-05-01 23:02:12 +03:00
parent 4074e68715
commit 48097f8671
51 changed files with 522 additions and 4438 deletions
-12
View File
@@ -1,12 +0,0 @@
//go:build !windows
package main
import (
"context"
"fmt"
)
func runDebugFlow(_ context.Context) error {
return fmt.Errorf("debug-flow requires Windows")
}
-64
View File
@@ -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
}
}
}
-42
View File
@@ -127,52 +127,10 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newUpdateCmd()) root.AddCommand(newUpdateCmd())
root.AddCommand(newServiceCmd()) root.AddCommand(newServiceCmd())
root.AddCommand(newGUICmd()) root.AddCommand(newGUICmd())
root.AddCommand(newProxyCmd())
root.AddCommand(newDebugFlowCmd())
return root return root
} }
// newDebugFlowCmd opens a WinDivert FLOW handle with filter "tcp"
// (capture all TCP flow events from any process) and logs every event
// for 30 seconds. Useful to verify the FLOW layer is working at all
// without process-targeting interference.
func newDebugFlowCmd() *cobra.Command {
return &cobra.Command{
Use: "debug-flow",
Short: "[debug] open broad FLOW handle, log events for 30s",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDebugFlow(cmd.Context())
},
}
}
// newProxyCmd is the headless engine-only mode: no Wails, no tray —
// just spin up the WinDivert + SOCKS5 pipeline against the configured
// upstream and block on Ctrl+C. Useful for debugging without the GUI
// stack in the way; everything still goes to %LOCALAPPDATA%\Drover\debug.log.
func newProxyCmd() *cobra.Command {
var host, login, password string
var port int
var auth bool
cmd := &cobra.Command{
Use: "proxy",
Short: "Run the WinDivert+SOCKS5 engine in headless mode (no GUI, blocks until Ctrl+C)",
RunE: func(cmd *cobra.Command, args []string) error {
return runProxy(cmd.Context(), host, port, auth, login, password)
},
}
cmd.Flags().StringVar(&host, "host", "", "upstream SOCKS5 host (required)")
cmd.Flags().IntVar(&port, "port", 0, "upstream SOCKS5 port (required)")
cmd.Flags().BoolVar(&auth, "auth", false, "enable user/pass auth")
cmd.Flags().StringVar(&login, "login", "", "SOCKS5 login (when --auth)")
cmd.Flags().StringVar(&password, "password", "", "SOCKS5 password (when --auth)")
_ = cmd.MarkFlagRequired("host")
_ = cmd.MarkFlagRequired("port")
return cmd
}
func newGUICmd() *cobra.Command { func newGUICmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "gui", Use: "gui",
-14
View File
@@ -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)")
}
-80
View File
@@ -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())
}
}
}
}
BIN
View File
Binary file not shown.
-2
View File
@@ -1,2 +0,0 @@
// Package app wires the Wails application (Go ↔ JS bindings).
package app
-2
View File
@@ -1,2 +0,0 @@
// Package bypass implements DPI bypass via fake QUIC injection.
package bypass
-2
View File
@@ -1,2 +0,0 @@
// Package config loads and validates the TOML configuration.
package config
Binary file not shown.
Binary file not shown.
-423
View File
@@ -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
}
-46
View File
@@ -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
}
-2
View File
@@ -1,2 +0,0 @@
// Package divert wraps WinDivert for kernel-level packet capture.
package divert
-23
View File
@@ -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"
)
-110
View File
@@ -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)
}
-73
View File
@@ -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")
}
-120
View File
@@ -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[:])
}
-50
View File
@@ -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))
}
-431
View File
@@ -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)
}
-260
View File
@@ -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)
}
-2
View File
@@ -1,2 +0,0 @@
// Package engine orchestrates the packet processing pipeline.
package engine
-667
View File
@@ -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
}
}
-45
View File
@@ -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())
}
-29
View File
@@ -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
}
-30
View File
@@ -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)
}
}
View File
+11 -10
View File
@@ -12,7 +12,7 @@ import (
"time" "time"
"git.okcu.io/root/drover-go/internal/checker" "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" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
@@ -28,7 +28,7 @@ type App struct {
version string version string
mu sync.Mutex mu sync.Mutex
eng *engine.Engine eng *sboxrun.Engine
startedAt time.Time startedAt time.Time
// muCheck guards cancelCheck and checkDone. // 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) log.Printf("gui: StartEngine called host=%s port=%d auth=%v", cfg.Host, cfg.Port, cfg.Auth)
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
if a.eng != nil && a.eng.Status() == engine.StatusActive { if a.eng != nil && a.eng.Status() == sboxrun.StatusActive {
log.Printf("gui: StartEngine no-op (already active)") log.Printf("gui: StartEngine no-op (already active)")
return nil return nil
} }
e, err := engine.New(engine.Config{ e, err := sboxrun.New(sboxrun.Config{
ProxyAddr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), ProxyHost: cfg.Host,
ProxyPort: cfg.Port,
UseAuth: cfg.Auth, UseAuth: cfg.Auth,
Login: cfg.Login, Login: cfg.Login,
Password: cfg.Password, 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 { 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()}) runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
return err return err
} }
if err := e.Start(a.ctx); err != nil { if err := e.Start(a.ctx); err != nil {
log.Printf("gui: engine.Start failed: %v", err) log.Printf("gui: sboxrun.Start failed: %v", err)
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()}) runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
return err return err
} }
@@ -224,7 +225,7 @@ func (a *App) StopEngine() error {
func (a *App) GetStatus() map[string]any { func (a *App) GetStatus() map[string]any {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() 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{ res := map[string]any{
"running": running, "running": running,
"uptimeS": int(time.Since(a.startedAt).Seconds()), "uptimeS": int(time.Since(a.startedAt).Seconds()),
@@ -247,7 +248,7 @@ func (a *App) statsLoop() {
defer tick.Stop() defer tick.Stop()
for range tick.C { for range tick.C {
a.mu.Lock() 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() a.mu.Unlock()
continue continue
} }
@@ -173,7 +173,18 @@ export function useDrover(initial = {}) {
async function startProxy() { async function startProxy() {
if (phase !== 'checked') return; if (phase !== 'checked') return;
if (lastSummary?.failed === tests.length) 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'. // engine:status event will flip phase to 'active'.
} }
+1 -1
View File
@@ -12,7 +12,7 @@
export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) } export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) }
export function CancelCheck() { return window['go']['gui']['App']['CancelCheck']() } 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 StopEngine() { return window['go']['gui']['App']['StopEngine']() }
export function GetStatus() { return window['go']['gui']['App']['GetStatus']() } export function GetStatus() { return window['go']['gui']['App']['GetStatus']() }
export function Version() { return window['go']['gui']['App']['Version']() } export function Version() { return window['go']['gui']['App']['Version']() }
-2
View File
@@ -1,2 +0,0 @@
// Package procscan resolves process IDs via Toolhelp32.
package procscan
-71
View File
@@ -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
}
-49
View File
@@ -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)
}
-36
View File
@@ -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
}
-192
View File
@@ -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")
-139
View File
@@ -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)
}
-446
View File
@@ -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)
}
-256
View File
@@ -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.
+107
View File
@@ -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
}
+34
View File
@@ -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"
)
+83
View File
@@ -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[:])
}
+223
View File
@@ -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
}
+48
View File
@@ -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")
}
-2
View File
@@ -1,2 +0,0 @@
// Package service installs the Windows service and exposes the IPC named pipe.
package service
-117
View File
@@ -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
}
-199
View File
@@ -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
}
-2
View File
@@ -1,2 +0,0 @@
// Package socks5 implements a SOCKS5 client (CONNECT + UDP ASSOCIATE, RFC 1928 + 1929).
package socks5
-178
View File
@@ -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
}
-203
View File
@@ -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)
})
}
}
-2
View File
@@ -1,2 +0,0 @@
// Package tray manages the system tray icon.
package tray