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