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:
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func runDebugFlow(_ context.Context) error {
|
||||
return fmt.Errorf("debug-flow requires Windows")
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user