pivot: replace WinDivert engine with embedded sing-box + wintun
Build / test (push) Failing after 31s
Build / build-windows (push) Has been skipped

After 5+ hours of WinDivert NETWORK-layer NAT-rewrite debugging
(streamdump pattern, SOCKET-layer SYN preemption, lazy PID resolution,
UDP ASSOCIATE relay + manual reinject), Discord voice still wouldn't
connect. The fundamental issue is that WinDivert reinjected UDP
packets don't always reach connect()-bound application sockets — the
demux happens at a layer above the reinject point.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 23:02:12 +03:00
parent 4074e68715
commit 48097f8671
51 changed files with 522 additions and 4438 deletions
+14 -13
View File
@@ -12,7 +12,7 @@ import (
"time"
"git.okcu.io/root/drover-go/internal/checker"
"git.okcu.io/root/drover-go/internal/engine"
"git.okcu.io/root/drover-go/internal/sboxrun"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -28,7 +28,7 @@ type App struct {
version string
mu sync.Mutex
eng *engine.Engine
eng *sboxrun.Engine
startedAt time.Time
// muCheck guards cancelCheck and checkDone.
@@ -179,24 +179,25 @@ 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()
if a.eng != nil && a.eng.Status() == engine.StatusActive {
if a.eng != nil && a.eng.Status() == sboxrun.StatusActive {
log.Printf("gui: StartEngine no-op (already active)")
return nil
}
e, err := engine.New(engine.Config{
ProxyAddr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
UseAuth: cfg.Auth,
Login: cfg.Login,
Password: cfg.Password,
Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"},
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", "Update.exe"},
})
if err != nil {
log.Printf("gui: engine.New failed: %v", err)
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: engine.Start failed: %v", err)
log.Printf("gui: sboxrun.Start failed: %v", err)
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
return err
}
@@ -224,7 +225,7 @@ func (a *App) StopEngine() error {
func (a *App) GetStatus() map[string]any {
a.mu.Lock()
defer a.mu.Unlock()
running := a.eng != nil && a.eng.Status() == engine.StatusActive
running := a.eng != nil && a.eng.Status() == sboxrun.StatusActive
res := map[string]any{
"running": running,
"uptimeS": int(time.Since(a.startedAt).Seconds()),
@@ -247,7 +248,7 @@ func (a *App) statsLoop() {
defer tick.Stop()
for range tick.C {
a.mu.Lock()
if a.eng == nil || a.eng.Status() != engine.StatusActive || a.ctx == nil {
if a.eng == nil || a.eng.Status() != sboxrun.StatusActive || a.ctx == nil {
a.mu.Unlock()
continue
}
@@ -173,7 +173,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'.
}
+1 -1
View File
@@ -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']() }