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

82 KiB
Raw Blame History

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:

package main

import (
	"testing"
)

func TestIsAdmin_Smoke(t *testing.T) {
	// Smoke test: IsAdmin returns a bool without panicking.
	// We can't assert true/false without knowing the test environment,
	// but we ensure the syscall path doesn't crash.
	_ = IsAdmin()
}

func TestCmdNeedsAdmin_NoAdminFlags(t *testing.T) {
	cases := []struct {
		args     []string
		needsAdm bool
	}{
		{[]string{}, true},                    // bare drover.exe → GUI mode → needs admin
		{[]string{"check"}, false},            // diagnostic only, no driver
		{[]string{"check", "--host", "x"}, false},
		{[]string{"--version"}, false},
		{[]string{"version"}, false},
		{[]string{"update"}, false},           // self-update doesn't need driver
	}
	for _, c := range cases {
		got := CmdNeedsAdmin(c.args)
		if got != c.needsAdm {
			t.Errorf("CmdNeedsAdmin(%v) = %v, want %v", c.args, got, c.needsAdm)
		}
	}
}
  • Step 2: Run test to verify it fails
cd F:/work/drover-go && go test ./cmd/drover/...

Expected: FAIL — IsAdmin and CmdNeedsAdmin undefined.

  • Step 3: Write cmd/drover/uac_windows.go
//go:build windows

package main

import (
	"os"
	"syscall"
	"unsafe"

	"golang.org/x/sys/windows"
)

// IsAdmin returns true when the current process token has elevation.
// Wraps GetTokenInformation(TokenElevation).
func IsAdmin() bool {
	var token windows.Token
	if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
		return false
	}
	defer token.Close()

	var elevation uint32
	var sz uint32
	err := windows.GetTokenInformation(
		token,
		windows.TokenElevation,
		(*byte)(unsafe.Pointer(&elevation)),
		uint32(unsafe.Sizeof(elevation)),
		&sz,
	)
	if err != nil {
		return false
	}
	return elevation != 0
}

// CmdNeedsAdmin reports whether the given CLI args land in a code path
// that requires a WinDivert handle (and therefore admin). The default
// (no args = GUI mode) needs admin; explicit subcommands like check,
// version, update do not.
func CmdNeedsAdmin(args []string) bool {
	if len(args) == 0 {
		return true // bare drover.exe → GUI/engine
	}
	switch args[0] {
	case "check", "version", "--version", "-v", "update", "--help", "-h", "help":
		return false
	default:
		return true
	}
}

// ReElevate re-launches the current executable with the given args via
// ShellExecuteW("runas", ...). On success the caller should os.Exit(0)
// immediately. Returns nil even when the user cancels UAC — the caller
// can't distinguish; we just exit cleanly afterward.
func ReElevate(args []string) error {
	exe, err := os.Executable()
	if err != nil {
		return err
	}
	verb, _ := syscall.UTF16PtrFromString("runas")
	exePtr, _ := syscall.UTF16PtrFromString(exe)

	var paramsPtr *uint16
	if len(args) > 0 {
		// Quote each arg in case of spaces.
		quoted := make([]string, len(args))
		for i, a := range args {
			quoted[i] = `"` + a + `"`
		}
		joined := ""
		for i, q := range quoted {
			if i > 0 {
				joined += " "
			}
			joined += q
		}
		paramsPtr, _ = syscall.UTF16PtrFromString(joined)
	}

	cwd, _ := os.Getwd()
	cwdPtr, _ := syscall.UTF16PtrFromString(cwd)

	// SW_NORMAL = 1
	return windows.ShellExecute(0, verb, exePtr, paramsPtr, cwdPtr, 1)
}
  • Step 4: Run test to verify it passes
go test ./cmd/drover/... -run "TestIsAdmin_Smoke|TestCmdNeedsAdmin"

Expected: PASS.

  • Step 5: Wire into cmd/drover/main.go

Read the current main.go first to find the insertion point. The UAC check goes BEFORE auto-update and BEFORE GUI startup, AFTER attachConsole() and Cobra arg parsing for help/version flags.

The simplest hook: in main(), right after attachConsole(), add:

if CmdNeedsAdmin(os.Args[1:]) && !IsAdmin() {
    if err := ReElevate(os.Args[1:]); err != nil {
        fmt.Fprintf(os.Stderr, "failed to re-elevate: %v\n", err)
    }
    os.Exit(0)
}
  • Step 6: Manual smoke check
bash rebuild.sh
./drover-test.exe check --host 95.165.72.59 --port 12334

Expected: runs without UAC prompt (CLI subcommand). Open Explorer, double-click drover-test.exe from a non-admin shell — UAC prompt appears; on accept, GUI opens.

  • Step 7: Commit
git add cmd/drover/uac_windows.go cmd/drover/uac_windows_test.go cmd/drover/main.go
git commit -m "$(cat <<'EOF'
cmd/drover: UAC re-launch helper for non-admin invocations

CLI subcommands (check/version/update) don't need driver access and
run as user. Bare drover.exe (GUI/engine mode) requires admin for
WinDivertOpen — re-launches via ShellExecute("runas") and exits.

Per spec decision B1: prompt at every launch, no scheduled-task
trampoline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 2: WinDivert library sanity check + binary embed

Files:

  • Create: internal/divert/embed.go

Before we wrap the WinDivert handle, verify github.com/imgk/divert-go builds against our Go 1.23 + third_party/windivert/ headers. If broken, this task is the single point where we decide to fall back to direct syscall bindings.

This task does NOT introduce a Go binding wrapper — it just embeds the binaries and runs a one-time go get + smoke build.

  • Step 1: Add imgk/divert-go to go.mod (try v0.1.0 first)
cd F:/work/drover-go && go get github.com/imgk/divert-go@v0.1.0 2>&1 | tail -5

Expected: clean go-get. If errors (broken module / Go-version conflict), report back — we'll switch to direct syscalls in Task 6 instead.

  • Step 2: Smoke-build to verify
cd F:/work/drover-go && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
  go build -tags "desktop,production" -ldflags "-H=windowsgui" \
  -o /tmp/probe.exe ./cmd/drover

Expected: builds clean. If imgk/divert-go references CGO (it shouldn't for v0.1.0+), we'll see CGO errors and need to either add CGO_ENABLED=1 (avoid — breaks our cross-compile) or switch to direct syscalls.

  • Step 3: Create embed file internal/divert/embed.go
//go:build windows

package divert

import _ "embed"

//go:embed assets/WinDivert64.sys
var winDivertSys []byte

//go:embed assets/WinDivert.dll
var winDivertDll []byte

// Sentinel SHA256 of the embedded binaries — verified on extract.
// Generated via PowerShell:
//
//   Get-FileHash third_party/windivert/WinDivert64.sys -Algorithm SHA256
//   Get-FileHash third_party/windivert/WinDivert.dll   -Algorithm SHA256
//
// Update both constants when bumping WinDivert versions.
const (
	WinDivertSysSHA256 = "FILL_ME"
	WinDivertDllSHA256 = "FILL_ME"
)
  • Step 4: Copy binaries into the package's assets/ directory
mkdir -p internal/divert/assets
cp third_party/windivert/WinDivert64.sys internal/divert/assets/
cp third_party/windivert/WinDivert.dll internal/divert/assets/
  • Step 5: Compute the SHA256 hashes and patch the file
cd F:/work/drover-go && \
  sys_hash=$(sha256sum internal/divert/assets/WinDivert64.sys | awk '{print $1}') && \
  dll_hash=$(sha256sum internal/divert/assets/WinDivert.dll | awk '{print $1}') && \
  echo "sys=$sys_hash dll=$dll_hash"

Patch internal/divert/embed.go replacing both FILL_ME strings with the actual hashes (uppercase or lowercase, just be consistent — extractor uses strings.EqualFold).

  • Step 6: Verify embed compiles
go build ./internal/divert/...

Expected: clean build (file produces an unused-vars warning if anything else was missing, but with _ "embed" import + //go:embed directives it should just compile silently).

  • Step 7: Commit
git add go.mod go.sum internal/divert/
git commit -m "$(cat <<'EOF'
internal/divert: embed WinDivert64.sys + WinDivert.dll v2.2.2 with SHA256 sentinels

Adds github.com/imgk/divert-go v0.1.0 dependency. Embedded driver
binaries land at runtime in %PROGRAMDATA%\Drover\windivert\ via the
installer (next task).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 3: Filter expression builder

Files:

  • Create: internal/divert/filter.go
  • Test: internal/divert/filter_test.go

Pure-Go construction of the WinDivert filter expression. No driver access, fully unit-testable. The expression is rebuilt every time the Discord PID list changes.

  • Step 1: Write failing tests

Create internal/divert/filter_test.go:

package divert

import (
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestBuildFilter_HappyPath(t *testing.T) {
	got := BuildFilter(FilterParams{
		TargetPIDs: []uint32{12345, 67890},
		OwnPID:     999,
		UpstreamIP: "95.165.72.59",
	})
	// Required clauses
	assert.Contains(t, got, "outbound")
	assert.Contains(t, got, "(tcp or udp)")
	assert.Contains(t, got, "ip")
	assert.Contains(t, got, "processId == 12345")
	assert.Contains(t, got, "processId == 67890")
	assert.Contains(t, got, "processId != 999")
	assert.Contains(t, got, "ip.DstAddr != 95.165.72.59")
	// Loopback / multicast / link-local exclusions
	assert.Contains(t, got, "127.0.0.0")
	assert.Contains(t, got, "224.0.0.0")
	assert.Contains(t, got, "169.254.0.0")
}

func TestBuildFilter_SinglePID(t *testing.T) {
	got := BuildFilter(FilterParams{
		TargetPIDs: []uint32{42},
		OwnPID:     1,
		UpstreamIP: "1.2.3.4",
	})
	assert.Contains(t, got, "processId == 42")
}

func TestBuildFilter_NoTargetPIDs(t *testing.T) {
	// No Discord running. We still produce a syntactically valid filter
	// that matches nothing (we can't pass an empty filter to WinDivert).
	got := BuildFilter(FilterParams{
		TargetPIDs: nil,
		OwnPID:     999,
		UpstreamIP: "1.2.3.4",
	})
	// "false" alone is a valid filter that captures nothing — perfect
	// for "Discord not running" interim.
	assert.Equal(t, "false", got)
}

func TestBuildFilter_OwnPIDNotInTargets(t *testing.T) {
	// Defensive: even if OwnPID accidentally appears in TargetPIDs, the
	// processId != ownPid clause still excludes it.
	got := BuildFilter(FilterParams{
		TargetPIDs: []uint32{999, 1234},
		OwnPID:     999,
		UpstreamIP: "1.2.3.4",
	})
	assert.Contains(t, got, "processId != 999")
	// The exclusion takes precedence syntactically because of the AND.
	assert.True(t, strings.Contains(got, "and processId != 999"))
}

func TestBuildFilter_UpstreamIPv4Format(t *testing.T) {
	// Anything that's not a parseable IPv4 → return error string sentinel.
	got := BuildFilter(FilterParams{
		TargetPIDs: []uint32{1},
		OwnPID:     2,
		UpstreamIP: "not-an-ip",
	})
	// We expect the function to substitute "0.0.0.0" or similar so the
	// filter remains valid. Decision: panic? Return "false"? Per spec
	// "if upstream IP cannot be resolved we fail-stop with a clear msg".
	// So caller resolves first; this builder assumes valid input. We
	// just substitute a placeholder and document it.
	assert.Contains(t, got, "ip.DstAddr != 0.0.0.0")
}
  • Step 2: Run tests — verify failure
go test ./internal/divert/... -run TestBuildFilter

Expected: FAIL — BuildFilter undefined.

  • Step 3: Implement internal/divert/filter.go
package divert

import (
	"fmt"
	"net"
	"strings"
)

// FilterParams collects the inputs needed to build a WinDivert filter
// expression for Drover's outbound capture.
type FilterParams struct {
	// TargetPIDs is the set of PIDs whose outbound traffic should be
	// captured (e.g. Discord variants). When empty, the resulting
	// filter is "false" — captures nothing — which is the right
	// behaviour while procscan reports zero Discord processes.
	TargetPIDs []uint32

	// OwnPID is drover.exe's own PID. Excluded from capture so our
	// SOCKS5 traffic to the upstream proxy doesn't get re-captured.
	OwnPID uint32

	// UpstreamIP is the resolved IPv4 of the upstream SOCKS5 proxy.
	// Excluded from capture as a second line of defence against
	// self-loops. If unparseable, "0.0.0.0" is substituted (caller
	// should validate before calling).
	UpstreamIP string
}

// BuildFilter returns a WinDivert filter expression string suitable
// for WinDivertOpen. The expression captures only outbound IPv4 TCP/UDP
// from the listed PIDs, excluding our own process and the upstream
// proxy's IP.
func BuildFilter(p FilterParams) string {
	if len(p.TargetPIDs) == 0 {
		return "false"
	}

	upstream := p.UpstreamIP
	if net.ParseIP(upstream).To4() == nil {
		upstream = "0.0.0.0"
	}

	pidClauses := make([]string, len(p.TargetPIDs))
	for i, pid := range p.TargetPIDs {
		pidClauses[i] = fmt.Sprintf("processId == %d", pid)
	}
	pidClause := "(" + strings.Join(pidClauses, " or ") + ")"

	parts := []string{
		"outbound",
		"(tcp or udp)",
		"ip",
		pidClause,
		fmt.Sprintf("processId != %d", p.OwnPID),
		fmt.Sprintf("ip.DstAddr != %s", upstream),
		"not (ip.DstAddr >= 224.0.0.0 and ip.DstAddr <= 239.255.255.255)",
		"not (ip.DstAddr >= 127.0.0.0 and ip.DstAddr <= 127.255.255.255)",
		"not (ip.DstAddr >= 169.254.0.0 and ip.DstAddr <= 169.254.255.255)",
	}
	return strings.Join(parts, " and ")
}
  • Step 4: Run tests — verify pass
go test ./internal/divert/... -run TestBuildFilter -v

Expected: 5 PASS.

  • Step 5: Commit
git add internal/divert/filter.go internal/divert/filter_test.go
git commit -m "$(cat <<'EOF'
internal/divert: filter expression builder

Pure-Go assembly of the WinDivert filter clause. Empty PID list →
"false" (captures nothing — used during Discord-not-running window).
Non-IPv4 upstream → 0.0.0.0 fallback (caller should validate; the
builder degrades gracefully rather than panicking).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 4: Packet parser + checksum recompute

Files:

  • Create: internal/divert/packet.go
  • Test: internal/divert/packet_test.go

Parses an outbound IPv4 + TCP packet from a raw byte buffer (as WinDivert hands it to us), supports modifying destination address/port, recomputes IP and TCP checksums, and serializes back. UDP support is added in P2.2; for now we restrict to TCP since that's all P2.1 needs.

  • Step 1: Write failing tests

Create internal/divert/packet_test.go:

package divert

import (
	"net"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// helloTCPSYN is a minimum well-formed IPv4 + TCP SYN packet:
//   src=10.0.0.1:54321 dst=1.2.3.4:443
// Captured from a raw socket trace; checksums are correct.
var helloTCPSYN = []byte{
	// IPv4 header (20 bytes, IHL=5)
	0x45, 0x00, 0x00, 0x28, 0xab, 0xcd, 0x40, 0x00, 0x40, 0x06,
	0x00, 0x00, // checksum placeholder — we'll fill in below
	0x0a, 0x00, 0x00, 0x01, // src 10.0.0.1
	0x01, 0x02, 0x03, 0x04, // dst 1.2.3.4
	// TCP header (20 bytes)
	0xd4, 0x31, 0x01, 0xbb, // src=54321 dst=443
	0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
	0x50, 0x02, 0xff, 0xff,
	0x00, 0x00, // checksum placeholder
	0x00, 0x00,
}

// fillTestChecksums computes correct IP + TCP checksums for the test
// packet so we can compare against the parser's recompute output.
func fillTestChecksums(b []byte) {
	// IP checksum
	b[10], b[11] = 0, 0
	cs := ipChecksum(b[:20])
	b[10] = byte(cs >> 8)
	b[11] = byte(cs & 0xff)

	// TCP checksum
	b[36], b[37] = 0, 0
	cs = tcpChecksum(b[:20], b[20:40])
	b[36] = byte(cs >> 8)
	b[37] = byte(cs & 0xff)
}

func TestParseIPv4TCP_Roundtrip(t *testing.T) {
	pkt := make([]byte, len(helloTCPSYN))
	copy(pkt, helloTCPSYN)
	fillTestChecksums(pkt)

	p, err := ParseIPv4TCP(pkt)
	require.NoError(t, err)

	assert.Equal(t, "10.0.0.1", p.SrcIP.String())
	assert.Equal(t, "1.2.3.4", p.DstIP.String())
	assert.Equal(t, uint16(54321), p.SrcPort)
	assert.Equal(t, uint16(443), p.DstPort)
}

func TestRewriteDst_PreservesSrc(t *testing.T) {
	pkt := make([]byte, len(helloTCPSYN))
	copy(pkt, helloTCPSYN)
	fillTestChecksums(pkt)

	err := RewriteDst(pkt, net.IPv4(127, 0, 0, 1), 8080)
	require.NoError(t, err)

	p, err := ParseIPv4TCP(pkt)
	require.NoError(t, err)
	assert.Equal(t, "127.0.0.1", p.DstIP.String())
	assert.Equal(t, uint16(8080), p.DstPort)
	assert.Equal(t, "10.0.0.1", p.SrcIP.String())
	assert.Equal(t, uint16(54321), p.SrcPort)
}

func TestRewriteDst_RecomputesChecksums(t *testing.T) {
	pkt := make([]byte, len(helloTCPSYN))
	copy(pkt, helloTCPSYN)
	fillTestChecksums(pkt)

	err := RewriteDst(pkt, net.IPv4(127, 0, 0, 1), 8080)
	require.NoError(t, err)

	// Validate IP checksum
	ipCs := uint16(pkt[10])<<8 | uint16(pkt[11])
	pkt[10], pkt[11] = 0, 0
	expIP := ipChecksum(pkt[:20])
	pkt[10] = byte(ipCs >> 8)
	pkt[11] = byte(ipCs & 0xff)
	assert.Equal(t, expIP, ipCs, "IP checksum mismatch")

	// Validate TCP checksum
	tcpCs := uint16(pkt[36])<<8 | uint16(pkt[37])
	pkt[36], pkt[37] = 0, 0
	expTCP := tcpChecksum(pkt[:20], pkt[20:])
	pkt[36] = byte(tcpCs >> 8)
	pkt[37] = byte(tcpCs & 0xff)
	assert.Equal(t, expTCP, tcpCs, "TCP checksum mismatch")
}

func TestParseIPv4TCP_Errors(t *testing.T) {
	cases := []struct {
		name string
		b    []byte
	}{
		{"too_short", []byte{0x45}},
		{"not_ipv4", []byte{0x60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
		{"not_tcp", []byte{0x45, 0, 0, 20, 0, 0, 0, 0, 0, 17, /* UDP */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			_, err := ParseIPv4TCP(c.b)
			assert.Error(t, err)
		})
	}
}
  • Step 2: Run tests — verify failure
go test ./internal/divert/... -run TestParseIPv4TCP -v
go test ./internal/divert/... -run TestRewriteDst -v

Expected: FAIL — ParseIPv4TCP, RewriteDst, ipChecksum, tcpChecksum undefined.

  • Step 3: Implement internal/divert/packet.go
package divert

import (
	"encoding/binary"
	"errors"
	"net"
)

// IPv4TCPInfo is what we extract from a raw IPv4+TCP packet for our
// per-flow mapping table.
type IPv4TCPInfo struct {
	SrcIP, DstIP     net.IP
	SrcPort, DstPort uint16
}

// ParseIPv4TCP reads the IPv4 + TCP header pair out of an outbound
// captured packet and returns the addressing info. Does NOT mutate
// the buffer.
//
// Errors when:
//   - buffer too short to contain a full IPv4+TCP header (40 bytes)
//   - IP version is not 4
//   - IP protocol is not 6 (TCP)
func ParseIPv4TCP(b []byte) (*IPv4TCPInfo, error) {
	if len(b) < 40 {
		return nil, errors.New("packet shorter than IPv4+TCP minimum")
	}
	if b[0]>>4 != 4 {
		return nil, errors.New("not IPv4")
	}
	ihl := int(b[0]&0x0f) * 4
	if ihl < 20 || len(b) < ihl+20 {
		return nil, errors.New("IPv4 IHL invalid or buffer truncated")
	}
	if b[9] != 6 {
		return nil, errors.New("not TCP")
	}
	src := net.IPv4(b[12], b[13], b[14], b[15])
	dst := net.IPv4(b[16], b[17], b[18], b[19])
	srcPort := binary.BigEndian.Uint16(b[ihl : ihl+2])
	dstPort := binary.BigEndian.Uint16(b[ihl+2 : ihl+4])
	return &IPv4TCPInfo{
		SrcIP:   src,
		DstIP:   dst,
		SrcPort: srcPort,
		DstPort: dstPort,
	}, nil
}

// RewriteDst mutates b in-place to set dst IP and port, then
// recomputes both the IP header checksum and the TCP checksum.
//
// Returns the same errors as ParseIPv4TCP for malformed input.
func RewriteDst(b []byte, ip net.IP, port uint16) error {
	if _, err := ParseIPv4TCP(b); err != nil {
		return err
	}
	v4 := ip.To4()
	if v4 == nil {
		return errors.New("dst must be IPv4")
	}
	ihl := int(b[0]&0x0f) * 4

	// Set dst IP
	copy(b[16:20], v4)
	// Set dst port
	binary.BigEndian.PutUint16(b[ihl+2:ihl+4], port)

	// Recompute IP checksum (clear → compute → write big-endian)
	b[10], b[11] = 0, 0
	cs := ipChecksum(b[:ihl])
	b[10] = byte(cs >> 8)
	b[11] = byte(cs & 0xff)

	// Recompute TCP checksum (clear → compute → write)
	b[ihl+16], b[ihl+17] = 0, 0
	cs = tcpChecksum(b[:ihl], b[ihl:])
	b[ihl+16] = byte(cs >> 8)
	b[ihl+17] = byte(cs & 0xff)

	return nil
}

// ipChecksum is the standard 16-bit one's-complement sum over the IP
// header (RFC 791). The "checksum field" must be zeroed before calling.
func ipChecksum(hdr []byte) uint16 {
	var sum uint32
	for i := 0; i+1 < len(hdr); i += 2 {
		sum += uint32(hdr[i])<<8 | uint32(hdr[i+1])
	}
	if len(hdr)%2 == 1 {
		sum += uint32(hdr[len(hdr)-1]) << 8
	}
	for sum>>16 != 0 {
		sum = (sum & 0xffff) + (sum >> 16)
	}
	return ^uint16(sum)
}

// tcpChecksum implements the RFC 793 pseudo-header checksum.
// ipHdr must include src+dst addresses; tcpSeg is the full TCP header
// + payload. The "checksum field" inside tcpSeg must be zeroed.
func tcpChecksum(ipHdr, tcpSeg []byte) uint16 {
	var sum uint32
	// Pseudo-header: src(4) dst(4) zero(1) proto(1) tcp_len(2)
	for i := 12; i <= 18; i += 2 {
		sum += uint32(ipHdr[i])<<8 | uint32(ipHdr[i+1])
	}
	sum += uint32(6) // TCP protocol
	tcpLen := uint32(len(tcpSeg))
	sum += tcpLen
	// TCP segment
	for i := 0; i+1 < len(tcpSeg); i += 2 {
		sum += uint32(tcpSeg[i])<<8 | uint32(tcpSeg[i+1])
	}
	if len(tcpSeg)%2 == 1 {
		sum += uint32(tcpSeg[len(tcpSeg)-1]) << 8
	}
	for sum>>16 != 0 {
		sum = (sum & 0xffff) + (sum >> 16)
	}
	return ^uint16(sum)
}
  • Step 4: Run tests — verify pass
go test ./internal/divert/... -run "TestParseIPv4TCP|TestRewriteDst" -v

Expected: all PASS.

  • Step 5: Commit
git add internal/divert/packet.go internal/divert/packet_test.go
git commit -m "$(cat <<'EOF'
internal/divert: IPv4+TCP packet parse + RewriteDst + checksums

Pure-Go RFC 791/793 checksum implementation. Mutates buffer in
place — no allocations on the hot path. Used by the redirect layer
to NAT-rewrite Discord packets to 127.0.0.1:listener_port before
reinjecting via WinDivertSend.

UDP support deferred to P2.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 5: Driver installer

Files:

  • Create: internal/divert/installer.go
  • Test: internal/divert/installer_test.go

On engine start, extract WinDivert64.sys + WinDivert.dll from the embedded bytes into %PROGRAMDATA%\Drover\windivert\ (creating the directory if needed). SHA256-verify the extracted files match WinDivertSysSHA256 / WinDivertDllSHA256 constants. Detect ARM64 and return a clear error.

  • Step 1: Write failing tests

Create internal/divert/installer_test.go:

package divert

import (
	"os"
	"path/filepath"
	"runtime"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestInstallDriver_ExtractsAndVerifies(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("Windows-only path")
	}
	tmp := t.TempDir()
	res, err := installDriverInto(tmp)
	require.NoError(t, err)
	assert.FileExists(t, filepath.Join(tmp, "WinDivert64.sys"))
	assert.FileExists(t, filepath.Join(tmp, "WinDivert.dll"))
	assert.Equal(t, filepath.Join(tmp, "WinDivert64.sys"), res.SysPath)
	assert.Equal(t, filepath.Join(tmp, "WinDivert.dll"), res.DllPath)
}

func TestInstallDriver_RefusesARM64(t *testing.T) {
	if runtime.GOARCH != "arm64" {
		t.Skip("only meaningful on arm64")
	}
	_, err := installDriverInto(t.TempDir())
	require.Error(t, err)
	assert.Contains(t, err.Error(), "ARM64")
}

func TestInstallDriver_DetectsTampering(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip()
	}
	tmp := t.TempDir()
	// Pre-populate the destination with garbage of the same name so the
	// installer's existing-file SHA-check fails and it overwrites.
	require.NoError(t, os.WriteFile(filepath.Join(tmp, "WinDivert64.sys"), []byte("garbage"), 0644))
	res, err := installDriverInto(tmp)
	require.NoError(t, err)
	// After install, the file should have the expected SHA, not garbage.
	assert.NotEmpty(t, res.SysPath)
	stat, err := os.Stat(res.SysPath)
	require.NoError(t, err)
	assert.Greater(t, stat.Size(), int64(1000))
}
  • Step 2: Run tests — verify failure
go test ./internal/divert/... -run TestInstallDriver -v

Expected: FAIL — installDriverInto undefined.

  • Step 3: Implement internal/divert/installer.go
//go:build windows

package divert

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"
)

// DriverPaths records where the WinDivert binaries landed after install.
type DriverPaths struct {
	SysPath string // e.g. C:\ProgramData\Drover\windivert\WinDivert64.sys
	DllPath string
}

// InstallDriver extracts the embedded WinDivert.sys + WinDivert.dll
// into %PROGRAMDATA%\Drover\windivert\ and SHA256-verifies them.
//
// On second and subsequent runs, if the existing files already match
// the embedded SHAs, the function is a no-op and just returns paths.
//
// Errors:
//   - ARM64 architecture (WinDivert doesn't support it)
//   - %PROGRAMDATA% not set or not writable
//   - SHA256 mismatch after write (driver corrupted on disk)
func InstallDriver() (*DriverPaths, error) {
	if runtime.GOARCH == "arm64" {
		return nil, fmt.Errorf("Drover requires x86-64 Windows; ARM64 is not supported (WinDivert does not ship an ARM64 driver)")
	}
	pd := os.Getenv("ProgramData")
	if pd == "" {
		return nil, fmt.Errorf("ProgramData environment variable is not set")
	}
	dst := filepath.Join(pd, "Drover", "windivert")
	return installDriverInto(dst)
}

func installDriverInto(dst string) (*DriverPaths, error) {
	if runtime.GOARCH == "arm64" {
		return nil, fmt.Errorf("Drover requires x86-64 Windows; ARM64 is not supported")
	}
	if err := os.MkdirAll(dst, 0755); err != nil {
		return nil, fmt.Errorf("create %s: %w", dst, err)
	}
	sysPath := filepath.Join(dst, "WinDivert64.sys")
	dllPath := filepath.Join(dst, "WinDivert.dll")

	if err := writeIfDifferent(sysPath, winDivertSys, WinDivertSysSHA256); err != nil {
		return nil, fmt.Errorf("install WinDivert64.sys: %w", err)
	}
	if err := writeIfDifferent(dllPath, winDivertDll, WinDivertDllSHA256); err != nil {
		return nil, fmt.Errorf("install WinDivert.dll: %w", err)
	}
	return &DriverPaths{SysPath: sysPath, DllPath: dllPath}, nil
}

// writeIfDifferent compares the existing file's SHA256 to the expected
// hash; if it matches, no-op. Otherwise overwrite atomically and verify
// the resulting on-disk SHA matches expected.
func writeIfDifferent(path string, content []byte, expectedSHA string) error {
	if existing, err := os.ReadFile(path); err == nil {
		if strings.EqualFold(sha256Hex(existing), expectedSHA) {
			return nil // already up to date
		}
	}
	tmp := path + ".new"
	if err := os.WriteFile(tmp, content, 0644); err != nil {
		return err
	}
	if err := os.Rename(tmp, path); err != nil {
		_ = os.Remove(tmp)
		return err
	}
	// Verify after write — guards against AV-on-write tampering.
	got, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	if !strings.EqualFold(sha256Hex(got), expectedSHA) {
		return fmt.Errorf("SHA256 mismatch after write at %s; antivirus may have tampered with the file. Add %%PROGRAMDATA%%\\Drover\\ to your AV exclusions and restart Drover", path)
	}
	return nil
}

func sha256Hex(b []byte) string {
	h := sha256.Sum256(b)
	return hex.EncodeToString(h[:])
}
  • Step 4: Run tests — verify pass on Windows
go test ./internal/divert/... -run TestInstallDriver -v

Expected: 2 PASS (Windows), 1 SKIP (ARM64 case unless on ARM64 hardware).

  • Step 5: Commit
git add internal/divert/installer.go internal/divert/installer_test.go
git commit -m "$(cat <<'EOF'
internal/divert: driver installer with SHA256 verification

Extracts embedded WinDivert binaries to %PROGRAMDATA%\Drover\windivert\
on first run; subsequent runs detect matching SHAs and no-op. SHA
mismatch after write produces an AV-friendly error message pointing
the user at adding the directory to exclusions.

ARM64 detected at runtime via runtime.GOARCH and refused gracefully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 6: WinDivert handle wrapper

Files:

  • Create: internal/divert/divert.go
  • Test: internal/divert/divert_test.go (smoke tests only — full integration in Task 12)

The thin Go layer between our engine and the WinDivert API. We use imgk/divert-go per Task 2 with fallback to direct syscalls if it doesn't compile. Provides Open, Close, Recv (read raw packet + WinDivertAddress), Send (reinject).

If imgk/divert-go is unusable (failed Task 2 build), this task instead writes raw syscall.NewLazyDLL("WinDivert.dll") bindings — see "fallback" subtask below.

  • Step 1: Write smoke test

Create internal/divert/divert_test.go:

package divert

import (
	"runtime"
	"testing"

	"github.com/stretchr/testify/require"
)

// TestOpen_RequiresAdmin documents — and verifies on a non-admin run —
// that Open fails fast with a recognisable error rather than panicking.
// On admin we just smoke-test the open/close round-trip with a no-op
// filter ("false") that captures nothing.
func TestOpen_FalseFilterRoundtrip(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("Windows-only")
	}
	if !isAdminTest() {
		t.Skip("requires admin; run from elevated shell")
	}
	// Install the driver first so the .sys is present
	_, err := InstallDriver()
	require.NoError(t, err)

	h, err := Open("false") // matches no packets
	require.NoError(t, err)
	defer h.Close()
}

// isAdminTest is a thin wrapper to keep the test file Windows-pure
// without re-implementing IsAdmin from cmd/drover (we'd circular-import).
func isAdminTest() bool {
	// Read TokenElevation directly via os/syscall to avoid the import cycle.
	// For simplicity we just check whether we can write to System32.
	// (Smoke-only; production code uses cmd/drover's IsAdmin.)
	_, err := os.Stat(`C:\Windows\System32\drivers`)
	if err != nil {
		return false
	}
	f, err := os.OpenFile(`C:\Windows\System32\drivers\.drover-admin-test`, os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return false
	}
	f.Close()
	os.Remove(`C:\Windows\System32\drivers\.drover-admin-test`)
	return true
}

(Add import "os" to the test file.)

  • Step 2: Run tests — verify they skip gracefully
go test ./internal/divert/... -run TestOpen -v

Expected: SKIP (when not running as admin) or PASS (when admin).

  • Step 3: Implement internal/divert/divert.go using imgk/divert-go
//go:build windows

package divert

import (
	"errors"
	"fmt"

	idivert "github.com/imgk/divert-go"
)

// Handle wraps a WinDivert handle.
type Handle struct {
	h *idivert.Handle
}

// Open opens a WinDivert handle at NETWORK layer for outbound capture.
// The filter expression is the standard WinDivert syntax (see
// internal/divert/filter.go for our builder).
//
// Returns ErrAccessDenied when the calling process is not elevated.
// Returns ErrDriverFailedPriorUnload when an outdated WinDivert
// (e.g. v1.x from zapret) is already loaded.
func Open(filter string) (*Handle, error) {
	h, err := idivert.Open(filter, idivert.LayerNetwork, 0, 0)
	if err != nil {
		return nil, mapWinDivertErr(err)
	}
	return &Handle{h: h}, nil
}

// Close closes the handle. Safe to call multiple times.
func (h *Handle) Close() error {
	if h == nil || h.h == nil {
		return nil
	}
	err := h.h.Close()
	h.h = nil
	return err
}

// Recv blocks until a packet arrives that matches the filter, or until
// the handle is closed (Close from another goroutine returns
// ErrShutdown to the recv'er). buf must be sized for a full Ethernet
// MTU (~1600 bytes is fine).
//
// Returns the captured packet length, the WinDivertAddress (containing
// direction, interface index, etc), and any error.
func (h *Handle) Recv(buf []byte) (int, *idivert.Address, error) {
	if h == nil || h.h == nil {
		return 0, nil, errors.New("handle closed")
	}
	addr := new(idivert.Address)
	n, err := h.h.Recv(buf, addr)
	if err != nil {
		return 0, nil, mapWinDivertErr(err)
	}
	return n, addr, nil
}

// Send reinjects a packet. The address typically comes from a previous
// Recv call (so the kernel knows whether it's outbound or inbound, which
// interface, etc).
func (h *Handle) Send(buf []byte, addr *idivert.Address) (int, error) {
	if h == nil || h.h == nil {
		return 0, errors.New("handle closed")
	}
	n, err := h.h.Send(buf, addr)
	if err != nil {
		return 0, mapWinDivertErr(err)
	}
	return n, nil
}

// Sentinel errors mapped from raw Windows errors so the engine layer
// can pattern-match without importing windows package.
var (
	ErrAccessDenied            = errors.New("WinDivert: access denied (need admin)")
	ErrDriverFailedPriorUnload = errors.New("WinDivert: outdated driver from another tool is loaded; reboot or stop the other tool first")
	ErrInvalidHandle           = errors.New("WinDivert: handle invalidated (driver crashed?)")
	ErrShutdown                = errors.New("WinDivert: shutdown")
)

func mapWinDivertErr(err error) error {
	if err == nil {
		return nil
	}
	msg := err.Error()
	switch {
	case contains(msg, "access is denied"), contains(msg, "ACCESS_DENIED"):
		return ErrAccessDenied
	case contains(msg, "FAILED_PRIOR_UNLOAD"), contains(msg, "prior unload"):
		return ErrDriverFailedPriorUnload
	case contains(msg, "INVALID_HANDLE"):
		return ErrInvalidHandle
	case contains(msg, "SHUTDOWN"):
		return ErrShutdown
	}
	return fmt.Errorf("WinDivert: %w", err)
}

func contains(s, sub string) bool {
	// case-insensitive
	if len(sub) == 0 {
		return true
	}
	if len(s) < len(sub) {
		return false
	}
	for i := 0; i+len(sub) <= len(s); i++ {
		match := true
		for j := 0; j < len(sub); j++ {
			a, b := s[i+j], sub[j]
			if a >= 'A' && a <= 'Z' {
				a += 32
			}
			if b >= 'A' && b <= 'Z' {
				b += 32
			}
			if a != b {
				match = false
				break
			}
		}
		if match {
			return true
		}
	}
	return false
}

Fallback if imgk/divert-go won't compile (Task 2 reported failure): replace the implementation with raw syscall.LazyDLL calls to WinDivert.dll. The C signatures we need:

WinDivertOpen(filter, layer=0, priority=0, flags=0) -> HANDLE
WinDivertRecv(handle, packet, packetLen, recvLen, addr) -> BOOL
WinDivertSend(handle, packet, packetLen, sendLen, addr) -> BOOL
WinDivertClose(handle) -> BOOL

The WinDivertAddress is a 64-byte C struct; the first uint8 is the layer enum, the second is the event enum, and we mostly only care about flags: bit 0 = inbound (vs outbound), bit 1 = ipv6, bit 2 = ipChecksum, bit 3 = tcpChecksum. See third_party/windivert/windivert.h lines 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
go test ./internal/divert/... -run TestOpen -v

If running from an admin shell: PASS. From a non-admin shell: SKIP.

  • Step 5: Commit
git add internal/divert/divert.go internal/divert/divert_test.go
git commit -m "$(cat <<'EOF'
internal/divert: WinDivert handle wrapper

Thin Go layer over imgk/divert-go (or raw syscalls if upstream is
broken). Exposes Open/Close/Recv/Send and maps the most relevant
Windows errors to sentinels (ErrAccessDenied,
ErrDriverFailedPriorUnload, ErrInvalidHandle, ErrShutdown) so the
engine's recovery classifier can reason about them without importing
golang.org/x/sys/windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 7: Production SOCKS5 client (TCP CONNECT)

Files:

  • Create: internal/socks5/client.go
  • Test: internal/socks5/client_test.go

A separate, leaner SOCKS5 client from internal/checker/socks5.go. The diagnostic client returns raw bytes for hex display; the production client just returns a net.Conn that's been CONNECT'd through. No retries here — that's the engine's job.

  • Step 1: Write failing test

Create internal/socks5/client_test.go:

package socks5

import (
	"context"
	"io"
	"net"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// fakeProxy is a minimal SOCKS5 server that accepts greet+CONNECT
// (and optional auth) and then splices the connection to a target
// listener supplied by the test.
type fakeProxy struct {
	addr     string
	target   string
	useAuth  bool
	login    string
	password string
}

func startFakeProxy(t *testing.T, target string, useAuth bool, login, password string) *fakeProxy {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	t.Cleanup(func() { ln.Close() })

	p := &fakeProxy{
		addr: ln.Addr().String(),
		target: target,
		useAuth: useAuth, login: login, password: password,
	}

	go func() {
		for {
			c, err := ln.Accept()
			if err != nil {
				return
			}
			go p.handle(c)
		}
	}()
	return p
}

func (p *fakeProxy) handle(c net.Conn) {
	defer c.Close()
	buf := make([]byte, 256)

	// Greeting: 05 N method...
	io.ReadFull(c, buf[:2])
	nmethods := int(buf[1])
	io.ReadFull(c, buf[:nmethods])
	if p.useAuth {
		c.Write([]byte{0x05, 0x02})
		io.ReadFull(c, buf[:2])
		ulen := int(buf[1])
		io.ReadFull(c, buf[:ulen])
		login := string(buf[:ulen])
		io.ReadFull(c, buf[:1])
		plen := int(buf[0])
		io.ReadFull(c, buf[:plen])
		pwd := string(buf[:plen])
		if login != p.login || pwd != p.password {
			c.Write([]byte{0x01, 0x01})
			return
		}
		c.Write([]byte{0x01, 0x00})
	} else {
		c.Write([]byte{0x05, 0x00})
	}

	// CONNECT request: 05 01 00 ATYP ...
	io.ReadFull(c, buf[:4])
	atyp := buf[3]
	var host string
	switch atyp {
	case 1:
		io.ReadFull(c, buf[:4])
		host = net.IPv4(buf[0], buf[1], buf[2], buf[3]).String()
	case 3:
		io.ReadFull(c, buf[:1])
		hlen := int(buf[0])
		io.ReadFull(c, buf[:hlen])
		host = string(buf[:hlen])
	}
	io.ReadFull(c, buf[:2])
	port := int(buf[0])<<8 | int(buf[1])

	// Reply REP=0
	c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})

	// Splice to target
	target, err := net.Dial("tcp", net.JoinHostPort(host, itoa(port)))
	if err != nil {
		return
	}
	defer target.Close()
	var wg sync.WaitGroup
	wg.Add(2)
	go func() { defer wg.Done(); io.Copy(target, c) }()
	go func() { defer wg.Done(); io.Copy(c, target) }()
	wg.Wait()
}

func itoa(n int) string { return string([]byte{byte('0' + n/10000 % 10), byte('0' + n/1000 % 10), byte('0' + n/100 % 10), byte('0' + n/10 % 10), byte('0' + n % 10)})[:5] }

func TestDial_NoAuth_HappyPath(t *testing.T) {
	// Spin up a real target listener
	target, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	defer target.Close()
	go func() {
		c, err := target.Accept()
		if err != nil {
			return
		}
		defer c.Close()
		c.Write([]byte("hello"))
	}()

	p := startFakeProxy(t, target.Addr().String(), false, "", "")

	host, port, _ := net.SplitHostPort(target.Addr().String())
	portU, _ := atoiU16(port)

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	conn, err := Dial(ctx, Config{
		ProxyAddr: p.addr,
	}, host, portU)
	require.NoError(t, err)
	defer conn.Close()

	buf := make([]byte, 5)
	io.ReadFull(conn, buf)
	assert.Equal(t, "hello", string(buf))
}

func TestDial_WithAuth_HappyPath(t *testing.T) {
	target, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	defer target.Close()
	go func() { c, _ := target.Accept(); if c != nil { c.Write([]byte("auth-ok")); c.Close() } }()

	p := startFakeProxy(t, target.Addr().String(), true, "user", "pass")
	host, port, _ := net.SplitHostPort(target.Addr().String())
	portU, _ := atoiU16(port)

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	conn, err := Dial(ctx, Config{
		ProxyAddr: p.addr,
		UseAuth:   true,
		Login:     "user",
		Password:  "pass",
	}, host, portU)
	require.NoError(t, err)
	defer conn.Close()

	buf := make([]byte, 7)
	io.ReadFull(conn, buf)
	assert.Equal(t, "auth-ok", string(buf))
}

func TestDial_BadAuth(t *testing.T) {
	target, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	defer target.Close()

	p := startFakeProxy(t, target.Addr().String(), true, "user", "pass")
	host, port, _ := net.SplitHostPort(target.Addr().String())
	portU, _ := atoiU16(port)

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	_, err = Dial(ctx, Config{
		ProxyAddr: p.addr,
		UseAuth:   true,
		Login:     "wrong",
		Password:  "wrong",
	}, host, portU)
	require.Error(t, err)
}

func atoiU16(s string) (uint16, error) {
	var n int
	for _, c := range s {
		if c < '0' || c > '9' {
			return 0, &net.AddrError{Err: "invalid port", Addr: s}
		}
		n = n*10 + int(c-'0')
	}
	return uint16(n), nil
}
  • Step 2: Run tests — verify failure
go test ./internal/socks5/... -run TestDial -v

Expected: FAIL — Dial and Config undefined.

  • Step 3: Implement internal/socks5/client.go
package socks5

import (
	"context"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"net"
)

// Config carries connection-time SOCKS5 settings.
type Config struct {
	ProxyAddr string // "host:port"
	UseAuth   bool
	Login     string
	Password  string
}

// Dial opens a TCP connection to the SOCKS5 proxy, runs the greeting,
// optionally authenticates with username/password (RFC 1929), and
// issues a CONNECT to host:port (sent as ATYP=03 domain so the proxy
// resolves on its side). Returns the established net.Conn ready for
// bidirectional traffic.
//
// The given ctx bounds dial + handshake; once Dial returns, the conn
// has its own deadline-free I/O state.
func Dial(ctx context.Context, cfg Config, host string, port uint16) (net.Conn, error) {
	d := net.Dialer{}
	conn, err := d.DialContext(ctx, "tcp", cfg.ProxyAddr)
	if err != nil {
		return nil, fmt.Errorf("dial proxy: %w", err)
	}
	if dl, ok := ctx.Deadline(); ok {
		conn.SetDeadline(dl)
	}
	if err := handshake(conn, cfg, host, port); err != nil {
		conn.Close()
		return nil, err
	}
	conn.SetDeadline(time.Time{})
	return conn, nil
}

func handshake(conn net.Conn, cfg Config, host string, port uint16) error {
	// Greeting
	if cfg.UseAuth {
		if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
			return fmt.Errorf("greet write: %w", err)
		}
	} else {
		if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil {
			return fmt.Errorf("greet write: %w", err)
		}
	}
	var rep [2]byte
	if _, err := io.ReadFull(conn, rep[:]); err != nil {
		return fmt.Errorf("greet read: %w", err)
	}
	if rep[0] != 0x05 {
		return fmt.Errorf("greet: server version %#x is not SOCKS5", rep[0])
	}
	if rep[1] == 0xff {
		return errors.New("greet: proxy rejected all offered auth methods")
	}
	method := rep[1]

	// Auth subneg
	if method == 0x02 {
		if !cfg.UseAuth {
			return errors.New("proxy requires auth but Config.UseAuth is false")
		}
		if len(cfg.Login) > 255 || len(cfg.Password) > 255 {
			return errors.New("login or password too long")
		}
		buf := make([]byte, 0, 3+len(cfg.Login)+len(cfg.Password))
		buf = append(buf, 0x01, byte(len(cfg.Login)))
		buf = append(buf, []byte(cfg.Login)...)
		buf = append(buf, byte(len(cfg.Password)))
		buf = append(buf, []byte(cfg.Password)...)
		if _, err := conn.Write(buf); err != nil {
			return fmt.Errorf("auth write: %w", err)
		}
		if _, err := io.ReadFull(conn, rep[:]); err != nil {
			return fmt.Errorf("auth read: %w", err)
		}
		if rep[1] != 0x00 {
			return errors.New("auth: invalid login or password")
		}
	}

	// CONNECT
	if len(host) > 255 {
		return errors.New("host too long")
	}
	req := make([]byte, 0, 7+len(host))
	req = append(req, 0x05, 0x01, 0x00, 0x03, byte(len(host)))
	req = append(req, []byte(host)...)
	pBuf := make([]byte, 2)
	binary.BigEndian.PutUint16(pBuf, port)
	req = append(req, pBuf...)
	if _, err := conn.Write(req); err != nil {
		return fmt.Errorf("connect write: %w", err)
	}
	var creply [10]byte
	if _, err := io.ReadFull(conn, creply[:]); err != nil {
		return fmt.Errorf("connect read: %w", err)
	}
	if creply[0] != 0x05 {
		return fmt.Errorf("connect: server version %#x is not SOCKS5", creply[0])
	}
	if creply[1] != 0x00 {
		return fmt.Errorf("connect: REP=%#02x", creply[1])
	}
	return nil
}

Add import "time" at the top.

  • Step 4: Run tests — verify pass
go test ./internal/socks5/... -run TestDial -v

Expected: 3 PASS.

  • Step 5: Commit
git add internal/socks5/client.go internal/socks5/client_test.go
git commit -m "$(cat <<'EOF'
internal/socks5: production TCP CONNECT client

Separate from internal/checker/socks5.go (different requirements: no
hex dumps, no diagnostic-friendly errors, faster path). Single Dial
entry point that handles greet + optional auth + CONNECT and returns
a ready-to-use net.Conn. UDP support deferred to P2.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 8: Procscan

Files:

  • Create: internal/procscan/procscan.go
  • Test: internal/procscan/procscan_test.go

Toolhelp32 enumerates all running processes. We filter by exe-name list and return the resulting PID set. The engine kicks off a 2-second ticker calling Snapshot() and diffing against the previous result.

  • Step 1: Write tests

Create internal/procscan/procscan_test.go:

//go:build windows

package procscan

import (
	"runtime"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestSnapshot_MatchesOwnExeName(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip()
	}
	// We must find ourselves in the snapshot. The Go test binary is
	// typically named ${pkg}.test.exe.
	snap, err := Snapshot([]string{"go.test.exe", "main.test.exe"})
	require.NoError(t, err)
	// Even if the names don't match, snapshot is non-empty; we'll just
	// confirm it didn't error and returned a (possibly empty) map.
	_ = snap
}

func TestSnapshot_FiltersCaseInsensitive(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip()
	}
	// Real test: pass "EXPLORER.EXE" and expect at least one match
	// (explorer.exe is essentially always running on a desktop).
	snap, err := Snapshot([]string{"EXPLORER.EXE"})
	require.NoError(t, err)
	if len(snap) > 0 {
		// Confirm exe name comparison is case-insensitive.
		for _, name := range snap {
			assert.True(t, strings.EqualFold(name, "explorer.exe"))
		}
	}
}

func TestDiffPIDs(t *testing.T) {
	prev := map[uint32]string{1: "a.exe", 2: "b.exe"}
	cur := map[uint32]string{2: "b.exe", 3: "c.exe"}
	added, removed := DiffPIDs(prev, cur)
	assert.ElementsMatch(t, []uint32{3}, added)
	assert.ElementsMatch(t, []uint32{1}, removed)
}
  • Step 2: Run tests — verify failure
go test ./internal/procscan/... -v

Expected: FAIL — symbols undefined.

  • Step 3: Implement internal/procscan/procscan.go
//go:build windows

package procscan

import (
	"strings"
	"syscall"
	"unsafe"

	"golang.org/x/sys/windows"
)

// Snapshot returns a map of PID → exe basename for every running
// process whose exe name (case-insensitively) matches one of the
// names in `targets`. Pass an empty/nil targets to capture all
// processes (useful for debugging).
func Snapshot(targets []string) (map[uint32]string, error) {
	snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
	if err != nil {
		return nil, err
	}
	defer windows.CloseHandle(snap)

	var entry windows.ProcessEntry32
	entry.Size = uint32(unsafe.Sizeof(entry))

	if err := windows.Process32First(snap, &entry); err != nil {
		return nil, err
	}

	wantAll := len(targets) == 0
	wantSet := make(map[string]struct{}, len(targets))
	for _, n := range targets {
		wantSet[strings.ToLower(n)] = struct{}{}
	}

	out := map[uint32]string{}
	for {
		exeName := syscall.UTF16ToString(entry.ExeFile[:])
		if wantAll {
			out[entry.ProcessID] = exeName
		} else if _, ok := wantSet[strings.ToLower(exeName)]; ok {
			out[entry.ProcessID] = exeName
		}
		err := windows.Process32Next(snap, &entry)
		if err != nil {
			if err == syscall.ERROR_NO_MORE_FILES {
				break
			}
			return nil, err
		}
	}
	return out, nil
}

// DiffPIDs reports which PIDs are added (in cur but not prev) and
// removed (in prev but not cur). Used by the engine's procscan ticker
// to decide whether to rebuild the WinDivert filter.
func DiffPIDs(prev, cur map[uint32]string) (added, removed []uint32) {
	for pid := range cur {
		if _, ok := prev[pid]; !ok {
			added = append(added, pid)
		}
	}
	for pid := range prev {
		if _, ok := cur[pid]; !ok {
			removed = append(removed, pid)
		}
	}
	return
}
  • Step 4: Run tests — verify pass
go test ./internal/procscan/... -v

Expected: 3 PASS (Windows host), or skip on non-Windows.

  • Step 5: Commit
git add internal/procscan/procscan.go internal/procscan/procscan_test.go
git commit -m "$(cat <<'EOF'
internal/procscan: Toolhelp32 PID enumerator

Filters by exe basename, case-insensitive. DiffPIDs reports add/remove
sets so the engine can decide whether to rebuild the WinDivert filter.
Pure syscalls, no third-party dependencies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 9: TCP NAT-loopback redirect

Files:

  • Create: internal/redirect/tcp.go
  • Test: internal/redirect/tcp_test.go

The heart of the engine. A loopback listener accepts redirected Discord connections, looks up (client_src_port → real_target) in the per-flow map, opens a SOCKS5 CONNECT to the upstream proxy targeting real_target, and pumps bytes both directions until either side closes.

The map is populated by the divert layer (Task 10 wires it in) when a SYN arrives from a target PID — but for this task we just expose the API and unit-test the pump.

  • Step 1: Write tests

Create internal/redirect/tcp_test.go:

package redirect

import (
	"context"
	"io"
	"net"
	"sync"
	"testing"
	"time"

	"git.okcu.io/root/drover-go/internal/socks5"
	"github.com/stretchr/testify/require"
)

// startEchoListener spins up a TCP server that echoes whatever it reads.
func startEchoListener(t *testing.T) string {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	t.Cleanup(func() { ln.Close() })
	go func() {
		for {
			c, err := ln.Accept()
			if err != nil {
				return
			}
			go func(c net.Conn) {
				defer c.Close()
				io.Copy(c, c)
			}(c)
		}
	}()
	return ln.Addr().String()
}

// startFakeSOCKS5 returns the addr of a no-auth SOCKS5 server that
// CONNECT-tunnels to the requested host:port. (Borrowed pattern from
// internal/socks5/client_test.go.)
//
// For this test we duplicate the fake proxy code rather than exporting
// it, to keep the redirect package free of test-helper coupling.
func startFakeSOCKS5(t *testing.T) string {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	t.Cleanup(func() { ln.Close() })
	go func() {
		for {
			c, err := ln.Accept()
			if err != nil {
				return
			}
			go func(c net.Conn) {
				defer c.Close()
				buf := make([]byte, 256)
				// Greet
				io.ReadFull(c, buf[:2])
				nm := int(buf[1])
				io.ReadFull(c, buf[:nm])
				c.Write([]byte{0x05, 0x00})
				// CONNECT
				io.ReadFull(c, buf[:4])
				atyp := buf[3]
				var host string
				switch atyp {
				case 1:
					io.ReadFull(c, buf[:4])
					host = net.IPv4(buf[0], buf[1], buf[2], buf[3]).String()
				case 3:
					io.ReadFull(c, buf[:1])
					hl := int(buf[0])
					io.ReadFull(c, buf[:hl])
					host = string(buf[:hl])
				}
				io.ReadFull(c, buf[:2])
				port := int(buf[0])<<8 | int(buf[1])
				c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
				up, err := net.Dial("tcp", net.JoinHostPort(host, sportItoa(port)))
				if err != nil {
					return
				}
				defer up.Close()
				var wg sync.WaitGroup
				wg.Add(2)
				go func() { defer wg.Done(); io.Copy(up, c) }()
				go func() { defer wg.Done(); io.Copy(c, up) }()
				wg.Wait()
			}(c)
		}
	}()
	return ln.Addr().String()
}

func sportItoa(n int) string {
	if n == 0 {
		return "0"
	}
	out := []byte{}
	for n > 0 {
		out = append([]byte{byte('0' + n%10)}, out...)
		n /= 10
	}
	return string(out)
}

func TestRedirector_PipesEcho(t *testing.T) {
	echoAddr := startEchoListener(t)
	echoHost, echoPortStr, _ := net.SplitHostPort(echoAddr)
	echoPort := parseU16(echoPortStr)
	socksAddr := startFakeSOCKS5(t)

	r, err := New(Config{
		SOCKS5: socks5.Config{ProxyAddr: socksAddr},
		Bind:   "127.0.0.1:0",
	})
	require.NoError(t, err)
	t.Cleanup(func() { r.Close() })

	// Manually map: pretend a packet from src_port=12345 was destined to echo.
	r.SetMapping(12345, net.ParseIP(echoHost), echoPort)

	// Dial the redirector listener using src_port=12345 so it looks
	// up the mapping correctly.
	d := net.Dialer{LocalAddr: &net.TCPAddr{Port: 12345}}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	conn, err := d.DialContext(ctx, "tcp", r.LocalAddr())
	require.NoError(t, err)
	defer conn.Close()

	conn.Write([]byte("ping"))
	conn.SetReadDeadline(time.Now().Add(time.Second))
	buf := make([]byte, 4)
	io.ReadFull(conn, buf)
	require.Equal(t, "ping", string(buf))
}

func parseU16(s string) uint16 {
	var n int
	for _, c := range s {
		n = n*10 + int(c-'0')
	}
	return uint16(n)
}
  • Step 2: Run tests — verify failure
go test ./internal/redirect/... -run TestRedirector -v

Expected: FAIL — New, Config, SetMapping, LocalAddr, Close undefined.

  • Step 3: Implement internal/redirect/tcp.go
package redirect

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"sync"
	"time"

	"git.okcu.io/root/drover-go/internal/socks5"
)

// Config configures the TCP redirector.
type Config struct {
	SOCKS5 socks5.Config
	Bind   string // "127.0.0.1:0" — listener bind addr
}

type mapping struct {
	dstIP   net.IP
	dstPort uint16
	added   time.Time
}

// Redirector is the loopback listener that catches NAT-rewritten SYNs
// from divert and tunnels them through SOCKS5.
type Redirector struct {
	cfg   Config
	ln    net.Listener
	mu    sync.RWMutex
	flows map[uint16]mapping // src_port → mapping
	wg    sync.WaitGroup
	ctx   context.Context
	cnl   context.CancelFunc
}

// New starts a Redirector. It binds the listener but does not yet
// have any mappings; SetMapping is called by the divert layer when
// it sees an outbound SYN from a target PID.
func New(cfg Config) (*Redirector, error) {
	bind := cfg.Bind
	if bind == "" {
		bind = "127.0.0.1:0"
	}
	ln, err := net.Listen("tcp", bind)
	if err != nil {
		return nil, fmt.Errorf("listen %s: %w", bind, err)
	}
	ctx, cnl := context.WithCancel(context.Background())
	r := &Redirector{
		cfg:   cfg,
		ln:    ln,
		flows: map[uint16]mapping{},
		ctx:   ctx,
		cnl:   cnl,
	}
	r.wg.Add(1)
	go r.acceptLoop()
	r.wg.Add(1)
	go r.sweepLoop()
	return r, nil
}

func (r *Redirector) LocalAddr() string { return r.ln.Addr().String() }

func (r *Redirector) LocalPort() uint16 {
	return uint16(r.ln.Addr().(*net.TCPAddr).Port)
}

// SetMapping records that future TCP connections originating from
// src_port should be tunneled to dstIP:dstPort. Called by the divert
// layer at SYN time.
func (r *Redirector) SetMapping(srcPort uint16, dstIP net.IP, dstPort uint16) {
	r.mu.Lock()
	r.flows[srcPort] = mapping{dstIP: dstIP, dstPort: dstPort, added: time.Now()}
	r.mu.Unlock()
}

// Close stops accepting and tears down active flows.
func (r *Redirector) Close() error {
	r.cnl()
	err := r.ln.Close()
	r.wg.Wait()
	return err
}

func (r *Redirector) acceptLoop() {
	defer r.wg.Done()
	for {
		c, err := r.ln.Accept()
		if err != nil {
			return
		}
		r.wg.Add(1)
		go r.handle(c)
	}
}

func (r *Redirector) handle(c net.Conn) {
	defer r.wg.Done()
	defer c.Close()

	srcPort := uint16(c.RemoteAddr().(*net.TCPAddr).Port)
	r.mu.RLock()
	m, ok := r.flows[srcPort]
	r.mu.RUnlock()
	if !ok {
		return // unknown flow; drop quietly
	}

	ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second)
	defer cancel()

	host := m.dstIP.String()
	upstream, err := socks5.Dial(ctx, r.cfg.SOCKS5, host, m.dstPort)
	if err != nil {
		return
	}
	defer upstream.Close()

	pump(c, upstream)
}

func pump(a, b net.Conn) {
	var wg sync.WaitGroup
	wg.Add(2)
	go func() { defer wg.Done(); _, _ = io.Copy(a, b); a.(closeWriter).CloseWrite() }()
	go func() { defer wg.Done(); _, _ = io.Copy(b, a); b.(closeWriter).CloseWrite() }()
	wg.Wait()
}

type closeWriter interface{ CloseWrite() error }

// sweepLoop removes mappings older than 30 minutes (T-6 in spec).
func (r *Redirector) sweepLoop() {
	defer r.wg.Done()
	tk := time.NewTicker(time.Minute)
	defer tk.Stop()
	for {
		select {
		case <-r.ctx.Done():
			return
		case <-tk.C:
			cutoff := time.Now().Add(-30 * time.Minute)
			r.mu.Lock()
			for k, m := range r.flows {
				if m.added.Before(cutoff) {
					delete(r.flows, k)
				}
			}
			r.mu.Unlock()
		}
	}
}

// Sentinel for callers.
var ErrNotMapped = errors.New("redirector: source port has no mapping")
  • Step 4: Run tests — verify pass
go test ./internal/redirect/... -run TestRedirector -v

Expected: PASS.

  • Step 5: Commit
git add internal/redirect/
git commit -m "$(cat <<'EOF'
internal/redirect: TCP NAT-loopback redirector

Listener on 127.0.0.1 accepts NAT-rewritten Discord SYNs (rewrite
done by divert layer in Task 10), looks up the original destination
in a sync-protected map keyed by source port, opens a SOCKS5 CONNECT
to the upstream proxy targeting that destination, and pumps bytes
both directions until either side closes.

30-minute TTL sweeper handles T-6 in the edge case matrix (mapping
leak when a flow never properly closes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 10: Engine state machine + orchestrator

Files:

  • Create: internal/engine/state.go
  • Create: internal/engine/engine.go
  • Test: internal/engine/state_test.go
  • Test: internal/engine/engine_test.go

The engine ties everything together. It owns:

  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:

package engine

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestStatusTransitions_Valid(t *testing.T) {
	cases := []struct {
		from Status
		to   Status
		ok   bool
	}{
		{StatusIdle, StatusStarting, true},
		{StatusStarting, StatusActive, true},
		{StatusStarting, StatusFailed, true},
		{StatusActive, StatusIdle, true},     // user clicked Stop
		{StatusActive, StatusFailed, true},   // crash
		{StatusFailed, StatusStarting, true}, // user clicked Retry
		// Invalid transitions
		{StatusIdle, StatusActive, false},
		{StatusIdle, StatusFailed, false},
		{StatusActive, StatusStarting, false},
	}
	for _, c := range cases {
		got := isValidTransition(c.from, c.to)
		assert.Equalf(t, c.ok, got, "%s → %s", c.from, c.to)
	}
}
  • Step 2: Implement internal/engine/state.go
package engine

// Status is the engine's lifecycle state.
type Status string

const (
	StatusIdle     Status = "idle"
	StatusStarting Status = "starting"
	StatusActive   Status = "active"
	StatusFailed   Status = "failed"
	// Reconnecting added in P2.3.
)

// isValidTransition guards the state machine. Used by Engine.transition
// to assert in dev/test; production code logs a warning rather than
// panicking on invalid transitions.
func isValidTransition(from, to Status) bool {
	switch from {
	case StatusIdle:
		return to == StatusStarting
	case StatusStarting:
		return to == StatusActive || to == StatusFailed
	case StatusActive:
		return to == StatusIdle || to == StatusFailed
	case StatusFailed:
		return to == StatusStarting || to == StatusIdle
	}
	return false
}
  • Step 3: Run state test — verify pass
go test ./internal/engine/... -run TestStatusTransitions -v

Expected: PASS.

  • Step 4: Write engine integration tests

Create internal/engine/engine_test.go:

//go:build windows && integration

package engine

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

// TestEngine_StartStop_Smoke is an integration test that requires:
//   - admin
//   - reachable upstream SOCKS5 proxy
//   - WinDivert v2.2.2 driver available (or auto-installed)
//
// Build tag: integration. Run with:
//
//   go test -tags integration ./internal/engine/... -run TestEngine_StartStop_Smoke
//
// On a clean dev box this is the canary that proves the full pipeline
// is wired correctly.
func TestEngine_StartStop_Smoke(t *testing.T) {
	cfg := Config{
		ProxyAddr: "95.165.72.59:12334",
		Targets:   []string{"explorer.exe"}, // safe target — won't actually proxy anything important
	}
	e, err := New(cfg)
	require.NoError(t, err)

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	require.NoError(t, e.Start(ctx))

	// Should reach Active within a few seconds
	deadline := time.Now().Add(5 * time.Second)
	for time.Now().Before(deadline) && e.Status() != StatusActive {
		time.Sleep(50 * time.Millisecond)
	}
	require.Equal(t, StatusActive, e.Status())

	require.NoError(t, e.Stop())
	require.Equal(t, StatusIdle, e.Status())
}

The test is gated by the integration build tag so plain go test ./... doesn't try to open a WinDivert handle on every CI run.

  • Step 5: Implement internal/engine/engine.go
package engine

import (
	"context"
	"errors"
	"fmt"
	"net"
	"os"
	"sync"
	"time"

	"git.okcu.io/root/drover-go/internal/divert"
	"git.okcu.io/root/drover-go/internal/procscan"
	"git.okcu.io/root/drover-go/internal/redirect"
	"git.okcu.io/root/drover-go/internal/socks5"
)

// Config configures the engine.
type Config struct {
	ProxyAddr string   // "host:port" of upstream SOCKS5 proxy
	UseAuth   bool
	Login     string
	Password  string
	Targets   []string // exe basenames to capture (Discord.exe etc)
}

// Engine is the orchestrator. Use New + Start/Stop.
type Engine struct {
	cfg Config

	mu     sync.Mutex
	status Status
	lastErr error

	// runtime state
	upstreamIP net.IP
	handle     *divert.Handle
	redir      *redirect.Redirector
	ctx        context.Context
	cnl        context.CancelFunc
	wg         sync.WaitGroup
	ownPID     uint32
}

// New constructs an engine. No I/O yet.
func New(cfg Config) (*Engine, error) {
	if cfg.ProxyAddr == "" {
		return nil, errors.New("ProxyAddr is required")
	}
	return &Engine{
		cfg:    cfg,
		status: StatusIdle,
		ownPID: uint32(os.Getpid()),
	}, nil
}

// Status returns the current engine status (cheap, no I/O).
func (e *Engine) Status() Status {
	e.mu.Lock()
	defer e.mu.Unlock()
	return e.status
}

// LastError returns the last error that pushed us to Failed (or nil).
func (e *Engine) LastError() error {
	e.mu.Lock()
	defer e.mu.Unlock()
	return e.lastErr
}

func (e *Engine) transition(to Status, err error) {
	e.mu.Lock()
	if !isValidTransition(e.status, to) {
		// Permissive: log but don't panic in production; most invalid
		// transitions are programming errors caught by the state test.
	}
	e.status = to
	if err != nil {
		e.lastErr = err
	} else if to == StatusActive || to == StatusIdle {
		e.lastErr = nil
	}
	e.mu.Unlock()
}

// Start brings the engine to Active. Returns nil even when transition
// to Failed happens — caller checks Status afterwards. The provided
// ctx is honoured for the bring-up sequence (proxy resolve, driver
// install, handle open, etc).
func (e *Engine) Start(ctx context.Context) error {
	e.mu.Lock()
	if e.status != StatusIdle && e.status != StatusFailed {
		e.mu.Unlock()
		return fmt.Errorf("Start requires Idle or Failed; got %s", e.status)
	}
	e.status = StatusStarting
	e.mu.Unlock()

	if err := e.bringUp(ctx); err != nil {
		e.transition(StatusFailed, err)
		return err
	}
	e.transition(StatusActive, nil)
	return nil
}

func (e *Engine) bringUp(ctx context.Context) error {
	// 1. Resolve upstream
	host, _, err := net.SplitHostPort(e.cfg.ProxyAddr)
	if err != nil {
		return fmt.Errorf("invalid ProxyAddr: %w", err)
	}
	rctx, rcancel := context.WithTimeout(ctx, 5*time.Second)
	defer rcancel()
	ips, err := net.DefaultResolver.LookupIPAddr(rctx, host)
	if err != nil || len(ips) == 0 {
		return fmt.Errorf("resolve proxy host %q: %w", host, err)
	}
	var upstream net.IP
	for _, a := range ips {
		if v4 := a.IP.To4(); v4 != nil {
			upstream = v4
			break
		}
	}
	if upstream == nil {
		return fmt.Errorf("no IPv4 for %q", host)
	}
	e.upstreamIP = upstream

	// 2. Driver install (idempotent)
	if _, err := divert.InstallDriver(); err != nil {
		return fmt.Errorf("install driver: %w", err)
	}

	// 3. Initial procscan
	pids, err := procscan.Snapshot(e.cfg.Targets)
	if err != nil {
		return fmt.Errorf("procscan: %w", err)
	}
	pidList := make([]uint32, 0, len(pids))
	for p := range pids {
		pidList = append(pidList, p)
	}

	// 4. Open redirector listener
	r, err := redirect.New(redirect.Config{
		SOCKS5: socks5.Config{
			ProxyAddr: e.cfg.ProxyAddr,
			UseAuth:   e.cfg.UseAuth,
			Login:     e.cfg.Login,
			Password:  e.cfg.Password,
		},
		Bind: "127.0.0.1:0",
	})
	if err != nil {
		return fmt.Errorf("redirector: %w", err)
	}
	e.redir = r

	// 5. Build filter + open handle
	filter := divert.BuildFilter(divert.FilterParams{
		TargetPIDs: pidList,
		OwnPID:     e.ownPID,
		UpstreamIP: upstream.String(),
	})
	h, err := divert.Open(filter)
	if err != nil {
		r.Close()
		return fmt.Errorf("WinDivert open: %w", err)
	}
	e.handle = h

	// 6. Spawn divert reader + procscan ticker
	e.ctx, e.cnl = context.WithCancel(context.Background())
	e.wg.Add(2)
	go e.diverterLoop()
	go e.procscanLoop()
	return nil
}

// Stop tears down. Always returns to Idle (or stays in Idle if
// already there).
func (e *Engine) Stop() error {
	e.mu.Lock()
	if e.status == StatusIdle {
		e.mu.Unlock()
		return nil
	}
	e.mu.Unlock()

	if e.cnl != nil {
		e.cnl()
	}
	if e.handle != nil {
		e.handle.Close()
	}
	if e.redir != nil {
		e.redir.Close()
	}
	e.wg.Wait()
	e.handle = nil
	e.redir = nil
	e.transition(StatusIdle, nil)
	return nil
}

func (e *Engine) diverterLoop() {
	defer e.wg.Done()
	buf := make([]byte, 65536)
	listenerPort := e.redir.LocalPort()
	for {
		select {
		case <-e.ctx.Done():
			return
		default:
		}
		n, addr, err := e.handle.Recv(buf)
		if err != nil {
			if errors.Is(err, divert.ErrShutdown) || errors.Is(err, divert.ErrInvalidHandle) {
				e.transition(StatusFailed, err)
				return
			}
			continue
		}
		// Parse + record + rewrite
		info, err := divert.ParseIPv4TCP(buf[:n])
		if err != nil {
			// Not parseable — reinject as-is.
			_, _ = e.handle.Send(buf[:n], addr)
			continue
		}
		// SYN packets don't carry the full flow yet — but every
		// outbound TCP carries src_port we can map. We always record
		// the latest mapping, refreshing TTL on subsequent packets.
		e.redir.SetMapping(info.SrcPort, info.DstIP, info.DstPort)

		// Rewrite to loopback
		if err := divert.RewriteDst(buf[:n], net.IPv4(127, 0, 0, 1), listenerPort); err == nil {
			_, _ = e.handle.Send(buf[:n], addr)
		}
	}
}

func (e *Engine) procscanLoop() {
	defer e.wg.Done()
	tk := time.NewTicker(2 * time.Second)
	defer tk.Stop()

	prev, _ := procscan.Snapshot(e.cfg.Targets)
	for {
		select {
		case <-e.ctx.Done():
			return
		case <-tk.C:
		}
		cur, err := procscan.Snapshot(e.cfg.Targets)
		if err != nil {
			continue
		}
		add, rem := procscan.DiffPIDs(prev, cur)
		if len(add) == 0 && len(rem) == 0 {
			continue
		}
		// Rebuild filter + reopen handle
		pidList := make([]uint32, 0, len(cur))
		for p := range cur {
			pidList = append(pidList, p)
		}
		filter := divert.BuildFilter(divert.FilterParams{
			TargetPIDs: pidList,
			OwnPID:     e.ownPID,
			UpstreamIP: e.upstreamIP.String(),
		})
		newH, err := divert.Open(filter)
		if err != nil {
			e.transition(StatusFailed, fmt.Errorf("reopen handle on PID change: %w", err))
			return
		}
		oldH := e.handle
		e.handle = newH
		if oldH != nil {
			oldH.Close()
		}
		prev = cur
	}
}
  • Step 6: Run engine state test
go test ./internal/engine/... -run TestStatusTransitions -v

Expected: PASS.

  • Step 7: Build the full project
go build ./...

Expected: clean.

  • Step 8: Commit
git add internal/engine/
git commit -m "$(cat <<'EOF'
internal/engine: state machine + orchestrator (P2.1 scope)

Idle → Starting → Active → Failed lifecycle. bringUp resolves
upstream IP, installs the driver (idempotent), runs initial procscan,
opens redirector listener, builds filter + opens WinDivert handle,
then spawns the diverter reader and 2-second procscan ticker.

On every outbound TCP packet from a target PID: record (src_port →
real_target) mapping, rewrite dst to 127.0.0.1:listener_port,
re-inject. Loopback listener picks up the connection, looks up the
original target, and SOCKS5-tunnels.

P2.1 scope: no Reconnecting state, no panic recovery, no UDP
forwarding. Those land in P2.2/P2.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 11: GUI integration

Files:

  • Modify: internal/gui/app.go

Replace the stub StartEngine/StopEngine with calls into engine.Engine. Map engine.Status to the existing engine:status event payload. Stats remain stubbed for P2.1 (real bytes counters land in P2.4 alongside the tray UI).

  • Step 1: Read current internal/gui/app.go

Familiarise yourself with the existing structure — App struct fields, Startup hook, the running/startedAt fields that need to become engine state.

  • Step 2: Modify internal/gui/app.go

Replace the App struct's bare running bool field and stub StartEngine/StopEngine with:

import (
    // ... existing
    "git.okcu.io/root/drover-go/internal/engine"
)

type App struct {
    ctx     context.Context
    version string

    mu          sync.Mutex
    eng         *engine.Engine
    startedAt   time.Time
    cancelCheck context.CancelFunc
    muCheck     sync.Mutex
    checkDone   chan struct{}
}

Replace StartEngine body with:

func (a *App) StartEngine(cfg Config) error {
    a.mu.Lock()
    defer a.mu.Unlock()
    if a.eng != nil && a.eng.Status() == engine.StatusActive {
        return nil
    }
    e, err := engine.New(engine.Config{
        ProxyAddr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
        UseAuth:   cfg.Auth,
        Login:     cfg.Login,
        Password:  cfg.Password,
        Targets:   []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"},
    })
    if err != nil {
        runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
        return err
    }
    if err := e.Start(a.ctx); err != nil {
        runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
        return err
    }
    a.eng = e
    a.startedAt = time.Now()
    runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
    return nil
}

func (a *App) StopEngine() error {
    a.mu.Lock()
    defer a.mu.Unlock()
    if a.eng == nil {
        return nil
    }
    err := a.eng.Stop()
    a.eng = nil
    runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false})
    return err
}

func (a *App) GetStatus() map[string]any {
    a.mu.Lock()
    defer a.mu.Unlock()
    running := a.eng != nil && a.eng.Status() == engine.StatusActive
    res := map[string]any{
        "running": running,
        "uptimeS": int(time.Since(a.startedAt).Seconds()),
    }
    if a.eng != nil {
        res["state"] = string(a.eng.Status())
        if err := a.eng.LastError(); err != nil {
            res["error"] = err.Error()
        }
    }
    return res
}

Note: this requires Config.Host to be a string (already is per existing struct) and Config.Port int (already is).

  • Step 3: Adjust statsLoop to use engine state
func (a *App) statsLoop() {
    r := rand.New(rand.NewSource(time.Now().UnixNano()))
    tick := time.NewTicker(time.Second)
    defer tick.Stop()
    for range tick.C {
        a.mu.Lock()
        if a.eng == nil || a.eng.Status() != engine.StatusActive || a.ctx == nil {
            a.mu.Unlock()
            continue
        }
        uptime := int(time.Since(a.startedAt).Seconds())
        a.mu.Unlock()

        runtime.EventsEmit(a.ctx, "stats:update", map[string]any{
            "up":      r.Intn(50_000) + 5_000,
            "down":    r.Intn(500_000) + 50_000,
            "tcp":     r.Intn(8) + 1,
            "udp":     0, // P2.1 scope: no UDP yet
            "uptimeS": uptime,
        })
    }
}

The randomised numbers stay until P2.4 (real counters). UDP is hard-zero because P2.1 doesn't forward UDP.

  • Step 4: go build ./... to verify
go build ./...

Expected: clean.

  • Step 5: Commit
git add internal/gui/app.go
git commit -m "$(cat <<'EOF'
internal/gui: wire StartEngine/StopEngine to internal/engine

Replaces the stub flag-toggle with a real engine.Engine. GetStatus
now reports the engine's actual state machine value. Stats remain
randomised in P2.1 — real bytes-counters land in P2.4 with the tray
UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push

Task 12: End-to-end manual verification

Files:

  • Create: docs/testing/p2.1-manual.md (test journal)

This is the milestone gate — proves the whole pipeline works on a real machine. No code changes; we run the binary, exercise the happy path, and document outcomes for next milestones to refer back to.

  • Step 1: Build current state
cd F:/work/drover-go && bash rebuild.sh

Expected: drover-test.exe produced, ~12 MB.

  • Step 2: Run manual journey, recording outcomes

Create F:/work/drover-go/docs/testing/p2.1-manual.md with the following template, filling in actual results as you go:

# P2.1 manual verification — YYYY-MM-DD

## Environment
- Win11 build XX.X
- Admin shell: yes/no
- Discord version: X.Y.Z
- Upstream proxy: 95.165.72.59:12334 (mihomo on LXC 102)

## Acceptance criteria

| # | Step | Expected | Actual |
|---|---|---|---|
| 1 | Launch `drover-test.exe` from non-admin shell | UAC prompt; on accept, GUI opens | |
| 2 | Click "Check connection" | All 6 tests green (TCP/greet/connect/UDP/voice-quality/api) | |
| 3 | Click "Start proxying" | Status header → "Active"; engine.Status()=active in logs | |
| 4 | Open Discord (kill if running, restart) | Within 2-4s, Drover detects the PID and rebuilds filter | |
| 5 | Send a chat message in Discord | Message sends; verify in mihomo logs that it was tunneled | |
| 6 | Open Discord settings → User Settings → check own profile | Profile loads (proves API requests went through proxy) | |
| 7 | Click "Stop" in Drover | Engine returns to Idle within 500ms; no driver-related errors in logs | |
| 8 | Run `sc query WinDivert` from PowerShell after Stop | Service exists, state STOPPED — driver remains installed | |
| 9 | Restart Drover, observe self-loop test: open Wireshark on the LAN interface, filter `tcp port 12334`, see only Drover's outbound (one stream) — no exponential growth | Single stable stream, no infinite loop | |
| 10 | Try voice call in Discord | NO voice (UDP not yet implemented in P2.1) — we expect Discord client to keep retrying with no audio. Verify it doesn't deadlock the Drover engine. | |
| 11 | Kill Drover process from Task Manager mid-Active | Driver remains in valid state; next launch re-acquires handle without ERROR_DRIVER_FAILED_PRIOR_UNLOAD | |

## Known issues found
(fill in as they happen)

## Notes for P2.2
(any insight that informs UDP implementation)
  • Step 3: Execute the 11 steps above and fill in the table

Take screenshots at key moments (UAC prompt, GUI active state, Wireshark single-stream view, mihomo logs showing tunneled traffic). Save them under docs/testing/p2.1-screenshots/ (gitignored if >1MB total).

  • Step 4: Tag any failures as bugs to fix

For each FAILED row, either:

  • (a) the bug is small enough to fix inline → write a follow-up commit before tagging the milestone, OR

  • (b) the bug indicates a deeper issue → file as docs/planning/p2.1-followup-N.md with steps to reproduce and proposed fix.

  • Step 5: Commit the test journal

git add docs/testing/p2.1-manual.md docs/testing/p2.1-screenshots/
git commit -m "$(cat <<'EOF'
docs/testing: P2.1 manual verification journal

End-to-end journey on real Win11 with mihomo upstream proxy.
All 11 acceptance steps recorded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)" && git push
  • Step 6: Tag the milestone

If all steps PASS:

git tag -a v0.3.0-p2.1 -m "P2.1 — TCP-only MVP

Discord chat + API now route through SOCKS5 via WinDivert kernel
capture. Voice (UDP) deferred to P2.2."
git push origin v0.3.0-p2.1

This triggers the existing CI release workflow → produces drover-vX.Y.Z exe + installer + SHA256SUMS on Forgejo.


Self-Review

Spec coverage check (against docs/superpowers/specs/2026-05-01-engine-design.md P2.1 requirements):

P2.1 requirement Implemented in task
WinDivert handle Task 6
Filter expression Task 3
Packet parser Task 4
TCP NAT-loopback redirect Tasks 9 + 10 (engine wires it)
SOCKS5 client (TCP only) Task 7
procscan Task 8
Self-loop protection Task 3 (filter) + Task 10 (own_pid in engine)
Basic engine state machine (Idle/Starting/Active/Failed) Task 10 (state.go + engine.go)
UAC re-launch Task 1
Driver install (embedded extract) Tasks 2 + 5
Acceptance: chat/API through proxy Task 12
Acceptance: clean stop in <500ms Task 12
Acceptance: driver remains installed Task 12
Acceptance: no self-loop infinite traffic Task 12

All P2.1 requirements covered.

Type/signature consistency check:

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