experimental/windivert: P2.1+P2.2 with WinDivert NETWORK+SOCKET layers

WIP snapshot before pivot to sing-box+TUN. Reached:
- TCP redirect via streamdump pattern (swap+Outbound=0+reinject)
- SOCKET layer for SYN-stage flow detection (avoids FLOW Establish-too-late race)
- Lazy PID→name resolution (catches Update.exe inside procscan tick)
- UDP forward via SOCKS5 UDP ASSOCIATE relay + manual reinject
- Result: chat works, voice times out (Discord IP discovery / RTC handshake fails)

Reason for pivot: WinDivert NAT-reinject pattern has subtle layer-3
semantics issues that DLL-injection / TUN-based proxies sidestep
entirely. Going with embedded sing-box + wintun as the engine —
proven path for Discord voice through SOCKS5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 22:27:54 +03:00
parent 8ceb7775d7
commit 4074e68715
19 changed files with 2666 additions and 62 deletions
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package main
import (
"context"
"fmt"
)
func runDebugFlow(_ context.Context) error {
return fmt.Errorf("debug-flow requires Windows")
}
+64
View File
@@ -0,0 +1,64 @@
//go:build windows
package main
import (
"context"
"log"
"time"
"git.okcu.io/root/drover-go/internal/divert"
)
// runDebugFlow opens a WinDivert FLOW handle with the broadest possible
// filter ("tcp") and logs every flow-establish/delete event for up to
// 30 seconds. This is the simplest possible test that the FLOW layer
// is delivering events to our handle.
//
// If we see events here but our process-targeted handle in `proxy`
// stays silent, the bug is in our processId filter clause. If we see
// nothing here, the FLOW layer is broken on this machine.
func runDebugFlow(parent context.Context) error {
if _, err := divert.InstallDriver(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
log.Printf("debug-flow: opening FLOW handle with filter \"true\" (capture all flows)")
h, err := divert.OpenFlow("true")
if err != nil {
log.Printf("debug-flow: OpenFlow failed: %v", err)
return err
}
defer h.Close()
log.Printf("debug-flow: handle open, listening for 30s")
go func() {
<-ctx.Done()
_ = h.Close() // unblock RecvFlow
}()
count := 0
for {
ev, err := h.RecvFlow()
if err != nil {
if ctx.Err() != nil {
log.Printf("debug-flow: done — captured %d events in 30s", count)
return nil
}
log.Printf("debug-flow: RecvFlow err: %v", err)
return err
}
count++
log.Printf("debug-flow: event #%d est=%v pid=%d proto=%d %v:%d → %v:%d rawLocal=%x rawRemote=%x",
count, ev.Established, ev.ProcessID, ev.Protocol,
ev.SrcAddr, ev.SrcPort, ev.DstAddr, ev.DstPort,
ev.LocalRaw, ev.RemoteRaw)
if count >= 20 {
log.Printf("debug-flow: hit 20-event cap, stopping")
return nil
}
}
}
+94 -1
View File
@@ -3,7 +3,11 @@ package main
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
@@ -29,16 +33,30 @@ func main() {
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
attachToParentConsole()
// Open a debug log file at %LOCALAPPDATA%\Drover\debug.log so we have
// post-mortem visibility into engine startup failures even when the
// process was launched via UAC re-elevation (which detaches stderr
// from the parent terminal).
setupDebugLog()
// Detect if we need admin for the command in os.Args[1:]. If we do and
// we're not admin, re-launch via ShellExecute("runas", ...) and exit.
// CLI subcommands like "check", "version", "update" don't need admin
// and will run without UAC prompt.
if CmdNeedsAdmin(os.Args[1:]) && !IsAdmin() {
needsAdm := CmdNeedsAdmin(os.Args[1:])
isAdm := IsAdmin()
log.Printf("main: post-console admin=%v needsAdmin=%v args=%v", isAdm, needsAdm, os.Args[1:])
if needsAdm && !isAdm {
log.Printf("main: invoking ReElevate")
if err := ReElevate(os.Args[1:]); err != nil {
log.Printf("main: ReElevate returned err: %v", err)
fmt.Fprintf(os.Stderr, "failed to re-elevate: %v\n", err)
} else {
log.Printf("main: ReElevate returned ok, exiting parent")
}
os.Exit(0)
}
log.Printf("main: continuing in current process (no re-elevation needed)")
// Inject our build version so the updater package can stamp it on the
// User-Agent header it sends to git.okcu.io.
@@ -50,6 +68,39 @@ func main() {
}
}
// setupDebugLog wires the standard `log` package to write to both stderr
// and %LOCALAPPDATA%\Drover\debug.log. Survives UAC re-launch (each
// process opens its own append-mode handle).
func setupDebugLog() {
dir := os.Getenv("LOCALAPPDATA")
if dir == "" {
dir = os.Getenv("TEMP")
}
if dir == "" {
return
}
dir = filepath.Join(dir, "Drover")
_ = os.MkdirAll(dir, 0755)
// Truncate on each startup — keeps the log focused on the current
// run instead of accumulating past sessions. If you need history,
// rotate before launch.
f, err := os.OpenFile(filepath.Join(dir, "debug.log"), os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
// On a UAC-elevated launch (Start-Process -Verb RunAs) we have no
// parent console — os.Stderr points at an invalid handle. Writing
// to it via MultiWriter fails the *entire* write, so logs silently
// drop. Just write to the file; CLI subcommands launched from a
// real console can grep the file.
log.SetOutput(f)
_ = io.Discard // keep io import used
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Printf("=== drover %s start pid=%d args=%v admin=%v at %s ===",
Version, os.Getpid(), os.Args[1:], IsAdmin(), time.Now().Format(time.RFC3339))
}
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "drover",
@@ -76,10 +127,52 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newUpdateCmd())
root.AddCommand(newServiceCmd())
root.AddCommand(newGUICmd())
root.AddCommand(newProxyCmd())
root.AddCommand(newDebugFlowCmd())
return root
}
// newDebugFlowCmd opens a WinDivert FLOW handle with filter "tcp"
// (capture all TCP flow events from any process) and logs every event
// for 30 seconds. Useful to verify the FLOW layer is working at all
// without process-targeting interference.
func newDebugFlowCmd() *cobra.Command {
return &cobra.Command{
Use: "debug-flow",
Short: "[debug] open broad FLOW handle, log events for 30s",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDebugFlow(cmd.Context())
},
}
}
// newProxyCmd is the headless engine-only mode: no Wails, no tray —
// just spin up the WinDivert + SOCKS5 pipeline against the configured
// upstream and block on Ctrl+C. Useful for debugging without the GUI
// stack in the way; everything still goes to %LOCALAPPDATA%\Drover\debug.log.
func newProxyCmd() *cobra.Command {
var host, login, password string
var port int
var auth bool
cmd := &cobra.Command{
Use: "proxy",
Short: "Run the WinDivert+SOCKS5 engine in headless mode (no GUI, blocks until Ctrl+C)",
RunE: func(cmd *cobra.Command, args []string) error {
return runProxy(cmd.Context(), host, port, auth, login, password)
},
}
cmd.Flags().StringVar(&host, "host", "", "upstream SOCKS5 host (required)")
cmd.Flags().IntVar(&port, "port", 0, "upstream SOCKS5 port (required)")
cmd.Flags().BoolVar(&auth, "auth", false, "enable user/pass auth")
cmd.Flags().StringVar(&login, "login", "", "SOCKS5 login (when --auth)")
cmd.Flags().StringVar(&password, "password", "", "SOCKS5 password (when --auth)")
_ = cmd.MarkFlagRequired("host")
_ = cmd.MarkFlagRequired("port")
return cmd
}
func newGUICmd() *cobra.Command {
return &cobra.Command{
Use: "gui",
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package main
import (
"context"
"fmt"
)
// runProxy stub for non-Windows builds (drover only ships for Windows;
// this stub keeps `go build ./...` clean on Linux dev/CI machines).
func runProxy(_ context.Context, _ string, _ int, _ bool, _, _ string) error {
return fmt.Errorf("the proxy subcommand requires Windows (WinDivert is Windows-only)")
}
+80
View File
@@ -0,0 +1,80 @@
//go:build windows
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"git.okcu.io/root/drover-go/internal/engine"
)
// runProxy is the body of the `drover proxy` subcommand. It builds an
// engine.Engine from the supplied flags, calls Start, and blocks until
// the process receives SIGINT (Ctrl+C) or SIGTERM. On signal, it
// gracefully Stops the engine and exits.
//
// All output is mirrored to stderr (visible when launched from a
// console session) AND %LOCALAPPDATA%\Drover\debug.log. setupDebugLog
// in main.go has already wired the log package to write to both.
func runProxy(parent context.Context, host string, port int, auth bool, login, password string) error {
if host == "" || port == 0 {
return fmt.Errorf("--host and --port are required")
}
ctx, cancel := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
defer cancel()
cfg := engine.Config{
ProxyAddr: fmt.Sprintf("%s:%d", host, port),
UseAuth: auth,
Login: login,
Password: password,
Targets: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"},
}
log.Printf("proxy: building engine (proxy=%s auth=%v targets=%v)", cfg.ProxyAddr, cfg.UseAuth, cfg.Targets)
e, err := engine.New(cfg)
if err != nil {
return fmt.Errorf("engine.New: %w", err)
}
startCtx, startCancel := context.WithTimeout(ctx, 15*time.Second)
defer startCancel()
if err := e.Start(startCtx); err != nil {
log.Printf("proxy: Start failed: %v", err)
return fmt.Errorf("engine.Start: %w", err)
}
log.Printf("proxy: engine status=%s — press Ctrl+C to stop", e.Status())
// Periodic status ping so the user sees the engine is alive.
statusTk := time.NewTicker(10 * time.Second)
defer statusTk.Stop()
for {
select {
case <-ctx.Done():
log.Printf("proxy: signal received, shutting down")
if err := e.Stop(); err != nil {
log.Printf("proxy: Stop returned: %v", err)
}
log.Printf("proxy: bye")
return nil
case <-statusTk.C:
if le := e.LastError(); le != nil {
log.Printf("proxy: heartbeat status=%s lastErr=%v", e.Status(), le)
} else {
log.Printf("proxy: heartbeat status=%s", e.Status())
}
if e.Status() == engine.StatusFailed {
log.Printf("proxy: engine entered Failed state, exiting")
_ = e.Stop()
return fmt.Errorf("engine failed: %v", e.LastError())
}
}
}
}