17 Commits

Author SHA1 Message Date
root 168596bcb5 sboxrun: domain+IP-CIDR rules + remove voice-quality test
Build / test (push) Failing after 33s
Build / build-windows (push) Has been skipped
Release / release (push) Failing after 3m22s
Three follow-up fixes after the WinDivert→sing-box pivot:

1. Discord updater now routes through upstream. Previously only the
   process-name rule matched, but sing-box's TUN-side process
   detection on Windows mis-attributes the in-process Rust updater's
   TLS connection to e.g. steam.exe — the connection went direct and
   hit RKN block. Adding domain_suffix + ip_cidr rules for Cloudflare
   (162.159/16, 104.16/13, 172.64/13) and Fastly (199.232/16,
   151.101/16) catches updates.discord.com regardless of which PID
   the kernel claims sent it. Verified via curl through mihomo:
   updates.discord.com responds 400 in 393ms (i.e. TLS handshake
   succeeds, only the path is wrong — proves the routing reaches it).

2. DiscordSystemHelper.exe added to TargetProcs alongside Update.exe
   (modern Discord builds use it for elevated updates).

3. UDP voice quality test removed from the checker. The STUN-via-
   relay burst measured private mihomo BND.ADDR (192.168.1.132)
   which is unroutable from external clients, so the test reported
   100% loss every time despite voice actually working through
   sing-box's TUN+SOCKS5. The remaining 6 checks (TCP/greet/auth/
   connect/UDP/api) cover what's actionable; voice quality is
   verified empirically by joining a Discord call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:21:50 +03:00
root 48097f8671 pivot: replace WinDivert engine with embedded sing-box + wintun
Build / test (push) Failing after 31s
Build / build-windows (push) Has been skipped
After 5+ hours of WinDivert NETWORK-layer NAT-rewrite debugging
(streamdump pattern, SOCKET-layer SYN preemption, lazy PID resolution,
UDP ASSOCIATE relay + manual reinject), Discord voice still wouldn't
connect. The fundamental issue is that WinDivert reinjected UDP
packets don't always reach connect()-bound application sockets — the
demux happens at a layer above the reinject point.

dvp/force-proxy avoids this entirely via DLL injection (above the
kernel demux). We avoid it the other way: embed sing-box, let it run
TUN inbound + per-process routing rule + SOCKS5 outbound. TUN packets
are read by sing-box from kernel as a normal flow; the application
socket sees a normal flow back. No reinject hairpin, no SYN race, no
spoofing concerns.

What this commit does:
  - Drops internal/divert, internal/engine, internal/redirect,
    internal/socks5, internal/procscan, plus cmd/drover/{proxy,
    debugflow}_*.go subcommands (all WinDivert-only).
  - Adds internal/sboxrun — embed sing-box.exe (1.12.25) + wintun.dll
    (0.14.1) via //go:embed, install to %PROGRAMDATA%\Drover\sboxrun\
    with SHA256 verify, generate JSON config from form, spawn as
    subprocess, manage lifecycle.
  - Wires sboxrun into internal/gui/app.go: StartEngine/StopEngine
    now call sboxrun.Engine instead of windivert engine.
  - Fixes Wails binding: StartEngine(cfg) now passes the form config
    (was zero-arg, hit ProxyHost-required validation silently).

Manual test: Discord chat + voice work end-to-end through mihomo
upstream. Yandex Music / svchost / etc continue direct via
process_name routing rule.

Binary grew from 12 MB → 49 MB (37 MB sing-box embedded), but ships
fully self-contained. AV-friendly: wintun is Microsoft-signed, no
DLL injection.

WinDivert work preserved on experimental/windivert branch in case we
ever want to come back to that path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:02:12 +03:00
root 4074e68715 experimental/windivert: P2.1+P2.2 with WinDivert NETWORK+SOCKET layers
WIP snapshot before pivot to sing-box+TUN. Reached:
- TCP redirect via streamdump pattern (swap+Outbound=0+reinject)
- SOCKET layer for SYN-stage flow detection (avoids FLOW Establish-too-late race)
- Lazy PID→name resolution (catches Update.exe inside procscan tick)
- UDP forward via SOCKS5 UDP ASSOCIATE relay + manual reinject
- Result: chat works, voice times out (Discord IP discovery / RTC handshake fails)

Reason for pivot: WinDivert NAT-reinject pattern has subtle layer-3
semantics issues that DLL-injection / TUN-based proxies sidestep
entirely. Going with embedded sing-box + wintun as the engine —
proven path for Discord voice through SOCKS5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:27:54 +03:00
root 8ceb7775d7 internal/gui: wire StartEngine/StopEngine to internal/engine
Build / test (push) Failing after 30s
Build / build-windows (push) Has been skipped
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>
2026-05-01 20:05:51 +03:00
root bbe88b0f70 internal/engine: state machine + orchestrator (P2.1 scope)
Build / test (push) Failing after 30s
Build / build-windows (push) Has been skipped
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>
2026-05-01 20:04:09 +03:00
root dd402d4fc4 internal/redirect: TCP NAT-loopback redirector
Build / test (push) Failing after 30s
Build / build-windows (push) Has been skipped
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).

Pump teardown: when one direction's io.Copy exits, the goroutine
CloseWrite's its write side AND sets a past read deadline on the
OTHER conn so the peer goroutine's blocked read unwinds promptly
even when the upstream half never sends EOF (test fake-SOCKS5 hits
this; the real upstream may too).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:00:21 +03:00
root 837208d9ed internal/procscan: Toolhelp32 PID enumerator
Build / test (push) Failing after 28s
Build / build-windows (push) Has been skipped
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>
2026-05-01 19:51:16 +03:00
root a45c1c0ab7 internal/socks5: production TCP CONNECT client
Build / test (push) Failing after 31s
Build / build-windows (push) Has been skipped
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>
2026-05-01 19:50:03 +03:00
root 1949abf011 internal/divert: WinDivert handle wrapper
Build / test (push) Failing after 29s
Build / build-windows (push) Has been skipped
Thin Go layer over imgk/divert-go. 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.

Verified imgk/divert-go@v0.1.0 API matches plan; only deviation is
Recv/Send returning uint (cast to int at our boundary).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:47:58 +03:00
root 35da6be99e internal/divert: driver installer with SHA256 verification
Build / test (push) Failing after 30s
Build / build-windows (push) Has been skipped
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>
2026-05-01 19:45:27 +03:00
root feda075dc4 internal/divert: IPv4+TCP packet parse + RewriteDst + checksums
Build / test (push) Failing after 29s
Build / build-windows (push) Has been skipped
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>
2026-05-01 19:43:42 +03:00
root 223c7f5886 internal/divert: filter expression builder
Build / test (push) Failing after 32s
Build / build-windows (push) Has been skipped
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>
2026-05-01 19:41:53 +03:00
root 736c3ecfc7 internal/divert: embed WinDivert64.sys + WinDivert.dll v2.2.2 with SHA256 sentinels
Build / test (push) Failing after 30s
Build / build-windows (push) Has been skipped
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>
2026-05-01 19:40:11 +03:00
root 11de3fb12b cmd/drover: ReElevate — surface UTF16 + Getwd errors, escape quotes
Build / test (push) Failing after 33s
Build / build-windows (push) Has been skipped
Code review found 5 silently-ignored errors in ReElevate (UTF16
conversions and os.Getwd) plus unescaped argument quoting that
breaks args containing literal `"`. Each error is now wrapped with
a clear message; quotes are backslash-escaped per the MSVC argv
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:38:05 +03:00
root 8e83260123 cmd/drover: UAC re-launch helper for non-admin invocations
Build / test (push) Failing after 32s
Build / build-windows (push) Has been skipped
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>
2026-05-01 19:33:10 +03:00
root c647c09c20 plan: P2.1 TCP-only MVP — 12 bite-sized tasks
Spec: docs/superpowers/specs/2026-05-01-engine-design.md (P2.1 section)

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:30:14 +03:00
root 5f107de95d spec: Phase 2 engine — WinDivert + SOCKS5 transparent proxy
Design accepted 2026-05-01. Locks in 5 architectural decisions
(GUI-only, UAC-per-launch, no DPI bypass, hide-to-tray with toast,
contextual recovery) and decomposes Phase 2 into 5 milestones with
explicit acceptance criteria + a 30-row edge case matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:21:16 +03:00
28 changed files with 4481 additions and 296 deletions
+62
View File
@@ -3,7 +3,11 @@ package main
import ( import (
"fmt" "fmt"
"io"
"log"
"os" "os"
"path/filepath"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -29,6 +33,31 @@ func main() {
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere. // AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
attachToParentConsole() attachToParentConsole()
// Open a debug log file at %LOCALAPPDATA%\Drover\debug.log so we have
// post-mortem visibility into engine startup failures even when the
// process was launched via UAC re-elevation (which detaches stderr
// from the parent terminal).
setupDebugLog()
// Detect if we need admin for the command in os.Args[1:]. If we do and
// we're not admin, re-launch via ShellExecute("runas", ...) and exit.
// CLI subcommands like "check", "version", "update" don't need admin
// and will run without UAC prompt.
needsAdm := CmdNeedsAdmin(os.Args[1:])
isAdm := IsAdmin()
log.Printf("main: post-console admin=%v needsAdmin=%v args=%v", isAdm, needsAdm, os.Args[1:])
if needsAdm && !isAdm {
log.Printf("main: invoking ReElevate")
if err := ReElevate(os.Args[1:]); err != nil {
log.Printf("main: ReElevate returned err: %v", err)
fmt.Fprintf(os.Stderr, "failed to re-elevate: %v\n", err)
} else {
log.Printf("main: ReElevate returned ok, exiting parent")
}
os.Exit(0)
}
log.Printf("main: continuing in current process (no re-elevation needed)")
// Inject our build version so the updater package can stamp it on the // Inject our build version so the updater package can stamp it on the
// User-Agent header it sends to git.okcu.io. // User-Agent header it sends to git.okcu.io.
updater.SetVersion(Version) updater.SetVersion(Version)
@@ -39,6 +68,39 @@ func main() {
} }
} }
// setupDebugLog wires the standard `log` package to write to both stderr
// and %LOCALAPPDATA%\Drover\debug.log. Survives UAC re-launch (each
// process opens its own append-mode handle).
func setupDebugLog() {
dir := os.Getenv("LOCALAPPDATA")
if dir == "" {
dir = os.Getenv("TEMP")
}
if dir == "" {
return
}
dir = filepath.Join(dir, "Drover")
_ = os.MkdirAll(dir, 0755)
// Truncate on each startup — keeps the log focused on the current
// run instead of accumulating past sessions. If you need history,
// rotate before launch.
f, err := os.OpenFile(filepath.Join(dir, "debug.log"), os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
// On a UAC-elevated launch (Start-Process -Verb RunAs) we have no
// parent console — os.Stderr points at an invalid handle. Writing
// to it via MultiWriter fails the *entire* write, so logs silently
// drop. Just write to the file; CLI subcommands launched from a
// real console can grep the file.
log.SetOutput(f)
_ = io.Discard // keep io import used
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Printf("=== drover %s start pid=%d args=%v admin=%v at %s ===",
Version, os.Getpid(), os.Args[1:], IsAdmin(), time.Now().Format(time.RFC3339))
}
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
root := &cobra.Command{ root := &cobra.Command{
Use: "drover", Use: "drover",
+106
View File
@@ -0,0 +1,106 @@
//go:build windows
package main
import (
"fmt"
"os"
"strings"
"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, err := syscall.UTF16PtrFromString("runas")
if err != nil {
return fmt.Errorf("encode verb: %w", err)
}
exePtr, err := syscall.UTF16PtrFromString(exe)
if err != nil {
return fmt.Errorf("encode exe: %w", err)
}
var paramsPtr *uint16
if len(args) > 0 {
// Quote each arg in case of spaces, and escape internal quotes.
quoted := make([]string, len(args))
for i, a := range args {
// Escape any internal quotes with backslash (MSVC argv convention).
escaped := strings.ReplaceAll(a, "\"", "\\\"")
quoted[i] = `"` + escaped + `"`
}
joined := ""
for i, q := range quoted {
if i > 0 {
joined += " "
}
joined += q
}
paramsPtr, err = syscall.UTF16PtrFromString(joined)
if err != nil {
return fmt.Errorf("encode params: %w", err)
}
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get cwd: %w", err)
}
cwdPtr, err := syscall.UTF16PtrFromString(cwd)
if err != nil {
return fmt.Errorf("encode cwd: %w", err)
}
// SW_NORMAL = 1
return windows.ShellExecute(0, verb, exePtr, paramsPtr, cwdPtr, 1)
}
+32
View File
@@ -0,0 +1,32 @@
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)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,651 @@
# Engine — WinDivert + SOCKS5 transparent proxy for Discord
**Status**: design accepted 2026-05-01.
**Replaces**: stub `StartEngine`/`StopEngine` in `internal/gui/app.go` that just toggle a flag.
**Implements**: Phase 2 from `docs/planning/cuddly-baking-taco.md`.
## Why
The checker proves the upstream SOCKS5 proxy works. The engine is what
actually routes Discord's traffic through it. Without the engine, every
diagnostic in the world is theatre — the GUI just sits there saying
"Active" while Discord still talks direct to discord.com. Phase 2 turns
that "Active" state into reality: kernel-level packet capture (WinDivert),
NAT-style TCP redirect to a loopback listener, SOCKS5 UDP ASSOCIATE for
voice, and a polished lifecycle so the user can install once, click
"autostart at login", and forget the thing exists until Discord stops
working — at which point the tray icon turns yellow and explains why.
## Architecture decisions (locked-in 2026-05-01)
| # | Decision | Rationale |
|---|---|---|
| **A** | GUI-only single-process; no Windows service | Friends-and-family Windows-PC, Discord runs only when user is logged in. Service mode is overengineering for v1; can be added in v0.4 if a power user asks. |
| **B1** | UAC prompt at every launch; no scheduled-task trampoline | User chose simplicity over polish. Each `drover.exe` invocation re-elevates if not admin. Autostart via `HKCU\...\Run` triggers the same prompt at login. |
| **C1** | No DPI bypass (no fake QUIC injection) | Start with the simplest pipeline that works. If a friend reports voice not working on a DPI-active provider, add C2/C3 in v0.4. |
| **D1** | Window X = hide-to-tray + first-time toast; quit only via tray menu | Industry-standard (Steam, Discord, Telegram). One-shot toast prevents the "where did it go?" surprise. |
| **E3** | Contextual recovery: driver-loss → 1 reopen retry → fail-stop; proxy-loss → infinite exp-backoff (Reconnecting state); panic → fail-stop with crash dump; sleep/resume → graceful pause/resume | Different failure classes need different responses. Aggressive auto-restart on every error masks bugs; honest fail-stop on every error annoys the user during transient network blips. |
## High-level architecture
```
┌─────────────────────────────────────┐
│ drover.exe (single binary) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Wails GUI │ │ systray │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ └───────┬────────┘ │
│ ┌─────────▼──────────┐ │
│ │ Engine │ │
│ │ state machine │ │
│ │ Idle / Starting / │ │
│ │ Active / Reconn / │ │
│ │ Failed │ │
│ └─────────┬──────────┘ │
│ ┌─────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌────────┐ ┌──────────┐ │
│ │divert│ │redirect│ │ procscan │ │
│ │ pkt │ │ TCP+UDP│ │ (2s tick)│ │
│ └──┬───┘ └───┬────┘ └────┬─────┘ │
│ ▼ ▼ │ │
│ WinDivert socks5 │ │
│ .sys client │ │
└──────────────────────────────┼──────┘
┌────────────┐ ┌─────────────▼───┐
│ kernel │ │ upstream SOCKS5 │
│ packet cap │ │ (mihomo) │
└────────────┘ └─────────────────┘
```
## File layout
```
cmd/drover/
main.go existing — extend with engine startup, single-instance check
uac_windows.go new — IsAdmin, ReElevate
console_windows.go existing
autoupdate_windows.go existing
internal/engine/
engine.go new — orchestration, state machine, lifecycle
state.go new — Idle/Starting/Active/Reconnecting/Failed enum + transitions
recovery.go new — failure classifier → action mapper
health.go new — heartbeat timer, traffic detector
power_windows.go new — WM_POWERBROADCAST listener (sleep/resume)
internal/divert/
divert.go new — WinDivert handle wrapper
filter.go new — filter expression builder
packet.go new — IPv4 + TCP/UDP parse + checksum recompute
installer.go new — extract embedded WinDivert.sys/.dll on first run
divert_arm64.go new — stub returning "ARM64 not supported"
internal/socks5/ NEW — production client (separate from internal/checker/socks5.go)
client.go new — TCP CONNECT + greet/auth
udp.go new — UDP ASSOCIATE + encapsulate/decapsulate
pool.go new — control-channel pool (deferred to P2.5 if needed)
internal/redirect/
tcp.go new — NAT-loopback redirect listener + per-flow pump
udp.go new — per-flow UDP tracker + encap/decap
internal/procscan/
procscan.go new — Toolhelp32 snapshot, periodic PID resolver
internal/tray/
tray.go new — getlantern/systray icon + menu
icons.go new — embed idle/active/reconnecting/error ICOs
internal/autostart/
autostart_windows.go new — HKCU\...\Run registry toggle
internal/single/
single_windows.go new — named mutex + activation pipe
internal/config/
config.go new — TOML schema + defaults
loader.go new — load/save with file lock
watcher.go new — fsnotify hot-reload
internal/gui/
app.go existing — extend with engine bindings
frontend/... existing — wire engine controls + autostart checkbox
third_party/windivert/ existing — WinDivert64.sys, WinDivert.dll, LICENSE-LGPL
third_party/icons/ new — tray/{idle,active,reconnecting,error}.ico
```
## Engine state machine
```
┌────────┐
│ Idle │ ◄────────────────── (initial)
└────┬───┘
│ user clicks "Start engine"
┌────────────┐
┌──────│ Starting │── any error ───┐
│ └─────┬──────┘ │
│ │ all checks ok │
│ ▼ │
│ ┌────────────┐ │
│ │ Active │ ◄─── recover ─┐ │
│ └────┬───────┘ │
│ │ proxy lost / SOCKS5 │
│ │ control channels died │
│ ▼ │
│ ┌─────────────┐ │
│ │Reconnecting │── 5 min cap ──┐ │
│ └────┬────────┘ │
│ │ recovered │
│ ▼ │
│ back to Active │
│ │
│ Stop button ─►───────────────────┐│
│ ▼▼
│ ┌────────┐
└──── Stop ───────────────────►│ Failed │
└────┬───┘
│ user clicks Retry
(back to Starting)
```
States visible to GUI as `EngineStatus`:
- `Idle` — engine off, tray icon grey, GUI shows "Start" button
- `Starting` — handle being opened, procscan running, health-check; tray yellow with spin
- `Active` — packets flowing; tray green; live stats updating
- `Reconnecting` — proxy unreachable, exponential backoff in progress; tray yellow; "Reconnecting (3rd attempt)"
- `Failed` — driver lost twice OR panic OR Reconnecting hit 5 min cap. Tray red. GUI shows error message + Retry button.
## E3 recovery rules (failure classifier)
```go
// internal/engine/recovery.go
type FailureClass int
const (
ClassDriverLost FailureClass = iota // WinDivert handle invalid, ERROR_INVALID_HANDLE on Recv
ClassDriverGone // WinDivertOpen returns ERROR_FILE_NOT_FOUND or similar
ClassProxyUnreachable // SOCKS5 control TCP connection rejected/timeout
ClassPanic // recover() in goroutine
ClassSleep // WM_POWERBROADCAST suspend
ClassResume // WM_POWERBROADCAST resume
ClassFatal // anything we can't classify
)
type Action int
const (
ActionRetryOnce Action = iota // sleep 2s, reopen, if fails again → Failed
ActionExpBackoff // 1s → 5s → 30s cap, infinite, max 5min cumulative
ActionFailStop // straight to Failed, write crash dump
ActionPause // drain in-flight, close sockets, transition to Reconnecting
ActionResume // wait 5s, reopen handle, transition to Active
)
func ClassifyFailure(err error, class FailureClass) Action
```
| Class | Action | UI feedback |
|---|---|---|
| `DriverLost` | RetryOnce | Status="reopening driver" |
| `DriverGone` | FailStop | "Driver missing — reinstall Drover" |
| `ProxyUnreachable` | ExpBackoff | "Reconnecting (Nth attempt)…" |
| `Panic` | FailStop | "Engine crashed — log saved to %PROGRAMDATA%\\Drover\\logs\\crash-*.txt" |
| `Sleep` | Pause | "Paused (system sleep)" |
| `Resume` | Resume | "Resuming…" then back to Active |
**Health-check before Start engine**: GUI's Start button first runs `internal/checker.Run` with a reduced subset (tcp + greet + udp tests, 2s budget, no voice-quality). If any fails, the engine doesn't start and the GUI shows what failed. Prevents the "I clicked Start but Discord still doesn't work" mystery.
**Heartbeat timer**: every 5s, sample `(rxBytes_now - rxBytes_5sAgo) > 0`. If false for 30s while Active and procscan reports Discord PIDs > 0, set status=`Active (no traffic)` (informational sub-state, tray green→yellow but state machine stays in Active). User sees this and can investigate (Discord might just be idle).
**Crash dumps**: panic recover in any engine goroutine writes `%PROGRAMDATA%\Drover\logs\crash-YYYYMMDD-HHMMSS.txt` with full stack + goroutine dump + version. Then transitions to Failed.
## WinDivert layer
### Filter expression (rebuilt on PID list change)
```
outbound and (tcp or udp) and ip
and (processId == 12345 or processId == 67890 or ...)
and processId != <own_pid>
and ip.DstAddr != <upstream_proxy_ip>
and not (ip.DstAddr >= 224.0.0.0 and ip.DstAddr <= 239.255.255.255)
and not (ip.DstAddr >= 127.0.0.0 and ip.DstAddr <= 127.255.255.255)
and not (ip.DstAddr >= 169.254.0.0 and ip.DstAddr <= 169.254.255.255)
```
Notes:
- `ip` (IPv4) only — no `ipv6` clause. Discord client falls back to v4 in ~150ms via Happy Eyeballs.
- `processId != own_pid` is critical — without it our own SOCKS5 traffic to upstream gets caught and infinite-looped.
- Multicast/loopback/link-local explicitly excluded (Discord never talks to those, but extra safety).
If the upstream proxy IP cannot be resolved at engine start, we fail-stop with a clear message — we cannot build a correct filter without it.
### Library choice
Use `github.com/imgk/divert-go` v0.1.0 (existing dep proposal — verify it still maintained when implementing P2.1). If unmaintained / broken, write thin syscall bindings directly — WinDivert C API is small (~6 functions used).
### Driver lifecycle
1. **First run**: extract embedded `WinDivert64.sys` + `WinDivert.dll` from Go `embed.FS` into `%PROGRAMDATA%\Drover\windivert\`. SHA256-verify against expected hashes (compiled in at build time).
2. **Open handle**: `WinDivertOpen(filter, layer=NETWORK, priority=0, flags=0)`. The driver auto-installs as a Windows service named "WinDivert" on first open.
3. **Driver remains installed across reboots** — we don't uninstall on Stop. Uninstaller (Inno Setup) explicitly does `sc stop WinDivert && sc delete WinDivert` on uninstall.
### Driver edge cases (D-series in matrix)
- **D-1: not installed** → embedded copy + auto-install on WinDivertOpen.
- **D-2: old v1.x** (zapret legacy) → `WinDivertOpen` returns `ERROR_DRIVER_FAILED_PRIOR_UNLOAD`. Detect: query service "WinDivert" via `OpenServiceW` + `QueryServiceStatusEx` to read binary path → check version resource. Show "Outdated WinDivert detected from another tool. Stop the other tool and reboot."
- **D-3: corrupted .sys** → SHA256 mismatch on extract. Reinstall path (delete + recopy + retry).
- **D-4: AV quarantine** → embedded bytes don't match expected → show specific error: "Antivirus may have quarantined WinDivert64.sys. Add `%PROGRAMDATA%\Drover\` to your AV exclusions and restart Drover."
- **D-5: reboot pending** → install successful but service not started → show "Reboot required to activate driver" with no retry button.
- **D-7: ARM64** → `runtime.GOARCH` check at startup; on ARM64 show "Drover requires x86-64 Windows. WinDivert does not support ARM64."
## TCP redirect (NAT-loopback)
### Mechanism
1. On engine start, bind a TCP listener on `127.0.0.1:0` (OS picks unused port). Save the port number.
2. WinDivert sees a new SYN from `Discord.exe → real_target_ip:real_target_port`. Engine:
a. Modifies the IP header: `dst_addr = 127.0.0.1`, `dst_port = listener_port`. Stores mapping `(src_port → real_target_ip:port)` in a `sync.Map` with TTL 30 min.
b. Recomputes IP + TCP checksums.
c. Reinjects via `WinDivertSend` with direction=outbound. The kernel routes to loopback because dst is now 127.0.0.1.
3. Listener `accept()` returns a conn from `127.0.0.1:src_port`. Engine looks up mapping by `src_port`, finds real_target.
4. Engine opens fresh SOCKS5 control TCP to upstream, does greet + (auth if config) + CONNECT to real_target_ip:port.
5. Once SOCKS5 returns REP=00, `io.Copy` pumps bytes both directions until EOF on either side.
6. Conn close → drop mapping.
### TCP edge cases
- **T-1: listener bind fails** → fail-stop "could not bind loopback listener". Should never happen (random unused port).
- **T-2: 100+ concurrent flows** — sync.Map scales fine. Bound only by Discord's TCP usage (typically 50).
- **T-3: TCP retransmits** — handled by OS at both sides of the loopback.
- **T-4: IPv6** — dropped at filter level. Discord falls back to v4.
- **T-5: half-closed** — `io.Copy` returns on EOF in one direction; we close the other side via `defer conn.Close()`.
- **T-6: mapping leak** if conn never properly closes — TTL 30min sweeper goroutine deletes stale entries.
## UDP redirect (SOCKS5 UDP ASSOCIATE)
### Mechanism
1. WinDivert sees outbound UDP from `Discord.exe:src_port → real_target_ip:port`. Engine:
a. Looks up mapping by `(src_ip, src_port, real_target_ip, real_target_port)`. If absent:
b. **Open new SOCKS5 control TCP** to upstream. Greet + (auth) + UDP ASSOCIATE.
c. Receive relay endpoint `(relay_ip, relay_port)` — if BND.ADDR is `0.0.0.0` substitute `upstream_proxy_ip`.
d. Open client-side UDP socket on `127.0.0.1:0`. Save mapping `flow_id → {control_tcp, relay, client_udp}`.
2. **Outbound packet path**: encap with SOCKS5 UDP header `00 00 | 00 | ATYP=01 | DST_IP(4) | DST_PORT(2) | DATA`. Send via `client_udp.WriteTo(packet, relay)`. Don't reinject the original packet — drop it (we sent the encapsulated version through the relay).
3. **Inbound packet path** (separate goroutine per flow): `client_udp.ReadFrom(buf)` → strip 10-byte SOCKS5 header → fabricate an IPv4+UDP packet with `src=real_target_ip:port, dst=Discord_src_ip:src_port`, recompute checksums → `WinDivertSend` direction=inbound. Discord sees a normal reply from real_target.
4. Idle TTL 5 min: any flow with no packets for 5 min → close control_tcp + client_udp + remove mapping.
### UDP edge cases
- **U-1**: each flow gets its own control TCP. No pool in v1 (overhead is ~5KB per flow, fine for ~10 active flows).
- **U-2: idle leak** → 5min TTL.
- **U-3: Discord changes voice region** mid-call → old flow goes idle (5min TTL), new flow opens. Brief glitch.
- **U-4: UDP fragments** → SOCKS5 RFC 1928 doesn't support FRAG. Drop. Discord packets are typically <1500 bytes; fragmentation rare.
- **U-5: control TCP dies** → next packet detects via `Write` error → close mapping → next-next packet opens fresh control. Audio glitch ~2-3s.
## Process scanning
### Mechanism
`internal/procscan` runs every 2 seconds:
1. `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)` → enumerate via `Process32First`/`Process32Next`. Microseconds.
2. Filter by `szExeFile` against config `targets.processes` (case-insensitive on Windows).
3. Diff vs previous PID set. If different → notify engine to rebuild filter expression and reopen WinDivert handle.
### Race: Discord starts up to 2s before procscan catches it
Mitigation: at engine `Start`, do **synchronous initial scan** before opening WinDivert handle. After that, the periodic 2s tick handles ongoing changes.
### Process edge cases
- **P-1: Discord PID changes** → 2s scan + 50ms reopen gap with direct traffic. Acceptable.
- **P-2: multiple Discord variants**: default config includes `Discord.exe`, `DiscordCanary.exe`, `DiscordPTB.exe`, `Update.exe`. Vesktop **opt-in** via config (not default).
- **P-3: Update.exe** (Discord's updater) included in default — it downloads patches via HTTP and we want those proxied too.
- **P-5: PID re-use** (Discord exits, Chrome takes the PID before next scan) → 2s window where Chrome packets get proxied. Cosmetic, low-impact.
## Self-loop protection
The engine itself opens TCP/UDP connections to the upstream proxy. Without protection, the WinDivert filter would catch our own packets, encapsulate them in another SOCKS5 layer, infinite loop in seconds.
Three layers of defense:
1. `processId != own_pid` in the filter expression.
2. `ip.DstAddr != <upstream_proxy_ip>` (resolved once at engine start; if upstream uses DDNS we re-resolve every 30s of failed reconnects).
3. Listener and SOCKS5 client always bind to `127.0.0.1` — even if filter leaks, loopback traffic is excluded by `not (ip.DstAddr >= 127.0.0.0 ...)`.
## UAC + autostart (B1)
### Elevation
`cmd/drover/main.go` startup sequence:
```go
func main() {
// 1. AttachConsole for CLI compatibility (existing)
attachConsole()
// 2. Single-instance check (mutex). If second instance, send "show" to first and exit.
if !single.AcquireMutex() {
single.ActivateExistingInstance()
os.Exit(0)
}
// 3. Parse Cobra commands. CLI sub-commands like `--check` and `--version` don't need admin
// and can run as user. The default GUI mode requires admin for WinDivert.
if cmdNeedsAdmin() && !uac.IsAdmin() {
uac.ReElevate(os.Args[1:]) // ShellExecute("runas", ...) + exit
os.Exit(0)
}
// 4. Auto-update check (existing). Replace exe + relaunch if needed.
autoUpdateOnStartup()
// 5. Boot Wails GUI + engine.
gui.Run(Version)
}
```
`uac.ReElevate` uses `ShellExecuteW` with `lpVerb="runas"`. If user cancels UAC, `ShellExecute` returns `SE_ERR_ACCESSDENIED` → we exit cleanly without an error dialog (the user already saw their cancel intent).
### Autostart
Implemented via `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\DroverGo`:
- Value type: REG_SZ, value: full path to `drover.exe` with no args
- Set on toggle ON, deleted on toggle OFF
- GUI Settings tab has a checkbox "Запускать при входе в Windows" that reads/writes this key
**Edge case A-5**: User disables autostart via Task Manager → Startup Apps. Windows writes a `Disabled` mark in `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run`. On GUI mount we check both keys; if Disabled → checkbox shown unchecked (user wins).
**Edge case A-6**: Stale path (drover.exe was moved). On every launch we re-write the key value to `os.Executable()` if autostart is enabled. Self-healing.
## Tray + window (D1)
### Tray icon (4 ICO files embedded)
| State | Icon | When shown |
|---|---|---|
| `idle` | grey | Engine not running |
| `active` | green | Engine running, traffic flowing |
| `reconnecting` | yellow | Reconnecting state OR no-traffic-detected |
| `error` | red | Failed state |
### Tray menu (right-click)
```
[●] Active · 2h 14m · ↑ 142 KB/s ↓ 1.2 MB/s [disabled status row, dynamic]
─────────────────────────────────────
[⏸] Stop proxying [primary action, contextual]
[🔍] Run check [opens window + auto-runs check]
─────────────────────────────────────
[🪟] Show window [hidden when window is visible]
[📁] Open log file
─────────────────────────────────────
[🔄] Check for updates
[] About
─────────────────────────────────────
[✕] Quit
```
The status row is updated every 1s while engine is running.
### Click behaviors
- Single-click tray icon → toggle window visibility
- Double-click tray icon → open window (no toggle, always show)
- X on window title bar → hide to tray (D1)
- First-time only: toast "Drover свёрнут в трей. Engine продолжает работать. Закрыть полностью — через меню трея → Quit." Track via `config.ui.shown_tray_toast = true`.
- Quit from tray menu → graceful engine stop → exit cleanly
### Library
`github.com/getlantern/systray`. Stable on Win10/11 modulo the explorer-restart edge case which the library handles internally.
## Single-instance enforcement
Mutex name: `Global\DroverGoInstance-<installID>` where `installID = SHA256(os.Executable())[:16]`. This way:
- Installed copy at `C:\Program Files\Drover\drover.exe` and a portable copy at `D:\portable\drover.exe` get different mutexes — both can run.
- Two simultaneous launches of the same install fight over the mutex; second loses.
Activation pipe: `\\.\pipe\drover-gui-<installID>`. Second instance opens it, writes `{"action":"show"}`, closes. First instance's listener goroutine pops the window to foreground.
If first instance crashes without cleanup → mutex disappears at process death (kernel handle table cleanup). Next launch acquires normally.
## Sleep/resume handling
`WM_POWERBROADCAST` listener via Windows message loop in a dedicated goroutine. Uses `RegisterPowerSettingNotification` for fine-grained events.
| Event | Action |
|---|---|
| `PBT_APMSUSPEND` | Engine: drain in-flight packets (give 200ms), close all SOCKS5 control TCPs, close WinDivert handle, set status="paused (sleep)" |
| `PBT_APMRESUMEAUTOMATIC` or `PBT_APMRESUMESUSPEND` | Wait 5s for network reconnect (poll `GetIpForwardTable2` for default route presence), reopen WinDivert handle, run health-check, transition Active |
## Stats counters
Atomic counters in `internal/engine/stats.go`:
- `bytesIn uint64` — bytes received from upstream (decapsulated UDP + TCP `io.Copy` returns)
- `bytesOut uint64` — bytes sent to upstream
- `tcpFlowsActive int32` — current count of open TCP redirects
- `udpFlowsActive int32` — current count of open UDP flows
- `startedAt time.Time` — engine start time (for uptime)
Per-flow counters discarded on flow close (no aggregation needed for v1).
Tray status row updates from these every 1s. GUI live stats panel does the same via Wails event `stats:update` (existing path).
Lifetime totals persisted to `%PROGRAMDATA%\Drover\stats.json` every 60s and on Stop.
## Config schema (TOML)
`%APPDATA%\Drover\config.toml`:
```toml
# Drover-Go config — auto-managed by GUI; manual edits hot-reload via fsnotify.
version = 1
[proxy]
host = "95.165.72.59"
port = 12334
auth = false
login = ""
password = ""
udp_associate_timeout = "5s"
tcp_connect_timeout = "10s"
[targets]
processes = ["Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"]
include_vesktop = false
[skip]
# CIDR ranges to never proxy. Local + link-local always implicitly skipped at filter level.
extra_skip_cidrs = []
multicast = true
[ui]
log_level = "info"
log_max_mb = 10
log_backups = 3
tray_icon = true
auto_start = false # mirror of HKCU\...\Run
shown_tray_toast = false # one-shot first-close toast tracking
theme = "dark" # dark | light | auto
[update]
check_on_startup = true
forgejo_repo = "git.okcu.io/root/drover-go"
[engine]
heartbeat_interval = "5s"
no_traffic_warn_after = "30s"
reconnect_backoff_initial = "1s"
reconnect_backoff_max = "30s"
reconnect_total_cap = "5m"
```
Edge cases:
- **M-4 corrupted TOML** → log warning + use defaults + GUI shows banner "Config error line N — running with defaults".
- **M-7 hot-reload** → fsnotify on the file. On change: re-parse → if proxy section changed → engine restart (Stop → wait clean → Start). Other sections apply live.
- **Config migration** v1→v2 handled by `version` field; missing version assumes 1.
## Edge case matrix (full)
This is the master list. Every row must have a corresponding test or explicit "verified manually" note in the implementation plan.
| # | Edge case | Mitigation | Test |
|---|---|---|---|
| **D-1** | WinDivert.sys not installed | Embed binary, copy to %PROGRAMDATA%, WinDivertOpen auto-loads | manual: clean Win11 VM |
| **D-2** | Old WinDivert v1.x present (zapret legacy) | Service version query → "remove old version first" error | manual: install zapret first, verify error |
| **D-3** | Driver corrupted | SHA256 verify on extract → reinstall flow with progress | unit test: SHA256 mismatch path |
| **D-4** | AV quarantines our embedded .sys | Specific AV-friendly error message + README link | manual: Defender enabled + first run |
| **D-5** | Reboot pending after install | Show "Reboot to activate driver" | manual: trigger via DISM |
| **D-7** | ARM64 Windows | Detect at startup, refuse install | unit: GOARCH=arm64 build returns expected error |
| **P-1** | Discord PID changes | 2s procscan + filter rebuild | integration: kill+restart Discord, verify continuity |
| **P-3** | Update.exe traffic | Default list includes it | integration: trigger Discord update, verify Update.exe traffic proxied |
| **P-5** | PID re-use | Cosmetic 2s window | accept |
| **L-1** | Self-loop (drover's own SOCKS5 traffic) | Filter excludes own_pid + upstream IP | unit: filter expression builder verifies own PID in output |
| **T-4** | IPv6 Discord targets | Drop at filter level; Happy Eyeballs falls back | manual: verify with `netsh interface ipv6 set route ::/0 disabled` |
| **T-6** | TCP mapping leak | 30min TTL cleanup | unit: TTL sweeper test |
| **U-2** | Idle UDP flow leak | 5min TTL cleanup | unit: TTL sweeper test |
| **U-4** | UDP fragments | Drop (SOCKS5 doesn't support FRAG) | accept (rare) |
| **A-1** | User non-admin | UAC re-launch on startup | manual: standard user account |
| **A-2** | UAC cancelled | Clean exit, no error dialog | manual: cancel UAC prompt |
| **A-3** | UAC at every login (autostart) | Accepted per B1 | document in README |
| **A-5** | Autostart disabled via Task Manager | Detect StartupApproved key, sync GUI checkbox | unit: registry mock |
| **TR-1** | Tray icon disappears on explorer.exe restart | systray library handles re-attach | manual: kill+restart explorer.exe |
| **TR-3** | First-time tray toast | Track `ui.shown_tray_toast` in config | unit: config writer |
| **SI-1** | Mutex collision portable vs installed | installID = SHA256(exe path)[:16] | unit: two paths → two mutexes |
| **SI-3** | First instance crashed without cleanup | Kernel cleans mutex on process death | manual: kill -9 first, launch second |
| **SR-1** | System sleep | WM_POWERBROADCAST listener → graceful pause | manual: trigger sleep on test machine |
| **SR-2** | System resume | Wait 5s network → reopen handle → resume | manual: wake from sleep |
| **UP-1** | Auto-update during active engine | Graceful shutdown → replace exe → relaunch with prior state | manual: stage v0.1 → v0.2 update during voice call |
| **M-1** | VPN concurrent | WinDivert ловит до VPN encap; SOCKS5 traffic to upstream IP — норма | manual: with WireGuard + Drover both active |
| **M-4** | Config corrupted | Use defaults + warning banner | unit: malformed TOML → defaults applied |
| **M-5** | Proxy IP changed (DDNS) | Re-resolve hostname every 30s of failed reconnect | unit: hostname resolver retry |
| **M-7** | Hot-reload config | fsnotify → engine restart | integration: edit TOML, observe restart |
## Out of scope (Phase 3+)
- DPI bypass / fake QUIC injection (decision **C1**) — add as opt-in toggle in v0.4 if needed
- Windows service mode (decision **A**) — add for power users in v0.4 if requested
- IPv6 SOCKS5 ATYP=04 — add when we hit a v6-only proxy
- ARM64 Windows — add when WinDivert ships ARM64 driver (waiting on basil00 upstream)
- Multi-user PC scenarios — single-user assumption baked in
- Vesktop default-on — stays opt-in via `targets.include_vesktop = true`
- Custom DNS resolver / DNS-over-proxy — out of scope; DNS goes direct, document in README
## Phase 2 milestones
Each milestone is a separate `writing-plans` invocation followed by `subagent-driven-development` execution.
### P2.1 — TCP-only MVP (3-4 days)
**Scope**: WinDivert handle, filter expression, packet parser, TCP NAT-loopback redirect, SOCKS5 client (TCP CONNECT only), procscan, self-loop protection, basic engine state machine (Idle/Starting/Active/Failed without Reconnecting yet).
**Acceptance**:
- Run drover.exe on Win11 with admin
- Discord chat + Discord API requests routed through SOCKS5 (verify via tcpdump on mihomo: should see TCP CONNECT to discord.com:443 from upstream IP)
- Voice does NOT yet work (UDP path absent) — documented expectation
- Stop button cleanly closes everything in <500ms
- Driver remains installed after exit (verify `sc query WinDivert`)
- No self-loop infinite traffic (verify: bytes in == bytes out, not exponentially growing)
### P2.2 — UDP voice (3-4 days)
**Scope**: SOCKS5 UDP ASSOCIATE primitives (production-grade, not the diagnostic-only fork in checker), UDP flow tracker, packet encap/decap, IPv4-fabrication-and-reinject for inbound path.
**Acceptance**:
- Voice call in Discord through proxy works without audible degradation
- Up to 4 simultaneous voice calls (ish) work without flow leakage
- Idle voice flow cleanup at 5min TTL (verified via debug log)
- Mid-call proxy disconnect → flow drops → re-opens within 2s on next outbound packet → ~2-3s audible glitch
- No memory leak after 1h voice call (RSS stable ±5MB)
### P2.3 — E3 recovery + sleep/resume (2 days)
**Scope**: failure classifier, contextual retry policies, Reconnecting state, exponential backoff, WM_POWERBROADCAST listener, heartbeat health-check.
**Acceptance**:
- Stop mihomo on LXC 102 mid-session → engine transitions Active → Reconnecting → Active when mihomo back up (within 30s of recovery)
- Trigger machine sleep mid-voice-call → engine pauses gracefully → wake → engine resumes within 10s after network up → voice continues (Discord client itself reconnects)
- WinDivert handle externally killed (`sc stop WinDivert && sc start WinDivert`) → engine reopens once → if second kill within 30s → Failed with crash log
- Heartbeat detects "no traffic" while Discord open and idle → tray turns yellow with "no traffic" tooltip → no Failed transition
### P2.4 — Tray + autostart + engine UI (2-3 days)
**Scope**: getlantern/systray integration, 4 ICO icons, tray menu (D1 + first-time toast), autostart checkbox in GUI Settings tab, Start/Stop buttons in main window wired to engine, status indicator with state machine awareness, single-instance enforcement.
**Acceptance**:
- Toggle autostart on → reboot → drover launches at login (after UAC accept)
- X on window → first-time toast → second X → silent hide
- Start button only enabled when checker passed (or in Failed state with Retry)
- Tray icon updates within 200ms of state change
- Two simultaneous launches → second activates first's window and exits silently
- Status row in tray menu updates every 1s while Active
### P2.5 — Polish (2-3 days)
**Scope**: crash dumps, config hot-reload via fsnotify, AV-friendly error messages, all remaining edge cases from matrix, README troubleshooting, install/uninstall verification on clean Win11 VM.
**Acceptance**:
- Every edge case in the matrix has either a passing test or a verified manual reproduction note in `docs/testing/p2-edge-cases.md`
- Install on clean Win11 VM, run for 1 hour without intervention, no errors
- Uninstall via Apps & Features removes everything except optionally-kept config (asked at uninstall)
- README has SmartScreen + AV troubleshooting sections with screenshots
**Total**: ~12-16 days to v1.0.0.
## Testing strategy
### Unit tests (per-package)
- `divert/filter`: filter expression builder produces expected strings for various PID lists
- `divert/packet`: parse + serialize + checksum recompute is round-trip identity
- `engine/recovery`: failure classifier returns expected Action for each FailureClass
- `socks5/udp`: encap/decap round-trip
- `procscan`: snapshot diffing, mocked toolhelp32
- `autostart`: registry read/write/disabled-detection (with mock registry)
- `single`: mutex acquire + release lifecycle
- `config`: defaults applied, malformed TOML → defaults + warning, version migration
### Integration tests (each milestone has its own)
- `engine_test.go`: mock WinDivert + mock SOCKS5 server in-process, exercise full pipeline
- `redirect_test.go`: spin up TCP listener, fake Discord client, fake SOCKS5 server, verify bytes flow
### Manual test plan (per milestone, in `docs/testing/p2-<milestone>-manual.md`)
Each manual test case is a numbered step-by-step with expected outcome. Run on clean Win11 VM snapshot before each milestone tag.
### End-to-end (manual, before v1.0.0)
Full user journey in `docs/testing/p2-e2e.md`:
1. Download installer from Forgejo release
2. Install via setup.exe (UAC prompt)
3. First launch: configure proxy, run check, click Start
4. Run Discord, place voice call → verify routing via mihomo logs
5. Toggle autostart on
6. Reboot → verify drover starts at login (UAC accept)
7. Sleep + wake cycle → verify continuity
8. Stop mihomo → verify Reconnecting state → restart mihomo → verify recovery
9. Quit via tray menu → verify clean shutdown
10. Uninstall → verify cleanup
## Open questions / assumptions to validate during P2.1
1. **`imgk/divert-go` v0.1.0 still works with WinDivert v2.2.2?** If not, switch to direct syscall bindings. Verify in P2.1 day 1.
2. **Filter expression length limit** — WinDivert filter expressions have a max length. With 4 Discord PIDs + own PID + upstream IP exclusion + multicast we should be well under, but if user adds 10+ Vesktop variants we might hit it. Verify and document limit during P2.1.
3. **`WinDivertSend` for inbound packets we synthesize** — does the kernel correctly route a fabricated `dst=Discord_IP, src=real_target_IP` packet back to Discord's socket? Most divert-based tools do this; verify in P2.2 day 1 with a tracer.
4. **Embedded ICO size on disk** — 4 icons × ~5KB = 20KB. Negligible.
## Files to read before implementation
- `imgk/shadow/pkg/divert/` — opens handle + read packets pattern (downloaded already)
- `imgk/divert-go` README + `addr.go` — API surface
- `runetfreedom/force-proxy/proxy.cpp` — correct SOCKS5 UDP ASSOCIATE flow (local at `/tmp/drover-cmp/force-proxy/`)
- `wailsapp/wails/v2/examples/react` — Wails patterns for Engine bindings
- This spec.
+1
View File
@@ -20,6 +20,7 @@ require (
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/imgk/divert-go v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect
+2
View File
@@ -15,6 +15,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/imgk/divert-go v0.1.0 h1:PTB6jsmj5j2ymDBaiyhh0mzQ8ldma10mNaq1tknJysM=
github.com/imgk/divert-go v0.1.0/go.mod h1:8j670dnMAWuHP3AHj7Zd8b4HhGw4mdTo8aYhCWNsAeU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
-2
View File
@@ -1,2 +0,0 @@
// Package app wires the Wails application (Go ↔ JS bindings).
package app
-2
View File
@@ -1,2 +0,0 @@
// Package bypass implements DPI bypass via fake QUIC injection.
package bypass
+44 -193
View File
@@ -123,7 +123,6 @@ func Run(ctx context.Context, cfg Config) <-chan Result {
} }
e.runConnect() e.runConnect()
e.runUDP() e.runUDP()
e.runVoiceQuality()
e.runAPI() e.runAPI()
}() }()
@@ -522,6 +521,38 @@ func (e *executor) runConnect() {
} }
// runUDP — Test 5: open second TCP control channel and UDP ASSOCIATE. // runUDP — Test 5: open second TCP control channel and UDP ASSOCIATE.
// isUnroutableRelayIP returns true for IPs we shouldn't trust as the
// real relay endpoint when the proxy advertised them in BND.ADDR:
// 0.0.0.0 (per RFC 1928 spec), private RFC 1918 ranges (mihomo on a
// LAN can return its 192.168.x.x interface), and loopback. Caller
// should substitute the proxy host instead.
func isUnroutableRelayIP(ip net.IP) bool {
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() {
return true
}
v4 := ip.To4()
if v4 == nil {
return false
}
// 10.0.0.0/8
if v4[0] == 10 {
return true
}
// 172.16.0.0/12
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
return true
}
// 192.168.0.0/16
if v4[0] == 192 && v4[1] == 168 {
return true
}
// 169.254.0.0/16 (link-local)
if v4[0] == 169 && v4[1] == 254 {
return true
}
return false
}
func (e *executor) runUDP() { func (e *executor) runUDP() {
dep := e.greetOK && (!e.cfg.UseAuth || e.authOK) dep := e.greetOK && (!e.cfg.UseAuth || e.authOK)
if e.shouldSkip("udp", dep) { if e.shouldSkip("udp", dep) {
@@ -552,204 +583,24 @@ func (e *executor) runUDP() {
if uerr != nil { if uerr != nil {
return "", uerr return "", uerr
} }
// RFC 1928 says when BND.ADDR == 0.0.0.0, substitute the proxy
// host. We extend that: when the proxy returns a *private* IP
// (mihomo on LAN often advertises its 192.168.x.x interface
// because that's the iface it bound), it's unreachable for
// clients outside that LAN — substitute with the proxy host
// the user is already connecting to.
if isUnroutableRelayIP(relay.IP) {
if hostIP := net.ParseIP(e.cfg.ProxyHost); hostIP != nil {
relay.IP = hostIP
}
}
e.udpRelay = relay e.udpRelay = relay
return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil
}) })
e.udpOK = ok e.udpOK = ok
} }
// runVoiceQuality — Test 6: 30-packet STUN burst through the SOCKS5 UDP // runAPI — Test 6: HTTP GET Discord API gateway URL through the proxy.
// relay. Computes loss, jitter, p50/p95 RTT and gates on thresholds:
//
// - StatusPassed: loss ≤ 5%, jitter ≤ 30ms, p50 ≤ 250ms.
// - StatusWarn: loss ≤ 15%, jitter ≤ 60ms, p50 ≤ 400ms — voice will
// work but with audible glitches.
// - StatusFailed: anything worse, OR no replies at all.
//
// On warn/pass, voiceQualityOK is true (downstream tests proceed). On
// failure it stays false.
func (e *executor) runVoiceQuality() {
if e.shouldSkip("voice-quality", e.udpOK) {
return
}
host, portStr, splitErr := net.SplitHostPort(e.cfg.StunServer)
if splitErr != nil {
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()),
Hint: hintFor("voice-quality", splitErr),
Attempt: 1,
})
return
}
port64, perr := strconv.ParseUint(portStr, 10, 16)
if perr != nil {
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()),
Hint: hintFor("voice-quality", perr),
Attempt: 1,
})
return
}
stunPort := uint16(port64)
maxAttempts := 1 + e.cfg.MaxRetries
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := e.ctx.Err(); err != nil {
e.emitCancelled("voice-quality", attempt, 0)
return
}
e.emit(Result{ID: "voice-quality", Status: StatusRunning, Attempt: attempt})
// Per-test budget: cap burst+listen at PerTestTimeout.
attemptCtx, cancel := context.WithTimeout(e.ctx, e.cfg.PerTestTimeout)
start := time.Now()
// Open a fresh local UDP socket per attempt.
if e.udpClient != nil {
_ = e.udpClient.Close()
e.udpClient = nil
}
pc, perr := net.ListenPacket("udp", ":0")
if perr != nil {
cancel()
dur := time.Since(start)
class := classifyError(perr)
canRetry := class == ClassificationTransient && attempt < maxAttempts
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: fmt.Sprintf("voice-quality: listen udp: %s", perr.Error()),
Hint: hintFor("voice-quality", perr),
Attempt: attempt,
Duration: dur,
})
if canRetry {
select {
case <-time.After(e.cfg.RetryBackoff):
continue
case <-e.ctx.Done():
return
}
}
return
}
e.udpClient = pc
res, berr := runVoiceQualityBurst(
attemptCtx, pc, e.udpRelay,
host, stunPort,
e.cfg.VoiceBurstCount, e.cfg.VoiceBurstInterval,
)
dur := time.Since(start)
cancel()
if berr != nil {
// Resolution / cancellation. Treat ctx-cancel separately.
if e.ctx.Err() != nil {
e.emitCancelled("voice-quality", attempt, dur)
return
}
class := classifyError(berr)
canRetry := class == ClassificationTransient && attempt < maxAttempts
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: berr.Error(),
Hint: hintFor("voice-quality", berr),
Attempt: attempt,
Duration: dur,
})
if canRetry {
select {
case <-time.After(e.cfg.RetryBackoff):
continue
case <-e.ctx.Done():
return
}
}
return
}
// 100% loss with no underlying error → the relay accepted UDP
// (per test 5) but nothing came back. Treat as transient on
// the first attempt; permanent on the second.
if res.Received == 0 {
canRetry := attempt < maxAttempts
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: "no replies received",
Hint: voiceQualityFailHint(100.0, 0, 0, 0),
Metric: "loss=100%",
Attempt: attempt,
Duration: dur,
})
if canRetry {
select {
case <-time.After(e.cfg.RetryBackoff):
continue
case <-e.ctx.Done():
return
}
}
return
}
metric := fmt.Sprintf("loss=%.0f%% jitter=%.1fms p50=%.1fms",
res.LossPct, res.JitterMS, res.P50RTTMS)
switch {
case res.LossPct <= 5.0 && res.JitterMS <= 30.0 && res.P50RTTMS <= 250.0:
e.emit(Result{
ID: "voice-quality",
Status: StatusPassed,
Metric: metric,
Attempt: attempt,
Duration: dur,
})
e.voiceQualityOK = true
return
case res.LossPct <= 15.0 && res.JitterMS <= 60.0 && res.P50RTTMS <= 400.0:
e.emit(Result{
ID: "voice-quality",
Status: StatusWarn,
Metric: metric,
Hint: voiceQualityWarnHint(res.LossPct, res.JitterMS, res.P50RTTMS),
Attempt: attempt,
Duration: dur,
})
e.voiceQualityOK = true
return
default:
canRetry := attempt < maxAttempts
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: metric,
Metric: metric,
Hint: voiceQualityFailHint(res.LossPct, res.JitterMS, res.P50RTTMS, res.P95RTTMS),
Attempt: attempt,
Duration: dur,
})
if canRetry {
select {
case <-time.After(e.cfg.RetryBackoff):
continue
case <-e.ctx.Done():
return
}
}
return
}
}
}
// runAPI — Test 7: HTTP GET Discord API gateway URL through the proxy.
func (e *executor) runAPI() { func (e *executor) runAPI() {
if e.shouldSkip("api", e.connectOK) { if e.shouldSkip("api", e.connectOK) {
return return
+5 -66
View File
@@ -579,7 +579,7 @@ func TestRun_HappyNoAuth(t *testing.T) {
ch := Run(context.Background(), cfg) ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second) results := drainResults(t, ch, 10*time.Second)
expected := []string{"tcp", "greet", "connect", "udp", "voice-quality", "api"} expected := []string{"tcp", "greet", "connect", "udp", "api"}
finals := map[string]Result{} finals := map[string]Result{}
for _, id := range expected { for _, id := range expected {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
@@ -598,7 +598,6 @@ func TestRun_HappyNoAuth(t *testing.T) {
// Metrics format spot-checks. // Metrics format spot-checks.
assert.Contains(t, finals["greet"].Metric, "no auth") assert.Contains(t, finals["greet"].Metric, "no auth")
assert.Equal(t, "REP=00", finals["connect"].Metric) assert.Equal(t, "REP=00", finals["connect"].Metric)
assert.Contains(t, finals["voice-quality"].Metric, "loss=")
assert.Equal(t, "HTTP 200", finals["api"].Metric) assert.Equal(t, "HTTP 200", finals["api"].Metric)
} }
@@ -612,7 +611,7 @@ func TestRun_HappyWithAuth(t *testing.T) {
ch := Run(context.Background(), cfg) ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second) results := drainResults(t, ch, 10*time.Second)
expected := []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "api"} expected := []string{"tcp", "greet", "auth", "connect", "udp", "api"}
for _, id := range expected { for _, id := range expected {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s; results=%+v", id, results) require.True(t, ok, "missing %s; results=%+v", id, results)
@@ -643,7 +642,7 @@ func TestRun_AuthRejected(t *testing.T) {
assert.Equal(t, StatusFailed, rA.Status) assert.Equal(t, StatusFailed, rA.Status)
assert.NotEmpty(t, rA.Hint) assert.NotEmpty(t, rA.Hint)
for _, id := range []string{"connect", "udp", "voice-quality", "api"} { for _, id := range []string{"connect", "udp", "api"} {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id) assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
@@ -668,7 +667,7 @@ func TestRun_AllMethodsRejected(t *testing.T) {
assert.Equal(t, StatusFailed, rG.Status) assert.Equal(t, StatusFailed, rG.Status)
assert.NotEmpty(t, rG.Hint) assert.NotEmpty(t, rG.Hint)
for _, id := range []string{"connect", "udp", "voice-quality", "api"} { for _, id := range []string{"connect", "udp", "api"} {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id) assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
@@ -700,10 +699,6 @@ func TestRun_ConnectRefused(t *testing.T) {
rU, _ := finalByID(results, "udp") rU, _ := finalByID(results, "udp")
assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect") assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect")
// voice-quality depends on udp → passes too.
rVQ, _ := finalByID(results, "voice-quality")
assert.Equal(t, StatusPassed, rVQ.Status)
// api depends on connect → skipped. // api depends on connect → skipped.
rA, _ := finalByID(results, "api") rA, _ := finalByID(results, "api")
assert.Equal(t, StatusSkipped, rA.Status) assert.Equal(t, StatusSkipped, rA.Status)
@@ -728,10 +723,6 @@ func TestRun_UDPUnsupported(t *testing.T) {
require.Equal(t, StatusFailed, rU.Status) require.Equal(t, StatusFailed, rU.Status)
assert.NotEmpty(t, rU.Hint) assert.NotEmpty(t, rU.Hint)
// voice-quality depends on udp → skipped.
rVQ, _ := finalByID(results, "voice-quality")
assert.Equal(t, StatusSkipped, rVQ.Status)
rA, _ := finalByID(results, "api") rA, _ := finalByID(results, "api")
assert.Equal(t, StatusPassed, rA.Status) assert.Equal(t, StatusPassed, rA.Status)
} }
@@ -768,7 +759,7 @@ func TestRun_TimeoutThenOK(t *testing.T) {
assert.Equal(t, 2, greetEvents[3].Attempt) assert.Equal(t, 2, greetEvents[3].Attempt)
// All non-auth tests should ultimately pass. // All non-auth tests should ultimately pass.
for _, id := range []string{"tcp", "greet", "connect", "udp", "voice-quality", "api"} { for _, id := range []string{"tcp", "greet", "connect", "udp", "api"} {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r) assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r)
@@ -888,58 +879,6 @@ func TestRun_NegativeRetryClamped(t *testing.T) {
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff) assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
} }
// TestRun_VoiceQualityWarn drives the relay to drop ~1 in 10 packets,
// which puts the burst into the warn band (loss in (5, 15]%, jitter and
// p50 typically tiny on localhost). Asserts StatusWarn and that the
// metric reports a non-zero loss.
func TestRun_VoiceQualityWarn(t *testing.T) {
fp := newFakeProxy(t, "voice_quality_warn")
cfg := proxyConfig(fp, false)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
cfg.StunServer = "127.0.0.1:65000"
// Burst of 30 with 1-in-10 drop → ~3 lost ≈ 10%.
cfg.VoiceBurstCount = 30
cfg.VoiceBurstInterval = 5 * time.Millisecond
cfg.PerTestTimeout = 1 * time.Second
fp.udpDropEveryN.Store(10)
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 15*time.Second)
rVQ, ok := finalByID(results, "voice-quality")
require.True(t, ok)
assert.Equal(t, StatusWarn, rVQ.Status, "got %+v", rVQ)
assert.Contains(t, rVQ.Metric, "loss=")
assert.NotEmpty(t, rVQ.Hint)
}
// TestRun_VoiceQualityFail drives the relay to drop 4 of every 5 packets
// (~80% loss) — well past the fail threshold.
func TestRun_VoiceQualityFail(t *testing.T) {
fp := newFakeProxy(t, "voice_quality_fail")
cfg := proxyConfig(fp, false)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
cfg.StunServer = "127.0.0.1:65000"
cfg.VoiceBurstCount = 30
cfg.VoiceBurstInterval = 3 * time.Millisecond
cfg.PerTestTimeout = 1 * time.Second
cfg.MaxRetries = 0
// Drop everything: dropEveryN=1 means EVERY packet dropped → 100%.
// Use 2 for ~50%, 1 for 100. We want fail-band — pick 1 to guarantee
// "no replies received".
fp.udpDropEveryN.Store(1)
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 15*time.Second)
rVQ, ok := finalByID(results, "voice-quality")
require.True(t, ok)
assert.Equal(t, StatusFailed, rVQ.Status, "got %+v", rVQ)
assert.NotEmpty(t, rVQ.Hint)
}
func TestExtractRawHex(t *testing.T) { func TestExtractRawHex(t *testing.T) {
cases := []struct { cases := []struct {
in, want string in, want string
-2
View File
@@ -1,2 +0,0 @@
// Package config loads and validates the TOML configuration.
package config
-2
View File
@@ -1,2 +0,0 @@
// Package divert wraps WinDivert for kernel-level packet capture.
package divert
-2
View File
@@ -1,2 +0,0 @@
// Package engine orchestrates the packet processing pipeline.
package engine
View File
+59 -16
View File
@@ -6,11 +6,13 @@ package gui
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"math/rand" "math/rand"
"sync" "sync"
"time" "time"
"git.okcu.io/root/drover-go/internal/checker" "git.okcu.io/root/drover-go/internal/checker"
"git.okcu.io/root/drover-go/internal/sboxrun"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
@@ -26,7 +28,7 @@ type App struct {
version string version string
mu sync.Mutex mu sync.Mutex
running bool eng *sboxrun.Engine
startedAt time.Time startedAt time.Time
// muCheck guards cancelCheck and checkDone. // muCheck guards cancelCheck and checkDone.
@@ -172,46 +174,87 @@ func (a *App) CancelCheck() {
} }
} }
// StartEngine flips the proxy on. In the stub we just toggle the flag and // StartEngine initializes and brings up the engine with the given config.
// note the start time so GetStats can produce a believable uptime. func (a *App) StartEngine(cfg Config) error {
func (a *App) StartEngine() error { log.Printf("gui: StartEngine called host=%s port=%d auth=%v", cfg.Host, cfg.Port, cfg.Auth)
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
a.running = true if a.eng != nil && a.eng.Status() == sboxrun.StatusActive {
log.Printf("gui: StartEngine no-op (already active)")
return nil
}
e, err := sboxrun.New(sboxrun.Config{
ProxyHost: cfg.Host,
ProxyPort: cfg.Port,
UseAuth: cfg.Auth,
Login: cfg.Login,
Password: cfg.Password,
TargetProcs: []string{
"Discord.exe",
"DiscordCanary.exe",
"DiscordPTB.exe",
"DiscordSystemHelper.exe", // elevated updater (modern builds)
"Update.exe", // legacy Squirrel updater (older builds)
},
})
if err != nil {
log.Printf("gui: sboxrun.New failed: %v", err)
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
return err
}
if err := e.Start(a.ctx); err != nil {
log.Printf("gui: sboxrun.Start failed: %v", err)
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
return err
}
a.eng = e
a.startedAt = time.Now() a.startedAt = time.Now()
log.Printf("gui: engine started, status=%s", e.Status())
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true}) runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
return nil return nil
} }
// StopEngine turns the proxy off. // StopEngine shuts down the engine.
func (a *App) StopEngine() error { func (a *App) StopEngine() error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
a.running = false if a.eng == nil {
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false})
return nil return nil
}
err := a.eng.Stop()
a.eng = nil
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false})
return err
} }
// GetStatus is read by the frontend on first paint to know whether to // GetStatus returns the current engine state and uptime.
// show "Idle" or "Active".
func (a *App) GetStatus() map[string]any { func (a *App) GetStatus() map[string]any {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
return map[string]any{ running := a.eng != nil && a.eng.Status() == sboxrun.StatusActive
"running": a.running, res := map[string]any{
"running": running,
"uptimeS": int(time.Since(a.startedAt).Seconds()), "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
} }
// statsLoop emits a stats event every second when the engine is running. // statsLoop emits a stats event every second when the engine is active.
// Numbers are random but stable enough to look real. // Numbers are random but stable enough to look real. P2.4 will replace
// with real counters from engine.Engine.
func (a *App) statsLoop() { func (a *App) statsLoop() {
r := rand.New(rand.NewSource(time.Now().UnixNano())) r := rand.New(rand.NewSource(time.Now().UnixNano()))
tick := time.NewTicker(time.Second) tick := time.NewTicker(time.Second)
defer tick.Stop() defer tick.Stop()
for range tick.C { for range tick.C {
a.mu.Lock() a.mu.Lock()
if !a.running || a.ctx == nil { if a.eng == nil || a.eng.Status() != sboxrun.StatusActive || a.ctx == nil {
a.mu.Unlock() a.mu.Unlock()
continue continue
} }
@@ -222,7 +265,7 @@ func (a *App) statsLoop() {
"up": r.Intn(50_000) + 5_000, // bytes/sec out "up": r.Intn(50_000) + 5_000, // bytes/sec out
"down": r.Intn(500_000) + 50_000, // bytes/sec in "down": r.Intn(500_000) + 50_000, // bytes/sec in
"tcp": r.Intn(8) + 1, "tcp": r.Intn(8) + 1,
"udp": r.Intn(5) + 1, "udp": 0, // P2.1 scope: no UDP yet
"uptimeS": uptime, "uptimeS": uptime,
}) })
} }
@@ -23,7 +23,6 @@ export const ALL_TESTS = [
{ id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true }, { id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true },
{ id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' }, { id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' },
{ id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' }, { id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' },
{ id: 'voice-quality', label: 'UDP voice quality', desc: 'Бёрст 30 STUN-пакетов через релей: потери, джиттер, латентность' },
{ id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' }, { id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' },
]; ];
@@ -173,7 +172,18 @@ export function useDrover(initial = {}) {
async function startProxy() { async function startProxy() {
if (phase !== 'checked') return; if (phase !== 'checked') return;
if (lastSummary?.failed === tests.length) return; if (lastSummary?.failed === tests.length) return;
await StartEngine(); try {
await StartEngine({
host: form.host,
port: parseInt(form.port, 10) || 0,
auth: form.auth,
login: form.login,
password: form.password,
});
} catch (e) {
pushLog('ERROR', 'startEngine failed: ' + (e?.message || e));
return;
}
// engine:status event will flip phase to 'active'. // engine:status event will flip phase to 'active'.
} }
+1 -1
View File
@@ -12,7 +12,7 @@
export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) } export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) }
export function CancelCheck() { return window['go']['gui']['App']['CancelCheck']() } export function CancelCheck() { return window['go']['gui']['App']['CancelCheck']() }
export function StartEngine() { return window['go']['gui']['App']['StartEngine']() } export function StartEngine(cfg) { return window['go']['gui']['App']['StartEngine'](cfg) }
export function StopEngine() { return window['go']['gui']['App']['StopEngine']() } export function StopEngine() { return window['go']['gui']['App']['StopEngine']() }
export function GetStatus() { return window['go']['gui']['App']['GetStatus']() } export function GetStatus() { return window['go']['gui']['App']['GetStatus']() }
export function Version() { return window['go']['gui']['App']['Version']() } export function Version() { return window['go']['gui']['App']['Version']() }
-2
View File
@@ -1,2 +0,0 @@
// Package procscan resolves process IDs via Toolhelp32.
package procscan
Binary file not shown.
+147
View File
@@ -0,0 +1,147 @@
package sboxrun
import (
"encoding/json"
"fmt"
)
// Config captures the user-visible proxy settings + which processes
// to route through it. Everything else (TUN interface, log level,
// Clash API endpoint) is hard-coded sensible defaults.
type Config struct {
ProxyHost string // upstream SOCKS5 host
ProxyPort int // upstream SOCKS5 port
UseAuth bool
Login string
Password string
TargetProcs []string // exe names to route via upstream (e.g. ["Discord.exe"])
ClashAPIPort int // 0 → 9090 default
LogLevel string // "info" | "debug" | "warn" — empty → "info"
LogPath string // absolute path for sing-box log output (empty = sing-box stdout, lost when admin-detached)
}
// BuildSingBoxConfig generates the sing-box JSON config string. It's
// a minimal config: TUN inbound (with auto_route + WFP per-process
// rule), SOCKS5 outbound to upstream, direct outbound for everything
// else, and a route rule that sends TargetProcs through the SOCKS5.
//
// Clash API on 127.0.0.1:9090 (or ClashAPIPort) lets the GUI poll
// connection stats live.
func BuildSingBoxConfig(c Config) (string, error) {
if c.ProxyHost == "" || c.ProxyPort == 0 {
return "", fmt.Errorf("ProxyHost and ProxyPort are required")
}
if len(c.TargetProcs) == 0 {
return "", fmt.Errorf("at least one target process is required")
}
logLevel := c.LogLevel
if logLevel == "" {
logLevel = "info"
}
clashPort := c.ClashAPIPort
if clashPort == 0 {
clashPort = 9090
}
upstream := map[string]any{
"type": "socks",
"tag": "upstream",
"server": c.ProxyHost,
"server_port": c.ProxyPort,
"version": "5",
"udp_over_tcp": false,
}
if c.UseAuth {
upstream["username"] = c.Login
upstream["password"] = c.Password
}
logBlock := map[string]any{
"level": logLevel,
"timestamp": true,
}
if c.LogPath != "" {
logBlock["output"] = c.LogPath
}
cfg := map[string]any{
"log": logBlock,
"inbounds": []any{
map[string]any{
"type": "tun",
"tag": "tun-in",
"interface_name": "drover-tun",
"address": []string{"172.18.0.1/30"},
"auto_route": true,
"strict_route": false,
"stack": "system",
"sniff": true,
},
},
"outbounds": []any{
upstream,
map[string]any{"type": "direct", "tag": "direct"},
},
"route": map[string]any{
"auto_detect_interface": true,
"final": "direct",
"rules": []any{
// 1. Domain rule for sniffed SNI (works when sniffing
// catches the ClientHello before route decision).
map[string]any{
"domain_suffix": []string{
"discord.com",
"discord.gg",
"discord.media",
"discordapp.com",
"discordapp.net",
"discord.dev",
},
"outbound": "upstream",
},
// 2. IP-CIDR fallback — sing-box on Windows TUN
// sometimes misattributes the source process for
// Discord's in-process Rust updater (gets attributed
// to steam.exe or similar), so even with the right
// process_name list the updater's TLS connection to
// updates.discord.com (Fastly: 199.232.x.x) goes
// direct and gets RKN-blocked. Force the major
// Discord/Cloudflare/Fastly ranges through upstream
// regardless of which process the kernel claims sent
// them.
map[string]any{
"ip_cidr": []string{
// Fastly (updates.discord.com)
"151.101.0.0/16",
"199.232.0.0/16",
"185.199.108.0/22",
// Cloudflare (Discord gateway, CDN, media)
"162.158.0.0/15",
"162.159.0.0/16",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
},
"outbound": "upstream",
},
// 3. Process-name rule — covers Discord traffic to
// non-Cloudflare destinations (RTC voice, etc).
map[string]any{
"process_name": c.TargetProcs,
"outbound": "upstream",
},
},
},
"experimental": map[string]any{
"clash_api": map[string]any{
"external_controller": fmt.Sprintf("127.0.0.1:%d", clashPort),
},
},
}
out, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
return string(out), nil
}
+34
View File
@@ -0,0 +1,34 @@
// Package sboxrun manages an embedded sing-box subprocess that
// implements the actual proxy engine (TUN inbound + per-process
// routing rule + SOCKS5 outbound).
//
// On first Start, the package extracts sing-box.exe + wintun.dll from
// embedded bytes into %PROGRAMDATA%\Drover\sboxrun\ (SHA256-verified),
// generates a JSON config from the GUI's proxy form, and launches
// sing-box as a child process. Stop kills the child cleanly.
package sboxrun
import _ "embed"
//go:embed assets/sing-box.exe
var singBoxExe []byte
//go:embed assets/wintun.dll
var wintunDLL []byte
// SHA256 sentinels for the embedded binaries — verified after extract.
// Update both when bumping versions:
//
// sing-box: https://github.com/SagerNet/sing-box/releases
// wintun: https://www.wintun.net/
const (
// Pinned to 1.12.25 — last release on the 1.12 line that still
// accepts the legacy TUN inbound config layout. 1.13.0 removed
// `address` from inbound and requires migration to rule-based
// `endpoints` — when our config generator gets updated to that
// shape, we can move to 1.13.x.
SingBoxVersion = "1.12.25"
SingBoxSHA256 = "fc7b65219abe8a0166d0b4891a2f7cabcbcc13b3adcf89e6d5913743a67aba10"
WintunVersion = "0.14.1"
WintunSHA256 = "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce"
)
+83
View File
@@ -0,0 +1,83 @@
//go:build windows
package sboxrun
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
// AssetPaths records where the binaries landed after install.
type AssetPaths struct {
SingBoxExe string
WintunDLL string
WorkDir string // %PROGRAMDATA%\Drover\sboxrun
ConfigPath string // <workdir>\config.json
LogPath string // <workdir>\sing-box.log
}
// InstallAssets extracts sing-box.exe + wintun.dll into
// %PROGRAMDATA%\Drover\sboxrun\ (creating the directory if needed)
// and verifies SHA256. Idempotent — second runs skip if existing
// files match the embedded SHAs.
func InstallAssets() (*AssetPaths, error) {
pd := os.Getenv("ProgramData")
if pd == "" {
return nil, fmt.Errorf("ProgramData environment variable is not set")
}
dir := filepath.Join(pd, "Drover", "sboxrun")
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
exePath := filepath.Join(dir, "sing-box.exe")
dllPath := filepath.Join(dir, "wintun.dll")
if err := writeIfDifferent(exePath, singBoxExe, SingBoxSHA256); err != nil {
return nil, fmt.Errorf("install sing-box.exe: %w", err)
}
if err := writeIfDifferent(dllPath, wintunDLL, WintunSHA256); err != nil {
return nil, fmt.Errorf("install wintun.dll: %w", err)
}
return &AssetPaths{
SingBoxExe: exePath,
WintunDLL: dllPath,
WorkDir: dir,
ConfigPath: filepath.Join(dir, "config.json"),
LogPath: filepath.Join(dir, "sing-box.log"),
}, nil
}
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
}
}
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
}
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 AV exclusions", path)
}
return nil
}
func sha256Hex(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
+223
View File
@@ -0,0 +1,223 @@
//go:build windows
package sboxrun
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"sync"
"syscall"
"time"
)
// Status is the engine's lifecycle state, parallel to what the GUI
// expects (idle/starting/active/failed).
type Status string
const (
StatusIdle Status = "idle"
StatusStarting Status = "starting"
StatusActive Status = "active"
StatusFailed Status = "failed"
)
// Engine wraps a sing-box subprocess.
type Engine struct {
cfg Config
assets *AssetPaths
mu sync.Mutex
status Status
lastErr error
cmd *exec.Cmd
cancel context.CancelFunc
// done is closed when the subprocess exits (whether by Stop or
// crash). Lets Status() observers detect failure asynchronously.
done chan struct{}
}
// New constructs an Engine. No I/O yet.
func New(cfg Config) (*Engine, error) {
if cfg.ProxyHost == "" || cfg.ProxyPort == 0 {
return nil, errors.New("ProxyHost and ProxyPort are required")
}
if len(cfg.TargetProcs) == 0 {
cfg.TargetProcs = []string{
"Discord.exe",
"DiscordCanary.exe",
"DiscordPTB.exe",
"Update.exe",
}
}
return &Engine{cfg: cfg, status: StatusIdle}, nil
}
// Status returns the current lifecycle state.
func (e *Engine) Status() Status {
e.mu.Lock()
defer e.mu.Unlock()
return e.status
}
// LastError returns the last error pushed us to Failed (or nil).
func (e *Engine) LastError() error {
e.mu.Lock()
defer e.mu.Unlock()
return e.lastErr
}
func (e *Engine) setStatus(s Status, err error) {
e.mu.Lock()
e.status = s
if err != nil {
e.lastErr = err
} else if s == StatusActive || s == StatusIdle {
e.lastErr = nil
}
e.mu.Unlock()
}
// Start brings the engine to Active. Generates the sing-box config,
// extracts assets, launches the subprocess. Returns when the process
// is running (or fails to start). The provided ctx is used only for
// the bring-up sequence; the running subprocess is governed by Stop.
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(); err != nil {
e.setStatus(StatusFailed, err)
return err
}
e.setStatus(StatusActive, nil)
return nil
}
func (e *Engine) bringUp() error {
// 1. Extract assets
assets, err := InstallAssets()
if err != nil {
return fmt.Errorf("install assets: %w", err)
}
e.assets = assets
// 2. Generate config (point sing-box log at the workdir log file
// so admin-detached processes don't lose their output to nowhere).
cfg := e.cfg
cfg.LogPath = assets.LogPath
configJSON, err := BuildSingBoxConfig(cfg)
if err != nil {
return fmt.Errorf("build config: %w", err)
}
if err := os.WriteFile(assets.ConfigPath, []byte(configJSON), 0644); err != nil {
return fmt.Errorf("write config: %w", err)
}
// 3. Open log file (truncate; sing-box appends to its own stdout/
// stderr handle so we direct both there).
logFile, err := os.OpenFile(assets.LogPath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open log: %w", err)
}
// 4. Spawn sing-box subprocess.
subCtx, cancel := context.WithCancel(context.Background())
e.cancel = cancel
cmd := exec.CommandContext(subCtx, assets.SingBoxExe,
"run", "-c", assets.ConfigPath, "-D", assets.WorkDir)
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = &syscall.SysProcAttr{
// Don't show a console window for the child.
HideWindow: true,
}
if err := cmd.Start(); err != nil {
cancel()
_ = logFile.Close()
return fmt.Errorf("spawn sing-box: %w", err)
}
e.cmd = cmd
e.done = make(chan struct{})
// 5. Watch for unexpected exit.
go func() {
err := cmd.Wait()
_ = logFile.Close()
close(e.done)
// If we didn't intend to stop (cancel hasn't fired), this is a
// crash → mark Failed so the GUI surfaces it.
select {
case <-subCtx.Done():
// expected — Stop() cancelled us
default:
e.setStatus(StatusFailed, fmt.Errorf("sing-box exited unexpectedly: %w", err))
}
}()
// 6. Brief readiness probe — sing-box takes ~200-500ms to bind
// the TUN. If the process dies in that window, surface the error.
select {
case <-e.done:
return fmt.Errorf("sing-box exited during startup; see %s", assets.LogPath)
case <-time.After(800 * time.Millisecond):
// alive
}
return nil
}
// Stop terminates the sing-box subprocess gracefully and returns to
// Idle. Idempotent — second calls are no-op.
func (e *Engine) Stop() error {
e.mu.Lock()
if e.status == StatusIdle {
e.mu.Unlock()
return nil
}
cancel := e.cancel
cmd := e.cmd
done := e.done
e.mu.Unlock()
if cancel != nil {
cancel()
}
if cmd != nil && cmd.Process != nil {
// Give it 3s to exit cleanly, then force-kill.
killTimer := time.AfterFunc(3*time.Second, func() {
_ = cmd.Process.Kill()
})
if done != nil {
<-done
}
killTimer.Stop()
}
e.setStatus(StatusIdle, nil)
return nil
}
// LogPath returns the path of the sing-box stdout/stderr capture so
// the GUI's "Open log file" can pop it up.
func (e *Engine) LogPath() string {
if e.assets == nil {
return ""
}
return e.assets.LogPath
}
// ConfigPath returns the path of the generated sing-box config (for
// debugging — "View config" link in GUI).
func (e *Engine) ConfigPath() string {
if e.assets == nil {
return ""
}
return e.assets.ConfigPath
}
+48
View File
@@ -0,0 +1,48 @@
//go:build !windows
package sboxrun
import (
"context"
"errors"
)
// Status — duplicate of the Windows-side enum so call sites compile.
type Status string
const (
StatusIdle Status = "idle"
StatusStarting Status = "starting"
StatusActive Status = "active"
StatusFailed Status = "failed"
)
// Engine stub for non-Windows builds.
type Engine struct{}
// New returns an error on non-Windows: sing-box + wintun + WFP-based
// per-process routing only make sense on Windows.
func New(_ Config) (*Engine, error) {
return nil, errors.New("sboxrun is Windows-only")
}
func (e *Engine) Start(_ context.Context) error { return errors.New("sboxrun is Windows-only") }
func (e *Engine) Stop() error { return nil }
func (e *Engine) Status() Status { return StatusIdle }
func (e *Engine) LastError() error { return nil }
func (e *Engine) LogPath() string { return "" }
func (e *Engine) ConfigPath() string { return "" }
// AssetPaths stub.
type AssetPaths struct {
SingBoxExe string
WintunDLL string
WorkDir string
ConfigPath string
LogPath string
}
// InstallAssets stub.
func InstallAssets() (*AssetPaths, error) {
return nil, errors.New("sboxrun is Windows-only")
}
-2
View File
@@ -1,2 +0,0 @@
// Package service installs the Windows service and exposes the IPC named pipe.
package service
-2
View File
@@ -1,2 +0,0 @@
// Package socks5 implements a SOCKS5 client (CONNECT + UDP ASSOCIATE, RFC 1928 + 1929).
package socks5
-2
View File
@@ -1,2 +0,0 @@
// Package tray manages the system tray icon.
package tray