c647c09c20
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>
2972 lines
82 KiB
Markdown
2972 lines
82 KiB
Markdown
# P2.1 — TCP-only MVP Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Get drover routing Discord's TCP traffic (chat + API) through an upstream SOCKS5 proxy via WinDivert kernel-level packet capture. Voice (UDP) is explicitly deferred to P2.2.
|
||
|
||
**Architecture:** WinDivert v2.2.2 driver captures outbound TCP from `Discord.exe`/`DiscordCanary.exe`/`DiscordPTB.exe`/`Update.exe`, the engine NAT-rewrites destination to `127.0.0.1:<listener_port>`, the loopback listener accepts the connection, looks up the original destination in a per-flow map, opens a SOCKS5 CONNECT to the upstream proxy, and pumps bytes both directions with `io.Copy`. Filter expression dynamically rebuilds when Discord's PIDs change (every 2s via `Toolhelp32`). Self-loop protection via `processId != own_pid` in the filter and excluding the upstream proxy IP.
|
||
|
||
**Tech Stack:** Go 1.23, `golang.org/x/sys/windows` for syscalls, `github.com/imgk/divert-go` v0.1.0 for WinDivert bindings (with fallback to direct syscalls if it's broken), embedded `WinDivert64.sys` + `WinDivert.dll` v2.2.2 from `third_party/windivert/`. Test framework: `testify`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-01-engine-design.md` (read sections P2.1, WinDivert layer, TCP redirect, Process scanning, Self-loop protection, UAC).
|
||
|
||
**Scope:** Tasks 1–12 below. Out of scope: UDP forwarding (P2.2), Reconnecting state (P2.3), tray/autostart UI (P2.4), polish/edge cases (P2.5).
|
||
|
||
---
|
||
|
||
## File structure for P2.1
|
||
|
||
| Path | Responsibility |
|
||
|---|---|
|
||
| `cmd/drover/uac_windows.go` (new) | `IsAdmin()` + `ReElevate()` — UAC re-launch helper |
|
||
| `cmd/drover/main.go` (modify) | Insert UAC check before GUI boot |
|
||
| `internal/divert/divert.go` (new) | WinDivert handle wrapper: Open/Close/Recv/Send |
|
||
| `internal/divert/filter.go` (new) | Build filter expression from PID list + own PID + upstream IP |
|
||
| `internal/divert/packet.go` (new) | Parse + serialize IPv4+TCP, recompute checksums |
|
||
| `internal/divert/installer.go` (new) | Extract embedded `WinDivert64.sys` + `WinDivert.dll` to `%PROGRAMDATA%\Drover\windivert\` with SHA256 verify |
|
||
| `internal/divert/embed.go` (new) | `//go:embed` of the two driver files |
|
||
| `internal/socks5/client.go` (new) | Production SOCKS5 client (greet + auth + CONNECT). NOT shared with `internal/checker/socks5.go` — different requirements (no diagnostic-friendly errors, no raw-byte exposure) |
|
||
| `internal/procscan/procscan.go` (new) | `CreateToolhelp32Snapshot` PID enumerator, periodic ticker |
|
||
| `internal/redirect/tcp.go` (new) | Loopback listener, per-flow `(src_port → real_target)` map, SOCKS5 dial + `io.Copy` pump |
|
||
| `internal/engine/state.go` (new) | `Status` enum + transition rules: Idle/Starting/Active/Failed |
|
||
| `internal/engine/engine.go` (new) | Orchestrator: Start/Stop, lifecycle, wire divert + redirect + procscan |
|
||
| `internal/gui/app.go` (modify) | Replace stub `StartEngine`/`StopEngine` with calls into `engine.Engine` |
|
||
|
||
---
|
||
|
||
## Task 1: UAC re-launch helper
|
||
|
||
**Files:**
|
||
- Create: `cmd/drover/uac_windows.go`
|
||
- Modify: `cmd/drover/main.go`
|
||
|
||
WinDivert `WinDivertOpen` fails with `ERROR_ACCESS_DENIED` for non-admin processes. Per decision **B1** (UAC at every launch), we detect non-admin at startup and re-launch via `ShellExecuteW` with `runas` verb. CLI sub-commands like `--check`, `--version`, and the auto-update path don't need admin and must not trigger UAC.
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Create `cmd/drover/uac_windows_test.go`:
|
||
|
||
```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 200–280 for the precise layout.
|
||
|
||
Subagent: try `imgk/divert-go` first; if `go build ./internal/divert/...` fails, document the failure clearly, switch to fallback, and report which path was taken.
|
||
|
||
- [ ] **Step 4: Run smoke test on this Windows machine**
|
||
|
||
```bash
|
||
go test ./internal/divert/... -run TestOpen -v
|
||
```
|
||
|
||
If running from an admin shell: PASS. From a non-admin shell: SKIP.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add internal/divert/divert.go internal/divert/divert_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
internal/divert: WinDivert handle wrapper
|
||
|
||
Thin Go layer over imgk/divert-go (or raw syscalls if upstream is
|
||
broken). Exposes Open/Close/Recv/Send and maps the most relevant
|
||
Windows errors to sentinels (ErrAccessDenied,
|
||
ErrDriverFailedPriorUnload, ErrInvalidHandle, ErrShutdown) so the
|
||
engine's recovery classifier can reason about them without importing
|
||
golang.org/x/sys/windows.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <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).
|