Spec: docs/superpowers/specs/2026-05-01-engine-design.md (P2.1 section) Tasks 1-2: bootstrap (UAC + binary embed) Tasks 3-6: divert layer (filter / packet / installer / handle) Tasks 7-9: forwarding (SOCKS5 client / procscan / TCP redirect) Task 10: engine state machine + orchestrator Task 11: GUI integration Task 12: end-to-end manual verification + tag v0.3.0-p2.1 Each task has failing-test → impl → passing-test → commit cycles (TDD where practical; syscall-heavy paths get manual verification). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
82 KiB
P2.1 — TCP-only MVP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Get drover routing Discord's TCP traffic (chat + API) through an upstream SOCKS5 proxy via WinDivert kernel-level packet capture. Voice (UDP) is explicitly deferred to P2.2.
Architecture: WinDivert v2.2.2 driver captures outbound TCP from Discord.exe/DiscordCanary.exe/DiscordPTB.exe/Update.exe, the engine NAT-rewrites destination to 127.0.0.1:<listener_port>, the loopback listener accepts the connection, looks up the original destination in a per-flow map, opens a SOCKS5 CONNECT to the upstream proxy, and pumps bytes both directions with io.Copy. Filter expression dynamically rebuilds when Discord's PIDs change (every 2s via Toolhelp32). Self-loop protection via processId != own_pid in the filter and excluding the upstream proxy IP.
Tech Stack: Go 1.23, golang.org/x/sys/windows for syscalls, github.com/imgk/divert-go v0.1.0 for WinDivert bindings (with fallback to direct syscalls if it's broken), embedded WinDivert64.sys + WinDivert.dll v2.2.2 from third_party/windivert/. Test framework: testify.
Spec: docs/superpowers/specs/2026-05-01-engine-design.md (read sections P2.1, WinDivert layer, TCP redirect, Process scanning, Self-loop protection, UAC).
Scope: Tasks 1–12 below. Out of scope: UDP forwarding (P2.2), Reconnecting state (P2.3), tray/autostart UI (P2.4), polish/edge cases (P2.5).
File structure for P2.1
| Path | Responsibility |
|---|---|
cmd/drover/uac_windows.go (new) |
IsAdmin() + ReElevate() — UAC re-launch helper |
cmd/drover/main.go (modify) |
Insert UAC check before GUI boot |
internal/divert/divert.go (new) |
WinDivert handle wrapper: Open/Close/Recv/Send |
internal/divert/filter.go (new) |
Build filter expression from PID list + own PID + upstream IP |
internal/divert/packet.go (new) |
Parse + serialize IPv4+TCP, recompute checksums |
internal/divert/installer.go (new) |
Extract embedded WinDivert64.sys + WinDivert.dll to %PROGRAMDATA%\Drover\windivert\ with SHA256 verify |
internal/divert/embed.go (new) |
//go:embed of the two driver files |
internal/socks5/client.go (new) |
Production SOCKS5 client (greet + auth + CONNECT). NOT shared with internal/checker/socks5.go — different requirements (no diagnostic-friendly errors, no raw-byte exposure) |
internal/procscan/procscan.go (new) |
CreateToolhelp32Snapshot PID enumerator, periodic ticker |
internal/redirect/tcp.go (new) |
Loopback listener, per-flow (src_port → real_target) map, SOCKS5 dial + io.Copy pump |
internal/engine/state.go (new) |
Status enum + transition rules: Idle/Starting/Active/Failed |
internal/engine/engine.go (new) |
Orchestrator: Start/Stop, lifecycle, wire divert + redirect + procscan |
internal/gui/app.go (modify) |
Replace stub StartEngine/StopEngine with calls into engine.Engine |
Task 1: UAC re-launch helper
Files:
- Create:
cmd/drover/uac_windows.go - Modify:
cmd/drover/main.go
WinDivert WinDivertOpen fails with ERROR_ACCESS_DENIED for non-admin processes. Per decision B1 (UAC at every launch), we detect non-admin at startup and re-launch via ShellExecuteW with runas verb. CLI sub-commands like --check, --version, and the auto-update path don't need admin and must not trigger UAC.
- Step 1: Write failing test
Create cmd/drover/uac_windows_test.go:
package main
import (
"testing"
)
func TestIsAdmin_Smoke(t *testing.T) {
// Smoke test: IsAdmin returns a bool without panicking.
// We can't assert true/false without knowing the test environment,
// but we ensure the syscall path doesn't crash.
_ = IsAdmin()
}
func TestCmdNeedsAdmin_NoAdminFlags(t *testing.T) {
cases := []struct {
args []string
needsAdm bool
}{
{[]string{}, true}, // bare drover.exe → GUI mode → needs admin
{[]string{"check"}, false}, // diagnostic only, no driver
{[]string{"check", "--host", "x"}, false},
{[]string{"--version"}, false},
{[]string{"version"}, false},
{[]string{"update"}, false}, // self-update doesn't need driver
}
for _, c := range cases {
got := CmdNeedsAdmin(c.args)
if got != c.needsAdm {
t.Errorf("CmdNeedsAdmin(%v) = %v, want %v", c.args, got, c.needsAdm)
}
}
}
- Step 2: Run test to verify it fails
cd F:/work/drover-go && go test ./cmd/drover/...
Expected: FAIL — IsAdmin and CmdNeedsAdmin undefined.
- Step 3: Write
cmd/drover/uac_windows.go
//go:build windows
package main
import (
"os"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// IsAdmin returns true when the current process token has elevation.
// Wraps GetTokenInformation(TokenElevation).
func IsAdmin() bool {
var token windows.Token
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
return false
}
defer token.Close()
var elevation uint32
var sz uint32
err := windows.GetTokenInformation(
token,
windows.TokenElevation,
(*byte)(unsafe.Pointer(&elevation)),
uint32(unsafe.Sizeof(elevation)),
&sz,
)
if err != nil {
return false
}
return elevation != 0
}
// CmdNeedsAdmin reports whether the given CLI args land in a code path
// that requires a WinDivert handle (and therefore admin). The default
// (no args = GUI mode) needs admin; explicit subcommands like check,
// version, update do not.
func CmdNeedsAdmin(args []string) bool {
if len(args) == 0 {
return true // bare drover.exe → GUI/engine
}
switch args[0] {
case "check", "version", "--version", "-v", "update", "--help", "-h", "help":
return false
default:
return true
}
}
// ReElevate re-launches the current executable with the given args via
// ShellExecuteW("runas", ...). On success the caller should os.Exit(0)
// immediately. Returns nil even when the user cancels UAC — the caller
// can't distinguish; we just exit cleanly afterward.
func ReElevate(args []string) error {
exe, err := os.Executable()
if err != nil {
return err
}
verb, _ := syscall.UTF16PtrFromString("runas")
exePtr, _ := syscall.UTF16PtrFromString(exe)
var paramsPtr *uint16
if len(args) > 0 {
// Quote each arg in case of spaces.
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = `"` + a + `"`
}
joined := ""
for i, q := range quoted {
if i > 0 {
joined += " "
}
joined += q
}
paramsPtr, _ = syscall.UTF16PtrFromString(joined)
}
cwd, _ := os.Getwd()
cwdPtr, _ := syscall.UTF16PtrFromString(cwd)
// SW_NORMAL = 1
return windows.ShellExecute(0, verb, exePtr, paramsPtr, cwdPtr, 1)
}
- Step 4: Run test to verify it passes
go test ./cmd/drover/... -run "TestIsAdmin_Smoke|TestCmdNeedsAdmin"
Expected: PASS.
- Step 5: Wire into
cmd/drover/main.go
Read the current main.go first to find the insertion point. The UAC check goes BEFORE auto-update and BEFORE GUI startup, AFTER attachConsole() and Cobra arg parsing for help/version flags.
The simplest hook: in main(), right after attachConsole(), add:
if CmdNeedsAdmin(os.Args[1:]) && !IsAdmin() {
if err := ReElevate(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "failed to re-elevate: %v\n", err)
}
os.Exit(0)
}
- Step 6: Manual smoke check
bash rebuild.sh
./drover-test.exe check --host 95.165.72.59 --port 12334
Expected: runs without UAC prompt (CLI subcommand). Open Explorer, double-click drover-test.exe from a non-admin shell — UAC prompt appears; on accept, GUI opens.
- Step 7: Commit
git add cmd/drover/uac_windows.go cmd/drover/uac_windows_test.go cmd/drover/main.go
git commit -m "$(cat <<'EOF'
cmd/drover: UAC re-launch helper for non-admin invocations
CLI subcommands (check/version/update) don't need driver access and
run as user. Bare drover.exe (GUI/engine mode) requires admin for
WinDivertOpen — re-launches via ShellExecute("runas") and exits.
Per spec decision B1: prompt at every launch, no scheduled-task
trampoline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 2: WinDivert library sanity check + binary embed
Files:
- Create:
internal/divert/embed.go
Before we wrap the WinDivert handle, verify github.com/imgk/divert-go builds against our Go 1.23 + third_party/windivert/ headers. If broken, this task is the single point where we decide to fall back to direct syscall bindings.
This task does NOT introduce a Go binding wrapper — it just embeds the binaries and runs a one-time go get + smoke build.
- Step 1: Add
imgk/divert-goto go.mod (try v0.1.0 first)
cd F:/work/drover-go && go get github.com/imgk/divert-go@v0.1.0 2>&1 | tail -5
Expected: clean go-get. If errors (broken module / Go-version conflict), report back — we'll switch to direct syscalls in Task 6 instead.
- Step 2: Smoke-build to verify
cd F:/work/drover-go && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
go build -tags "desktop,production" -ldflags "-H=windowsgui" \
-o /tmp/probe.exe ./cmd/drover
Expected: builds clean. If imgk/divert-go references CGO (it shouldn't for v0.1.0+), we'll see CGO errors and need to either add CGO_ENABLED=1 (avoid — breaks our cross-compile) or switch to direct syscalls.
- Step 3: Create embed file
internal/divert/embed.go
//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 PowerShell:
//
// Get-FileHash third_party/windivert/WinDivert64.sys -Algorithm SHA256
// Get-FileHash third_party/windivert/WinDivert.dll -Algorithm SHA256
//
// Update both constants when bumping WinDivert versions.
const (
WinDivertSysSHA256 = "FILL_ME"
WinDivertDllSHA256 = "FILL_ME"
)
- Step 4: Copy binaries into the package's
assets/directory
mkdir -p internal/divert/assets
cp third_party/windivert/WinDivert64.sys internal/divert/assets/
cp third_party/windivert/WinDivert.dll internal/divert/assets/
- Step 5: Compute the SHA256 hashes and patch the file
cd F:/work/drover-go && \
sys_hash=$(sha256sum internal/divert/assets/WinDivert64.sys | awk '{print $1}') && \
dll_hash=$(sha256sum internal/divert/assets/WinDivert.dll | awk '{print $1}') && \
echo "sys=$sys_hash dll=$dll_hash"
Patch internal/divert/embed.go replacing both FILL_ME strings with the actual hashes (uppercase or lowercase, just be consistent — extractor uses strings.EqualFold).
- Step 6: Verify embed compiles
go build ./internal/divert/...
Expected: clean build (file produces an unused-vars warning if anything else was missing, but with _ "embed" import + //go:embed directives it should just compile silently).
- Step 7: Commit
git add go.mod go.sum internal/divert/
git commit -m "$(cat <<'EOF'
internal/divert: embed WinDivert64.sys + WinDivert.dll v2.2.2 with SHA256 sentinels
Adds github.com/imgk/divert-go v0.1.0 dependency. Embedded driver
binaries land at runtime in %PROGRAMDATA%\Drover\windivert\ via the
installer (next task).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 3: Filter expression builder
Files:
- Create:
internal/divert/filter.go - Test:
internal/divert/filter_test.go
Pure-Go construction of the WinDivert filter expression. No driver access, fully unit-testable. The expression is rebuilt every time the Discord PID list changes.
- Step 1: Write failing tests
Create internal/divert/filter_test.go:
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) {
// Anything that's not a parseable IPv4 → return error string sentinel.
got := BuildFilter(FilterParams{
TargetPIDs: []uint32{1},
OwnPID: 2,
UpstreamIP: "not-an-ip",
})
// We expect the function to substitute "0.0.0.0" or similar so the
// filter remains valid. Decision: panic? Return "false"? Per spec
// "if upstream IP cannot be resolved we fail-stop with a clear msg".
// So caller resolves first; this builder assumes valid input. We
// just substitute a placeholder and document it.
assert.Contains(t, got, "ip.DstAddr != 0.0.0.0")
}
- Step 2: Run tests — verify failure
go test ./internal/divert/... -run TestBuildFilter
Expected: FAIL — BuildFilter undefined.
- Step 3: Implement
internal/divert/filter.go
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
}
// BuildFilter returns a WinDivert filter expression string suitable
// for WinDivertOpen. The expression captures only outbound IPv4 TCP/UDP
// from the listed PIDs, excluding our own process and the upstream
// proxy's IP.
func BuildFilter(p FilterParams) string {
if len(p.TargetPIDs) == 0 {
return "false"
}
upstream := p.UpstreamIP
if net.ParseIP(upstream).To4() == nil {
upstream = "0.0.0.0"
}
pidClauses := make([]string, len(p.TargetPIDs))
for i, pid := range p.TargetPIDs {
pidClauses[i] = fmt.Sprintf("processId == %d", pid)
}
pidClause := "(" + strings.Join(pidClauses, " or ") + ")"
parts := []string{
"outbound",
"(tcp or udp)",
"ip",
pidClause,
fmt.Sprintf("processId != %d", p.OwnPID),
fmt.Sprintf("ip.DstAddr != %s", upstream),
"not (ip.DstAddr >= 224.0.0.0 and ip.DstAddr <= 239.255.255.255)",
"not (ip.DstAddr >= 127.0.0.0 and ip.DstAddr <= 127.255.255.255)",
"not (ip.DstAddr >= 169.254.0.0 and ip.DstAddr <= 169.254.255.255)",
}
return strings.Join(parts, " and ")
}
- Step 4: Run tests — verify pass
go test ./internal/divert/... -run TestBuildFilter -v
Expected: 5 PASS.
- Step 5: Commit
git add internal/divert/filter.go internal/divert/filter_test.go
git commit -m "$(cat <<'EOF'
internal/divert: filter expression builder
Pure-Go assembly of the WinDivert filter clause. Empty PID list →
"false" (captures nothing — used during Discord-not-running window).
Non-IPv4 upstream → 0.0.0.0 fallback (caller should validate; the
builder degrades gracefully rather than panicking).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 4: Packet parser + checksum recompute
Files:
- Create:
internal/divert/packet.go - Test:
internal/divert/packet_test.go
Parses an outbound IPv4 + TCP packet from a raw byte buffer (as WinDivert hands it to us), supports modifying destination address/port, recomputes IP and TCP checksums, and serializes back. UDP support is added in P2.2; for now we restrict to TCP since that's all P2.1 needs.
- Step 1: Write failing tests
Create internal/divert/packet_test.go:
package divert
import (
"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)
})
}
}
- Step 2: Run tests — verify failure
go test ./internal/divert/... -run TestParseIPv4TCP -v
go test ./internal/divert/... -run TestRewriteDst -v
Expected: FAIL — ParseIPv4TCP, RewriteDst, ipChecksum, tcpChecksum undefined.
- Step 3: Implement
internal/divert/packet.go
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
}
// 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)
}
- Step 4: Run tests — verify pass
go test ./internal/divert/... -run "TestParseIPv4TCP|TestRewriteDst" -v
Expected: all PASS.
- Step 5: Commit
git add internal/divert/packet.go internal/divert/packet_test.go
git commit -m "$(cat <<'EOF'
internal/divert: IPv4+TCP packet parse + RewriteDst + checksums
Pure-Go RFC 791/793 checksum implementation. Mutates buffer in
place — no allocations on the hot path. Used by the redirect layer
to NAT-rewrite Discord packets to 127.0.0.1:listener_port before
reinjecting via WinDivertSend.
UDP support deferred to P2.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 5: Driver installer
Files:
- Create:
internal/divert/installer.go - Test:
internal/divert/installer_test.go
On engine start, extract WinDivert64.sys + WinDivert.dll from the embedded bytes into %PROGRAMDATA%\Drover\windivert\ (creating the directory if needed). SHA256-verify the extracted files match WinDivertSysSHA256 / WinDivertDllSHA256 constants. Detect ARM64 and return a clear error.
- Step 1: Write failing tests
Create internal/divert/installer_test.go:
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))
}
- Step 2: Run tests — verify failure
go test ./internal/divert/... -run TestInstallDriver -v
Expected: FAIL — installDriverInto undefined.
- Step 3: Implement
internal/divert/installer.go
//go:build windows
package divert
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// 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)
}
return &DriverPaths{SysPath: sysPath, DllPath: dllPath}, 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[:])
}
- Step 4: Run tests — verify pass on Windows
go test ./internal/divert/... -run TestInstallDriver -v
Expected: 2 PASS (Windows), 1 SKIP (ARM64 case unless on ARM64 hardware).
- Step 5: Commit
git add internal/divert/installer.go internal/divert/installer_test.go
git commit -m "$(cat <<'EOF'
internal/divert: driver installer with SHA256 verification
Extracts embedded WinDivert binaries to %PROGRAMDATA%\Drover\windivert\
on first run; subsequent runs detect matching SHAs and no-op. SHA
mismatch after write produces an AV-friendly error message pointing
the user at adding the directory to exclusions.
ARM64 detected at runtime via runtime.GOARCH and refused gracefully.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 6: WinDivert handle wrapper
Files:
- Create:
internal/divert/divert.go - Test:
internal/divert/divert_test.go(smoke tests only — full integration in Task 12)
The thin Go layer between our engine and the WinDivert API. We use imgk/divert-go per Task 2 with fallback to direct syscalls if it doesn't compile. Provides Open, Close, Recv (read raw packet + WinDivertAddress), Send (reinject).
If imgk/divert-go is unusable (failed Task 2 build), this task instead writes raw syscall.NewLazyDLL("WinDivert.dll") bindings — see "fallback" subtask below.
- Step 1: Write smoke test
Create internal/divert/divert_test.go:
package divert
import (
"runtime"
"testing"
"github.com/stretchr/testify/require"
)
// TestOpen_RequiresAdmin documents — and verifies on a non-admin run —
// that Open fails fast with a recognisable error rather than panicking.
// On admin we just smoke-test the open/close round-trip with a no-op
// filter ("false") that captures nothing.
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
}
(Add import "os" to the test file.)
- Step 2: Run tests — verify they skip gracefully
go test ./internal/divert/... -run TestOpen -v
Expected: SKIP (when not running as admin) or PASS (when admin).
- Step 3: Implement
internal/divert/divert.gousing imgk/divert-go
//go:build windows
package divert
import (
"errors"
"fmt"
idivert "github.com/imgk/divert-go"
)
// 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
}
// 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 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 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
}
Fallback if imgk/divert-go won't compile (Task 2 reported failure): replace the implementation with raw syscall.LazyDLL calls to WinDivert.dll. The C signatures we need:
WinDivertOpen(filter, layer=0, priority=0, flags=0) -> HANDLE
WinDivertRecv(handle, packet, packetLen, recvLen, addr) -> BOOL
WinDivertSend(handle, packet, packetLen, sendLen, addr) -> BOOL
WinDivertClose(handle) -> BOOL
The WinDivertAddress is a 64-byte C struct; the first uint8 is the layer enum, the second is the event enum, and we mostly only care about flags: bit 0 = inbound (vs outbound), bit 1 = ipv6, bit 2 = ipChecksum, bit 3 = tcpChecksum. See third_party/windivert/windivert.h lines 200–280 for the precise layout.
Subagent: try imgk/divert-go first; if go build ./internal/divert/... fails, document the failure clearly, switch to fallback, and report which path was taken.
- Step 4: Run smoke test on this Windows machine
go test ./internal/divert/... -run TestOpen -v
If running from an admin shell: PASS. From a non-admin shell: SKIP.
- Step 5: Commit
git add internal/divert/divert.go internal/divert/divert_test.go
git commit -m "$(cat <<'EOF'
internal/divert: WinDivert handle wrapper
Thin Go layer over imgk/divert-go (or raw syscalls if upstream is
broken). Exposes Open/Close/Recv/Send and maps the most relevant
Windows errors to sentinels (ErrAccessDenied,
ErrDriverFailedPriorUnload, ErrInvalidHandle, ErrShutdown) so the
engine's recovery classifier can reason about them without importing
golang.org/x/sys/windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 7: Production SOCKS5 client (TCP CONNECT)
Files:
- Create:
internal/socks5/client.go - Test:
internal/socks5/client_test.go
A separate, leaner SOCKS5 client from internal/checker/socks5.go. The diagnostic client returns raw bytes for hex display; the production client just returns a net.Conn that's been CONNECT'd through. No retries here — that's the engine's job.
- Step 1: Write failing test
Create internal/socks5/client_test.go:
package socks5
import (
"context"
"io"
"net"
"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, 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 itoa(n int) string { return string([]byte{byte('0' + n/10000 % 10), byte('0' + n/1000 % 10), byte('0' + n/100 % 10), byte('0' + n/10 % 10), byte('0' + n % 10)})[:5] }
func TestDial_NoAuth_HappyPath(t *testing.T) {
// Spin up a real target listener
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
}
- Step 2: Run tests — verify failure
go test ./internal/socks5/... -run TestDial -v
Expected: FAIL — Dial and Config undefined.
- Step 3: Implement
internal/socks5/client.go
package socks5
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
)
// 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
}
Add import "time" at the top.
- Step 4: Run tests — verify pass
go test ./internal/socks5/... -run TestDial -v
Expected: 3 PASS.
- Step 5: Commit
git add internal/socks5/client.go internal/socks5/client_test.go
git commit -m "$(cat <<'EOF'
internal/socks5: production TCP CONNECT client
Separate from internal/checker/socks5.go (different requirements: no
hex dumps, no diagnostic-friendly errors, faster path). Single Dial
entry point that handles greet + optional auth + CONNECT and returns
a ready-to-use net.Conn. UDP support deferred to P2.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 8: Procscan
Files:
- Create:
internal/procscan/procscan.go - Test:
internal/procscan/procscan_test.go
Toolhelp32 enumerates all running processes. We filter by exe-name list and return the resulting PID set. The engine kicks off a 2-second ticker calling Snapshot() and diffing against the previous result.
- Step 1: Write tests
Create internal/procscan/procscan_test.go:
//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)
}
- Step 2: Run tests — verify failure
go test ./internal/procscan/... -v
Expected: FAIL — symbols undefined.
- Step 3: Implement
internal/procscan/procscan.go
//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
}
- Step 4: Run tests — verify pass
go test ./internal/procscan/... -v
Expected: 3 PASS (Windows host), or skip on non-Windows.
- Step 5: Commit
git add internal/procscan/procscan.go internal/procscan/procscan_test.go
git commit -m "$(cat <<'EOF'
internal/procscan: Toolhelp32 PID enumerator
Filters by exe basename, case-insensitive. DiffPIDs reports add/remove
sets so the engine can decide whether to rebuild the WinDivert filter.
Pure syscalls, no third-party dependencies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 9: TCP NAT-loopback redirect
Files:
- Create:
internal/redirect/tcp.go - Test:
internal/redirect/tcp_test.go
The heart of the engine. A loopback listener accepts redirected Discord connections, looks up (client_src_port → real_target) in the per-flow map, opens a SOCKS5 CONNECT to the upstream proxy targeting real_target, and pumps bytes both directions until either side closes.
The map is populated by the divert layer (Task 10 wires it in) when a SYN arrives from a target PID — but for this task we just expose the API and unit-test the pump.
- Step 1: Write tests
Create internal/redirect/tcp_test.go:
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.)
//
// For this test we duplicate the fake proxy code rather than exporting
// it, to keep the redirect package free of test-helper coupling.
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)
}
- Step 2: Run tests — verify failure
go test ./internal/redirect/... -run TestRedirector -v
Expected: FAIL — New, Config, SetMapping, LocalAddr, Close undefined.
- Step 3: Implement
internal/redirect/tcp.go
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()
}
// 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(); _, _ = io.Copy(a, b); a.(closeWriter).CloseWrite() }()
go func() { defer wg.Done(); _, _ = io.Copy(b, a); b.(closeWriter).CloseWrite() }()
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")
- Step 4: Run tests — verify pass
go test ./internal/redirect/... -run TestRedirector -v
Expected: PASS.
- Step 5: Commit
git add internal/redirect/
git commit -m "$(cat <<'EOF'
internal/redirect: TCP NAT-loopback redirector
Listener on 127.0.0.1 accepts NAT-rewritten Discord SYNs (rewrite
done by divert layer in Task 10), looks up the original destination
in a sync-protected map keyed by source port, opens a SOCKS5 CONNECT
to the upstream proxy targeting that destination, and pumps bytes
both directions until either side closes.
30-minute TTL sweeper handles T-6 in the edge case matrix (mapping
leak when a flow never properly closes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 10: Engine state machine + orchestrator
Files:
- Create:
internal/engine/state.go - Create:
internal/engine/engine.go - Test:
internal/engine/state_test.go - Test:
internal/engine/engine_test.go
The engine ties everything together. It owns:
- The WinDivert handle.
- The redirector listener.
- The procscan ticker.
- The engine's own state machine (Idle/Starting/Active/Failed).
Start(cfg) walks through:
- Resolve upstream proxy IP (single A record, 5s timeout).
- Run
internal/checker.Runreduced subset (tcp+greet+udp, 2s budget). Any failure → Failed with reason. - Install driver (idempotent).
- Initial procscan to find Discord PIDs.
- Build filter expression.
- Open WinDivert handle.
- Open redirector.
- Spawn divert reader goroutine:
Recvpacket → parse →RewriteDst(127.0.0.1:redirector_port)→SetMapping(srcPort, origDstIP, origDstPort)→Send(reinject → kernel routes to loopback). - Spawn procscan ticker: every 2s, check PID set; if changed, rebuild filter + reopen handle.
- Transition Active.
Stop() cancels ctx, waits goroutines, closes handle, closes redirector → Idle.
For P2.1 we don't yet implement Reconnecting state (P2.3) or panic recovery (P2.3). On any unexpected error we go straight to Failed.
- Step 1: Write tests for state.go
Create internal/engine/state_test.go:
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)
}
}
- Step 2: Implement
internal/engine/state.go
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
}
- Step 3: Run state test — verify pass
go test ./internal/engine/... -run TestStatusTransitions -v
Expected: PASS.
- Step 4: Write engine integration tests
Create internal/engine/engine_test.go:
//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())
}
The test is gated by the integration build tag so plain go test ./... doesn't try to open a WinDivert handle on every CI run.
- Step 5: Implement
internal/engine/engine.go
package engine
import (
"context"
"errors"
"fmt"
"net"
"os"
"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
handle *divert.Handle
redir *redirect.Redirector
ctx context.Context
cnl context.CancelFunc
wg sync.WaitGroup
ownPID uint32
}
// 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()),
}, 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 {
// 1. Resolve upstream
host, _, err := net.SplitHostPort(e.cfg.ProxyAddr)
if err != nil {
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 {
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 {
return fmt.Errorf("no IPv4 for %q", host)
}
e.upstreamIP = upstream
// 2. Driver install (idempotent)
if _, err := divert.InstallDriver(); err != nil {
return fmt.Errorf("install driver: %w", err)
}
// 3. Initial procscan
pids, err := procscan.Snapshot(e.cfg.Targets)
if err != nil {
return fmt.Errorf("procscan: %w", err)
}
pidList := make([]uint32, 0, len(pids))
for p := range pids {
pidList = append(pidList, p)
}
// 4. Open redirector listener
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: "127.0.0.1:0",
})
if err != nil {
return fmt.Errorf("redirector: %w", err)
}
e.redir = r
// 5. Build filter + open handle
filter := divert.BuildFilter(divert.FilterParams{
TargetPIDs: pidList,
OwnPID: e.ownPID,
UpstreamIP: upstream.String(),
})
h, err := divert.Open(filter)
if err != nil {
r.Close()
return fmt.Errorf("WinDivert open: %w", err)
}
e.handle = h
// 6. Spawn divert reader + procscan ticker
e.ctx, e.cnl = context.WithCancel(context.Background())
e.wg.Add(2)
go e.diverterLoop()
go e.procscanLoop()
return nil
}
// 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.redir != nil {
e.redir.Close()
}
e.wg.Wait()
e.handle = nil
e.redir = nil
e.transition(StatusIdle, nil)
return nil
}
func (e *Engine) diverterLoop() {
defer e.wg.Done()
buf := make([]byte, 65536)
listenerPort := e.redir.LocalPort()
for {
select {
case <-e.ctx.Done():
return
default:
}
n, addr, err := e.handle.Recv(buf)
if err != nil {
if errors.Is(err, divert.ErrShutdown) || errors.Is(err, divert.ErrInvalidHandle) {
e.transition(StatusFailed, err)
return
}
continue
}
// Parse + record + rewrite
info, err := divert.ParseIPv4TCP(buf[:n])
if err != nil {
// Not parseable — reinject as-is.
_, _ = e.handle.Send(buf[:n], addr)
continue
}
// SYN packets don't carry the full flow yet — but every
// outbound TCP carries src_port we can map. We always record
// the latest mapping, refreshing TTL on subsequent packets.
e.redir.SetMapping(info.SrcPort, info.DstIP, info.DstPort)
// Rewrite to loopback
if err := divert.RewriteDst(buf[:n], net.IPv4(127, 0, 0, 1), listenerPort); err == nil {
_, _ = e.handle.Send(buf[:n], addr)
}
}
}
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
}
// Rebuild filter + reopen handle
pidList := make([]uint32, 0, len(cur))
for p := range cur {
pidList = append(pidList, p)
}
filter := divert.BuildFilter(divert.FilterParams{
TargetPIDs: pidList,
OwnPID: e.ownPID,
UpstreamIP: e.upstreamIP.String(),
})
newH, err := divert.Open(filter)
if err != nil {
e.transition(StatusFailed, fmt.Errorf("reopen handle on PID change: %w", err))
return
}
oldH := e.handle
e.handle = newH
if oldH != nil {
oldH.Close()
}
prev = cur
}
}
- Step 6: Run engine state test
go test ./internal/engine/... -run TestStatusTransitions -v
Expected: PASS.
- Step 7: Build the full project
go build ./...
Expected: clean.
- Step 8: Commit
git add internal/engine/
git commit -m "$(cat <<'EOF'
internal/engine: state machine + orchestrator (P2.1 scope)
Idle → Starting → Active → Failed lifecycle. bringUp resolves
upstream IP, installs the driver (idempotent), runs initial procscan,
opens redirector listener, builds filter + opens WinDivert handle,
then spawns the diverter reader and 2-second procscan ticker.
On every outbound TCP packet from a target PID: record (src_port →
real_target) mapping, rewrite dst to 127.0.0.1:listener_port,
re-inject. Loopback listener picks up the connection, looks up the
original target, and SOCKS5-tunnels.
P2.1 scope: no Reconnecting state, no panic recovery, no UDP
forwarding. Those land in P2.2/P2.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 11: GUI integration
Files:
- Modify:
internal/gui/app.go
Replace the stub StartEngine/StopEngine with calls into engine.Engine. Map engine.Status to the existing engine:status event payload. Stats remain stubbed for P2.1 (real bytes counters land in P2.4 alongside the tray UI).
- Step 1: Read current
internal/gui/app.go
Familiarise yourself with the existing structure — App struct fields, Startup hook, the running/startedAt fields that need to become engine state.
- Step 2: Modify
internal/gui/app.go
Replace the App struct's bare running bool field and stub StartEngine/StopEngine with:
import (
// ... existing
"git.okcu.io/root/drover-go/internal/engine"
)
type App struct {
ctx context.Context
version string
mu sync.Mutex
eng *engine.Engine
startedAt time.Time
cancelCheck context.CancelFunc
muCheck sync.Mutex
checkDone chan struct{}
}
Replace StartEngine body with:
func (a *App) StartEngine(cfg Config) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.eng != nil && a.eng.Status() == engine.StatusActive {
return nil
}
e, err := engine.New(engine.Config{
ProxyAddr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
UseAuth: cfg.Auth,
Login: cfg.Login,
Password: cfg.Password,
Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"},
})
if err != nil {
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
return err
}
if err := e.Start(a.ctx); err != nil {
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
return err
}
a.eng = e
a.startedAt = time.Now()
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
return nil
}
func (a *App) StopEngine() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.eng == nil {
return nil
}
err := a.eng.Stop()
a.eng = nil
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false})
return err
}
func (a *App) GetStatus() map[string]any {
a.mu.Lock()
defer a.mu.Unlock()
running := a.eng != nil && a.eng.Status() == engine.StatusActive
res := map[string]any{
"running": running,
"uptimeS": int(time.Since(a.startedAt).Seconds()),
}
if a.eng != nil {
res["state"] = string(a.eng.Status())
if err := a.eng.LastError(); err != nil {
res["error"] = err.Error()
}
}
return res
}
Note: this requires Config.Host to be a string (already is per existing struct) and Config.Port int (already is).
- Step 3: Adjust
statsLoopto use engine state
func (a *App) statsLoop() {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
tick := time.NewTicker(time.Second)
defer tick.Stop()
for range tick.C {
a.mu.Lock()
if a.eng == nil || a.eng.Status() != engine.StatusActive || a.ctx == nil {
a.mu.Unlock()
continue
}
uptime := int(time.Since(a.startedAt).Seconds())
a.mu.Unlock()
runtime.EventsEmit(a.ctx, "stats:update", map[string]any{
"up": r.Intn(50_000) + 5_000,
"down": r.Intn(500_000) + 50_000,
"tcp": r.Intn(8) + 1,
"udp": 0, // P2.1 scope: no UDP yet
"uptimeS": uptime,
})
}
}
The randomised numbers stay until P2.4 (real counters). UDP is hard-zero because P2.1 doesn't forward UDP.
- Step 4:
go build ./...to verify
go build ./...
Expected: clean.
- Step 5: Commit
git add internal/gui/app.go
git commit -m "$(cat <<'EOF'
internal/gui: wire StartEngine/StopEngine to internal/engine
Replaces the stub flag-toggle with a real engine.Engine. GetStatus
now reports the engine's actual state machine value. Stats remain
randomised in P2.1 — real bytes-counters land in P2.4 with the tray
UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
Task 12: End-to-end manual verification
Files:
- Create:
docs/testing/p2.1-manual.md(test journal)
This is the milestone gate — proves the whole pipeline works on a real machine. No code changes; we run the binary, exercise the happy path, and document outcomes for next milestones to refer back to.
- Step 1: Build current state
cd F:/work/drover-go && bash rebuild.sh
Expected: drover-test.exe produced, ~12 MB.
- Step 2: Run manual journey, recording outcomes
Create F:/work/drover-go/docs/testing/p2.1-manual.md with the following template, filling in actual results as you go:
# P2.1 manual verification — YYYY-MM-DD
## Environment
- Win11 build XX.X
- Admin shell: yes/no
- Discord version: X.Y.Z
- Upstream proxy: 95.165.72.59:12334 (mihomo on LXC 102)
## Acceptance criteria
| # | Step | Expected | Actual |
|---|---|---|---|
| 1 | Launch `drover-test.exe` from non-admin shell | UAC prompt; on accept, GUI opens | |
| 2 | Click "Check connection" | All 6 tests green (TCP/greet/connect/UDP/voice-quality/api) | |
| 3 | Click "Start proxying" | Status header → "Active"; engine.Status()=active in logs | |
| 4 | Open Discord (kill if running, restart) | Within 2-4s, Drover detects the PID and rebuilds filter | |
| 5 | Send a chat message in Discord | Message sends; verify in mihomo logs that it was tunneled | |
| 6 | Open Discord settings → User Settings → check own profile | Profile loads (proves API requests went through proxy) | |
| 7 | Click "Stop" in Drover | Engine returns to Idle within 500ms; no driver-related errors in logs | |
| 8 | Run `sc query WinDivert` from PowerShell after Stop | Service exists, state STOPPED — driver remains installed | |
| 9 | Restart Drover, observe self-loop test: open Wireshark on the LAN interface, filter `tcp port 12334`, see only Drover's outbound (one stream) — no exponential growth | Single stable stream, no infinite loop | |
| 10 | Try voice call in Discord | NO voice (UDP not yet implemented in P2.1) — we expect Discord client to keep retrying with no audio. Verify it doesn't deadlock the Drover engine. | |
| 11 | Kill Drover process from Task Manager mid-Active | Driver remains in valid state; next launch re-acquires handle without ERROR_DRIVER_FAILED_PRIOR_UNLOAD | |
## Known issues found
(fill in as they happen)
## Notes for P2.2
(any insight that informs UDP implementation)
- Step 3: Execute the 11 steps above and fill in the table
Take screenshots at key moments (UAC prompt, GUI active state, Wireshark single-stream view, mihomo logs showing tunneled traffic). Save them under docs/testing/p2.1-screenshots/ (gitignored if >1MB total).
- Step 4: Tag any failures as bugs to fix
For each FAILED row, either:
-
(a) the bug is small enough to fix inline → write a follow-up commit before tagging the milestone, OR
-
(b) the bug indicates a deeper issue → file as
docs/planning/p2.1-followup-N.mdwith steps to reproduce and proposed fix. -
Step 5: Commit the test journal
git add docs/testing/p2.1-manual.md docs/testing/p2.1-screenshots/
git commit -m "$(cat <<'EOF'
docs/testing: P2.1 manual verification journal
End-to-end journey on real Win11 with mihomo upstream proxy.
All 11 acceptance steps recorded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
- Step 6: Tag the milestone
If all steps PASS:
git tag -a v0.3.0-p2.1 -m "P2.1 — TCP-only MVP
Discord chat + API now route through SOCKS5 via WinDivert kernel
capture. Voice (UDP) deferred to P2.2."
git push origin v0.3.0-p2.1
This triggers the existing CI release workflow → produces drover-vX.Y.Z exe + installer + SHA256SUMS on Forgejo.
Self-Review
Spec coverage check (against docs/superpowers/specs/2026-05-01-engine-design.md P2.1 requirements):
| P2.1 requirement | Implemented in task |
|---|---|
| WinDivert handle | Task 6 |
| Filter expression | Task 3 |
| Packet parser | Task 4 |
| TCP NAT-loopback redirect | Tasks 9 + 10 (engine wires it) |
| SOCKS5 client (TCP only) | Task 7 |
| procscan | Task 8 |
| Self-loop protection | Task 3 (filter) + Task 10 (own_pid in engine) |
| Basic engine state machine (Idle/Starting/Active/Failed) | Task 10 (state.go + engine.go) |
| UAC re-launch | Task 1 |
| Driver install (embedded extract) | Tasks 2 + 5 |
| Acceptance: chat/API through proxy | Task 12 |
| Acceptance: clean stop in <500ms | Task 12 |
| Acceptance: driver remains installed | Task 12 |
| Acceptance: no self-loop infinite traffic | Task 12 |
All P2.1 requirements covered.
Type/signature consistency check:
Statusenum used identically in state.go, engine.go, gui/app.go ✓divert.FilterParamsfield names match between filter.go and engine.go ✓socks5.Configfields (ProxyAddr, UseAuth, Login, Password) match between client.go, redirect/tcp.go, engine.go ✓procscan.Snapshotreturnsmap[uint32]stringconsistently ✓redirect.Redirector.SetMapping(uint16, net.IP, uint16)matches the call in engine.go ✓divert.Handle.Recvreturns(int, *idivert.Address, error)— engine.diverterLoop matches ✓divert.Handle.Send(buf, addr)— same ✓
Placeholder scan: searched plan for "TBD", "TODO", "implement later", "fill in details". Two intentional FILL_ME strings exist in Task 2 (SHA256 sentinels) — explicit instruction tells the engineer to compute them via sha256sum and paste in. No other placeholders.
Open questions deferred to implementation:
- Whether
imgk/divert-gov0.1.0 actually compiles cleanly under Go 1.23 (Task 2 is the verification gate; fallback path documented in Task 6). - Filter expression length limit (mentioned in spec; in P2.1 we have ~5 PIDs max so well under).
These are validated empirically in Tasks 2 and 6 — not gaps in the plan.
Plan ready. 12 tasks, ~3-4 days of focused work. Each task is bite-sized (TDD where practical, manual verification where the syscall layer makes mocks expensive). Self-contained for subagent execution per Rule 18 (subagent-driven-development).