Files
drover-go/internal/sboxrun/runner.go
T
root 48097f8671
Build / test (push) Failing after 31s
Build / build-windows (push) Has been skipped
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>
2026-05-01 23:02:12 +03:00

224 lines
5.3 KiB
Go

//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
}