pivot: replace WinDivert engine with embedded sing-box + wintun
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>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,107 @@
|
||||
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{
|
||||
// Route only the target processes via upstream
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user