Files
drover-go/docs/superpowers/plans/2026-05-01-p2.1-tcp-mvp.md
T
root c647c09c20 plan: P2.1 TCP-only MVP — 12 bite-sized tasks
Spec: docs/superpowers/specs/2026-05-01-engine-design.md (P2.1 section)

Tasks 1-2: bootstrap (UAC + binary embed)
Tasks 3-6: divert layer (filter / packet / installer / handle)
Tasks 7-9: forwarding (SOCKS5 client / procscan / TCP redirect)
Task 10:  engine state machine + orchestrator
Task 11:  GUI integration
Task 12:  end-to-end manual verification + tag v0.3.0-p2.1

Each task has failing-test → impl → passing-test → commit cycles
(TDD where practical; syscall-heavy paths get manual verification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:30:14 +03:00

2972 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P2.1 — TCP-only MVP Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Get drover routing Discord's TCP traffic (chat + API) through an upstream SOCKS5 proxy via WinDivert kernel-level packet capture. Voice (UDP) is explicitly deferred to P2.2.
**Architecture:** WinDivert v2.2.2 driver captures outbound TCP from `Discord.exe`/`DiscordCanary.exe`/`DiscordPTB.exe`/`Update.exe`, the engine NAT-rewrites destination to `127.0.0.1:<listener_port>`, the loopback listener accepts the connection, looks up the original destination in a per-flow map, opens a SOCKS5 CONNECT to the upstream proxy, and pumps bytes both directions with `io.Copy`. Filter expression dynamically rebuilds when Discord's PIDs change (every 2s via `Toolhelp32`). Self-loop protection via `processId != own_pid` in the filter and excluding the upstream proxy IP.
**Tech Stack:** Go 1.23, `golang.org/x/sys/windows` for syscalls, `github.com/imgk/divert-go` v0.1.0 for WinDivert bindings (with fallback to direct syscalls if it's broken), embedded `WinDivert64.sys` + `WinDivert.dll` v2.2.2 from `third_party/windivert/`. Test framework: `testify`.
**Spec:** `docs/superpowers/specs/2026-05-01-engine-design.md` (read sections P2.1, WinDivert layer, TCP redirect, Process scanning, Self-loop protection, UAC).
**Scope:** Tasks 112 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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 2: WinDivert library sanity check + binary embed
**Files:**
- Create: `internal/divert/embed.go`
Before we wrap the WinDivert handle, verify `github.com/imgk/divert-go` builds against our Go 1.23 + `third_party/windivert/` headers. If broken, this task is the single point where we decide to fall back to direct syscall bindings.
This task does NOT introduce a Go binding wrapper — it just embeds the binaries and runs a one-time `go get` + smoke build.
- [ ] **Step 1: Add `imgk/divert-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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 3: Filter expression builder
**Files:**
- Create: `internal/divert/filter.go`
- Test: `internal/divert/filter_test.go`
Pure-Go construction of the WinDivert filter expression. No driver access, fully unit-testable. The expression is rebuilt every time the Discord PID list changes.
- [ ] **Step 1: Write failing tests**
Create `internal/divert/filter_test.go`:
```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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 4: Packet parser + checksum recompute
**Files:**
- Create: `internal/divert/packet.go`
- Test: `internal/divert/packet_test.go`
Parses an outbound IPv4 + TCP packet from a raw byte buffer (as WinDivert hands it to us), supports modifying destination address/port, recomputes IP and TCP checksums, and serializes back. UDP support is added in P2.2; for now we restrict to TCP since that's all P2.1 needs.
- [ ] **Step 1: Write failing tests**
Create `internal/divert/packet_test.go`:
```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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 5: Driver installer
**Files:**
- Create: `internal/divert/installer.go`
- Test: `internal/divert/installer_test.go`
On engine start, extract `WinDivert64.sys` + `WinDivert.dll` from the embedded bytes into `%PROGRAMDATA%\Drover\windivert\` (creating the directory if needed). SHA256-verify the extracted files match `WinDivertSysSHA256` / `WinDivertDllSHA256` constants. Detect ARM64 and return a clear error.
- [ ] **Step 1: Write failing tests**
Create `internal/divert/installer_test.go`:
```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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 6: WinDivert handle wrapper
**Files:**
- Create: `internal/divert/divert.go`
- Test: `internal/divert/divert_test.go` (smoke tests only — full integration in Task 12)
The thin Go layer between our engine and the WinDivert API. We use `imgk/divert-go` per Task 2 with fallback to direct syscalls if it doesn't compile. Provides `Open`, `Close`, `Recv` (read raw packet + WinDivertAddress), `Send` (reinject).
If `imgk/divert-go` is unusable (failed Task 2 build), this task instead writes raw `syscall.NewLazyDLL("WinDivert.dll")` bindings — see "fallback" subtask below.
- [ ] **Step 1: Write smoke test**
Create `internal/divert/divert_test.go`:
```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 200280 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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 7: Production SOCKS5 client (TCP CONNECT)
**Files:**
- Create: `internal/socks5/client.go`
- Test: `internal/socks5/client_test.go`
A separate, leaner SOCKS5 client from `internal/checker/socks5.go`. The diagnostic client returns raw bytes for hex display; the production client just returns a `net.Conn` that's been CONNECT'd through. No retries here — that's the engine's job.
- [ ] **Step 1: Write failing test**
Create `internal/socks5/client_test.go`:
```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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 8: Procscan
**Files:**
- Create: `internal/procscan/procscan.go`
- Test: `internal/procscan/procscan_test.go`
Toolhelp32 enumerates all running processes. We filter by exe-name list and return the resulting PID set. The engine kicks off a 2-second ticker calling `Snapshot()` and diffing against the previous result.
- [ ] **Step 1: Write tests**
Create `internal/procscan/procscan_test.go`:
```go
//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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 9: TCP NAT-loopback redirect
**Files:**
- Create: `internal/redirect/tcp.go`
- Test: `internal/redirect/tcp_test.go`
The heart of the engine. A loopback listener accepts redirected Discord connections, looks up `(client_src_port → real_target)` in the per-flow map, opens a SOCKS5 CONNECT to the upstream proxy targeting `real_target`, and pumps bytes both directions until either side closes.
The map is populated by the divert layer (Task 10 wires it in) when a SYN arrives from a target PID — but for this task we just expose the API and unit-test the pump.
- [ ] **Step 1: Write tests**
Create `internal/redirect/tcp_test.go`:
```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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 10: Engine state machine + orchestrator
**Files:**
- Create: `internal/engine/state.go`
- Create: `internal/engine/engine.go`
- Test: `internal/engine/state_test.go`
- Test: `internal/engine/engine_test.go`
The engine ties everything together. It owns:
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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 11: GUI integration
**Files:**
- Modify: `internal/gui/app.go`
Replace the stub `StartEngine`/`StopEngine` with calls into `engine.Engine`. Map `engine.Status` to the existing `engine:status` event payload. Stats remain stubbed for P2.1 (real bytes counters land in P2.4 alongside the tray UI).
- [ ] **Step 1: Read current `internal/gui/app.go`**
Familiarise yourself with the existing structure — App struct fields, Startup hook, the running/startedAt fields that need to become engine state.
- [ ] **Step 2: Modify `internal/gui/app.go`**
Replace the App struct's bare `running bool` field and stub StartEngine/StopEngine with:
```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) <noreply@anthropic.com>
EOF
)" && git push
```
---
## Task 12: End-to-end manual verification
**Files:**
- Create: `docs/testing/p2.1-manual.md` (test journal)
This is the milestone gate — proves the whole pipeline works on a real machine. No code changes; we run the binary, exercise the happy path, and document outcomes for next milestones to refer back to.
- [ ] **Step 1: Build current state**
```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) <noreply@anthropic.com>
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).