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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user