diff --git a/docs/superpowers/plans/2026-05-01-p2.1-tcp-mvp.md b/docs/superpowers/plans/2026-05-01-p2.1-tcp-mvp.md new file mode 100644 index 0000000..f68fb83 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-p2.1-tcp-mvp.md @@ -0,0 +1,2971 @@ +# 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:`, 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`: + +```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** + +```bash +cd F:/work/drover-go && go test ./cmd/drover/... +``` + +Expected: FAIL — `IsAdmin` and `CmdNeedsAdmin` undefined. + +- [ ] **Step 3: Write `cmd/drover/uac_windows.go`** + +```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** + +```bash +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: + +```go +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 +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** + +```bash +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) +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-go` to go.mod (try v0.1.0 first)** + +```bash +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** + +```bash +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 +//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** + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +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) +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`: + +```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** + +```bash +go test ./internal/divert/... -run TestBuildFilter +``` + +Expected: FAIL — `BuildFilter` undefined. + +- [ ] **Step 3: Implement `internal/divert/filter.go`** + +```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** + +```bash +go test ./internal/divert/... -run TestBuildFilter -v +``` + +Expected: 5 PASS. + +- [ ] **Step 5: Commit** + +```bash +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) +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`: + +```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** + +```bash +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`** + +```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** + +```bash +go test ./internal/divert/... -run "TestParseIPv4TCP|TestRewriteDst" -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +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) +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`: + +```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** + +```bash +go test ./internal/divert/... -run TestInstallDriver -v +``` + +Expected: FAIL — `installDriverInto` undefined. + +- [ ] **Step 3: Implement `internal/divert/installer.go`** + +```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** + +```bash +go test ./internal/divert/... -run TestInstallDriver -v +``` + +Expected: 2 PASS (Windows), 1 SKIP (ARM64 case unless on ARM64 hardware). + +- [ ] **Step 5: Commit** + +```bash +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) +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`: + +```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** + +```bash +go test ./internal/divert/... -run TestOpen -v +``` + +Expected: SKIP (when not running as admin) or PASS (when admin). + +- [ ] **Step 3: Implement `internal/divert/divert.go` using imgk/divert-go** + +```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** + +```bash +go test ./internal/divert/... -run TestOpen -v +``` + +If running from an admin shell: PASS. From a non-admin shell: SKIP. + +- [ ] **Step 5: Commit** + +```bash +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) +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`: + +```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** + +```bash +go test ./internal/socks5/... -run TestDial -v +``` + +Expected: FAIL — `Dial` and `Config` undefined. + +- [ ] **Step 3: Implement `internal/socks5/client.go`** + +```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** + +```bash +go test ./internal/socks5/... -run TestDial -v +``` + +Expected: 3 PASS. + +- [ ] **Step 5: Commit** + +```bash +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) +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 +//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** + +```bash +go test ./internal/procscan/... -v +``` + +Expected: FAIL — symbols undefined. + +- [ ] **Step 3: Implement `internal/procscan/procscan.go`** + +```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** + +```bash +go test ./internal/procscan/... -v +``` + +Expected: 3 PASS (Windows host), or skip on non-Windows. + +- [ ] **Step 5: Commit** + +```bash +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) +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`: + +```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** + +```bash +go test ./internal/redirect/... -run TestRedirector -v +``` + +Expected: FAIL — `New`, `Config`, `SetMapping`, `LocalAddr`, `Close` undefined. + +- [ ] **Step 3: Implement `internal/redirect/tcp.go`** + +```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** + +```bash +go test ./internal/redirect/... -run TestRedirector -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +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) +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: +1. The WinDivert handle. +2. The redirector listener. +3. The procscan ticker. +4. The engine's own state machine (Idle/Starting/Active/Failed). + +`Start(cfg)` walks through: +1. Resolve upstream proxy IP (single A record, 5s timeout). +2. Run `internal/checker.Run` reduced subset (tcp+greet+udp, 2s budget). Any failure → Failed with reason. +3. Install driver (idempotent). +4. Initial procscan to find Discord PIDs. +5. Build filter expression. +6. Open WinDivert handle. +7. Open redirector. +8. Spawn divert reader goroutine: `Recv` packet → parse → `RewriteDst(127.0.0.1:redirector_port)` → `SetMapping(srcPort, origDstIP, origDstPort)` → `Send` (reinject → kernel routes to loopback). +9. Spawn procscan ticker: every 2s, check PID set; if changed, rebuild filter + reopen handle. +10. 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`: + +```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`** + +```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** + +```bash +go test ./internal/engine/... -run TestStatusTransitions -v +``` + +Expected: PASS. + +- [ ] **Step 4: Write engine integration tests** + +Create `internal/engine/engine_test.go`: + +```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`** + +```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** + +```bash +go test ./internal/engine/... -run TestStatusTransitions -v +``` + +Expected: PASS. + +- [ ] **Step 7: Build the full project** + +```bash +go build ./... +``` + +Expected: clean. + +- [ ] **Step 8: Commit** + +```bash +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) +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: + +```go +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: + +```go +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 `statsLoop` to use engine state** + +```go +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** + +```bash +go build ./... +``` + +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +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) +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** + +```bash +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: + +```markdown +# 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.md` with steps to reproduce and proposed fix. + +- [ ] **Step 5: Commit the test journal** + +```bash +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) +EOF +)" && git push +``` + +- [ ] **Step 6: Tag the milestone** + +If all steps PASS: + +```bash +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**: +- `Status` enum used identically in state.go, engine.go, gui/app.go ✓ +- `divert.FilterParams` field names match between filter.go and engine.go ✓ +- `socks5.Config` fields (ProxyAddr, UseAuth, Login, Password) match between client.go, redirect/tcp.go, engine.go ✓ +- `procscan.Snapshot` returns `map[uint32]string` consistently ✓ +- `redirect.Redirector.SetMapping(uint16, net.IP, uint16)` matches the call in engine.go ✓ +- `divert.Handle.Recv` returns `(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-go` v0.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).