Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 168596bcb5 | |||
| 48097f8671 | |||
| 4074e68715 | |||
| 8ceb7775d7 | |||
| bbe88b0f70 | |||
| dd402d4fc4 | |||
| 837208d9ed | |||
| a45c1c0ab7 | |||
| 1949abf011 | |||
| 35da6be99e | |||
| feda075dc4 | |||
| 223c7f5886 | |||
| 736c3ecfc7 | |||
| 11de3fb12b | |||
| 8e83260123 | |||
| c647c09c20 | |||
| 5f107de95d |
@@ -3,7 +3,11 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -29,6 +33,31 @@ func main() {
|
||||
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
|
||||
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
|
||||
// User-Agent header it sends to git.okcu.io.
|
||||
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 {
|
||||
root := &cobra.Command{
|
||||
Use: "drover",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -20,6 +20,7 @@ require (
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // 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/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
|
||||
@@ -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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package app wires the Wails application (Go ↔ JS bindings).
|
||||
package app
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package bypass implements DPI bypass via fake QUIC injection.
|
||||
package bypass
|
||||
+44
-193
@@ -123,7 +123,6 @@ func Run(ctx context.Context, cfg Config) <-chan Result {
|
||||
}
|
||||
e.runConnect()
|
||||
e.runUDP()
|
||||
e.runVoiceQuality()
|
||||
e.runAPI()
|
||||
}()
|
||||
|
||||
@@ -522,6 +521,38 @@ func (e *executor) runConnect() {
|
||||
}
|
||||
|
||||
// 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() {
|
||||
dep := e.greetOK && (!e.cfg.UseAuth || e.authOK)
|
||||
if e.shouldSkip("udp", dep) {
|
||||
@@ -552,204 +583,24 @@ func (e *executor) runUDP() {
|
||||
if uerr != nil {
|
||||
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
|
||||
return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil
|
||||
})
|
||||
e.udpOK = ok
|
||||
}
|
||||
|
||||
// runVoiceQuality — Test 6: 30-packet STUN burst through the SOCKS5 UDP
|
||||
// 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.
|
||||
// runAPI — Test 6: HTTP GET Discord API gateway URL through the proxy.
|
||||
func (e *executor) runAPI() {
|
||||
if e.shouldSkip("api", e.connectOK) {
|
||||
return
|
||||
|
||||
@@ -579,7 +579,7 @@ func TestRun_HappyNoAuth(t *testing.T) {
|
||||
ch := Run(context.Background(), cfg)
|
||||
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{}
|
||||
for _, id := range expected {
|
||||
r, ok := finalByID(results, id)
|
||||
@@ -598,7 +598,6 @@ func TestRun_HappyNoAuth(t *testing.T) {
|
||||
// Metrics format spot-checks.
|
||||
assert.Contains(t, finals["greet"].Metric, "no auth")
|
||||
assert.Equal(t, "REP=00", finals["connect"].Metric)
|
||||
assert.Contains(t, finals["voice-quality"].Metric, "loss=")
|
||||
assert.Equal(t, "HTTP 200", finals["api"].Metric)
|
||||
}
|
||||
|
||||
@@ -612,7 +611,7 @@ func TestRun_HappyWithAuth(t *testing.T) {
|
||||
ch := Run(context.Background(), cfg)
|
||||
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 {
|
||||
r, ok := finalByID(results, id)
|
||||
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.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)
|
||||
require.True(t, ok, "missing %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.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)
|
||||
require.True(t, ok, "missing %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")
|
||||
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.
|
||||
rA, _ := finalByID(results, "api")
|
||||
assert.Equal(t, StatusSkipped, rA.Status)
|
||||
@@ -728,10 +723,6 @@ func TestRun_UDPUnsupported(t *testing.T) {
|
||||
require.Equal(t, StatusFailed, rU.Status)
|
||||
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")
|
||||
assert.Equal(t, StatusPassed, rA.Status)
|
||||
}
|
||||
@@ -768,7 +759,7 @@ func TestRun_TimeoutThenOK(t *testing.T) {
|
||||
assert.Equal(t, 2, greetEvents[3].Attempt)
|
||||
|
||||
// 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)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package config loads and validates the TOML configuration.
|
||||
package config
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package divert wraps WinDivert for kernel-level packet capture.
|
||||
package divert
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package engine orchestrates the packet processing pipeline.
|
||||
package engine
|
||||
+59
-16
@@ -6,11 +6,13 @@ package gui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/checker"
|
||||
"git.okcu.io/root/drover-go/internal/sboxrun"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
@@ -26,7 +28,7 @@ type App struct {
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
eng *sboxrun.Engine
|
||||
startedAt time.Time
|
||||
|
||||
// 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
|
||||
// note the start time so GetStats can produce a believable uptime.
|
||||
func (a *App) StartEngine() error {
|
||||
// StartEngine initializes and brings up the engine with the given config.
|
||||
func (a *App) StartEngine(cfg Config) error {
|
||||
log.Printf("gui: StartEngine called host=%s port=%d auth=%v", cfg.Host, cfg.Port, cfg.Auth)
|
||||
a.mu.Lock()
|
||||
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()
|
||||
log.Printf("gui: engine started, status=%s", e.Status())
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopEngine turns the proxy off.
|
||||
// StopEngine shuts down the engine.
|
||||
func (a *App) StopEngine() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.running = false
|
||||
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 nil
|
||||
return err
|
||||
}
|
||||
|
||||
// GetStatus is read by the frontend on first paint to know whether to
|
||||
// show "Idle" or "Active".
|
||||
// GetStatus returns the current engine state and uptime.
|
||||
func (a *App) GetStatus() map[string]any {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return map[string]any{
|
||||
"running": a.running,
|
||||
running := a.eng != nil && a.eng.Status() == sboxrun.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
|
||||
}
|
||||
|
||||
// statsLoop emits a stats event every second when the engine is running.
|
||||
// Numbers are random but stable enough to look real.
|
||||
// statsLoop emits a stats event every second when the engine is active.
|
||||
// Numbers are random but stable enough to look real. P2.4 will replace
|
||||
// with real counters from engine.Engine.
|
||||
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.running || a.ctx == nil {
|
||||
if a.eng == nil || a.eng.Status() != sboxrun.StatusActive || a.ctx == nil {
|
||||
a.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
@@ -222,7 +265,7 @@ func (a *App) statsLoop() {
|
||||
"up": r.Intn(50_000) + 5_000, // bytes/sec out
|
||||
"down": r.Intn(500_000) + 50_000, // bytes/sec in
|
||||
"tcp": r.Intn(8) + 1,
|
||||
"udp": r.Intn(5) + 1,
|
||||
"udp": 0, // P2.1 scope: no UDP yet
|
||||
"uptimeS": uptime,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export const ALL_TESTS = [
|
||||
{ id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true },
|
||||
{ id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' },
|
||||
{ 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 через прокси' },
|
||||
];
|
||||
|
||||
@@ -173,7 +172,18 @@ export function useDrover(initial = {}) {
|
||||
async function startProxy() {
|
||||
if (phase !== 'checked') 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'.
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) }
|
||||
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 GetStatus() { return window['go']['gui']['App']['GetStatus']() }
|
||||
export function Version() { return window['go']['gui']['App']['Version']() }
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package procscan resolves process IDs via Toolhelp32.
|
||||
package procscan
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package service installs the Windows service and exposes the IPC named pipe.
|
||||
package service
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package socks5 implements a SOCKS5 client (CONNECT + UDP ASSOCIATE, RFC 1928 + 1929).
|
||||
package socks5
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package tray manages the system tray icon.
|
||||
package tray
|
||||
Reference in New Issue
Block a user