# 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).