Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 168596bcb5 | |||
| 48097f8671 | |||
| 4074e68715 | |||
| 8ceb7775d7 | |||
| bbe88b0f70 | |||
| dd402d4fc4 | |||
| 837208d9ed | |||
| a45c1c0ab7 | |||
| 1949abf011 | |||
| 35da6be99e | |||
| feda075dc4 | |||
| 223c7f5886 | |||
| 736c3ecfc7 | |||
| 11de3fb12b | |||
| 8e83260123 | |||
| c647c09c20 | |||
| 5f107de95d | |||
| 11c4eb7f4a | |||
| 9ea777d7b7 | |||
| 0a85979142 | |||
| ea4202d4a3 | |||
| c48bd96369 | |||
| 1c1ab566d9 | |||
| 4b985bb7f0 | |||
| acd5291604 | |||
| 36e788402a | |||
| 52ce1e0aa7 | |||
| c83f942716 | |||
| b6619ef53b | |||
| 13c32c90d5 |
@@ -106,7 +106,9 @@ jobs:
|
||||
# flashes a console window. main.go calls AttachConsole on
|
||||
# startup so CLI invocations from cmd/PowerShell still print
|
||||
# to the parent terminal.
|
||||
go build -trimpath -ldflags="-s -w -H=windowsgui \
|
||||
# -tags desktop,production is REQUIRED by Wails (see release.yml).
|
||||
go build -trimpath -tags "desktop,production" \
|
||||
-ldflags="-s -w -H=windowsgui \
|
||||
-X main.Version=dev-${SHORT_SHA} \
|
||||
-X main.Commit=${SHORT_SHA} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
|
||||
@@ -78,7 +78,11 @@ jobs:
|
||||
# double-click experience doesn't flash a console window. main.go
|
||||
# calls AttachConsole on startup so CLI runs still print to the
|
||||
# parent terminal when launched from cmd/PowerShell.
|
||||
go build -trimpath -ldflags="-s -w -H=windowsgui \
|
||||
# -tags desktop,production is REQUIRED by Wails — otherwise the
|
||||
# binary aborts at startup with a "Wails applications will not
|
||||
# build without the correct build tags" MessageBox.
|
||||
go build -trimpath -tags "desktop,production" \
|
||||
-ldflags="-s -w -H=windowsgui \
|
||||
-X main.Version=${{ steps.version.outputs.version }} \
|
||||
-X main.Commit=${SHORT_SHA} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
|
||||
+4
-3
@@ -8,10 +8,11 @@
|
||||
*.out
|
||||
|
||||
# Wails
|
||||
/internal/frontend/node_modules/
|
||||
/internal/frontend/dist/
|
||||
/internal/frontend/wailsjs/
|
||||
/internal/gui/frontend/node_modules/
|
||||
/internal/gui/frontend/dist/
|
||||
.wails/
|
||||
# wailsjs/ is generated, but we keep it tracked because we hand-write
|
||||
# bindings/App.js for the gui-package layout (see comment in that file)
|
||||
|
||||
# IDE
|
||||
/.idea/
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func showTestWindow() {
|
||||
fmt.Printf("Drover-Go v%s — test window unavailable on non-Windows builds\n", Version)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// showTestWindow displays a native Win32 MessageBox with build info.
|
||||
// The intent is to give end-users a visual smoke-test on first run:
|
||||
// double-click drover.exe (or run `drover gui`) and see that:
|
||||
// 1. the binary actually launches on Windows,
|
||||
// 2. the embedded version metadata is correct,
|
||||
// 3. the process can talk to user32.dll (i.e. the runtime is healthy).
|
||||
//
|
||||
// This is *not* the production GUI — that comes later via Wails. Here we
|
||||
// purposely use only stdlib + golang.org/x/sys/windows so this works
|
||||
// before any Wails/CGO machinery is wired up.
|
||||
func showTestWindow() {
|
||||
user32 := windows.NewLazySystemDLL("user32.dll")
|
||||
messageBox := user32.NewProc("MessageBoxW")
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"Drover-Go v%s\n\n"+
|
||||
"Commit: %s\n"+
|
||||
"Build: %s\n"+
|
||||
"Go: %s\n"+
|
||||
"Arch: %s/%s\n\n"+
|
||||
"OK — the binary launched and the Windows API is reachable.",
|
||||
Version, Commit, BuildDate, runtime.Version(), runtime.GOOS, runtime.GOARCH,
|
||||
)
|
||||
title := fmt.Sprintf("Drover-Go v%s — test window", Version)
|
||||
|
||||
bodyW, _ := windows.UTF16PtrFromString(body)
|
||||
titleW, _ := windows.UTF16PtrFromString(title)
|
||||
|
||||
// MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND | MB_TOPMOST
|
||||
// MB_TOPMOST is essential — without it the message box can pop up
|
||||
// behind other windows and the user thinks nothing happened.
|
||||
const flags = 0x00000000 | 0x00000040 | 0x00010000 | 0x00040000
|
||||
|
||||
messageBox.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(bodyW)),
|
||||
uintptr(unsafe.Pointer(titleW)),
|
||||
flags,
|
||||
)
|
||||
}
|
||||
+69
-8
@@ -3,10 +3,15 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/gui"
|
||||
"git.okcu.io/root/drover-go/internal/updater"
|
||||
)
|
||||
|
||||
@@ -28,6 +33,31 @@ 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.
|
||||
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.
|
||||
updater.SetVersion(Version)
|
||||
@@ -38,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",
|
||||
@@ -46,13 +109,12 @@ func newRootCmd() *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: false,
|
||||
// No subcommand and no flags = end-user double-clicked the exe.
|
||||
// First do a quick update check (silent if no network or already
|
||||
// current); if an update is available we prompt, apply, and
|
||||
// re-launch ourselves. Then show the smoke-test window.
|
||||
// First do a quick silent update check (no-op if offline or
|
||||
// already current); if an update is available we apply it and
|
||||
// re-launch ourselves. Then we open the Wails-backed GUI.
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
autoUpdateOnStartup()
|
||||
showTestWindow()
|
||||
return nil
|
||||
return gui.Run(Version)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -72,10 +134,9 @@ func newRootCmd() *cobra.Command {
|
||||
func newGUICmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "gui",
|
||||
Short: "Show a test window (smoke check that the binary launches on this machine)",
|
||||
Short: "Open the Drover-Go window (same as launching the exe with no args)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
showTestWindow()
|
||||
return nil
|
||||
return gui.Run(Version)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// IsAdmin returns true when the current process token has elevation.
|
||||
// Wraps GetTokenInformation(TokenElevation).
|
||||
func IsAdmin() bool {
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
|
||||
return false
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
var elevation uint32
|
||||
var sz uint32
|
||||
err := windows.GetTokenInformation(
|
||||
token,
|
||||
windows.TokenElevation,
|
||||
(*byte)(unsafe.Pointer(&elevation)),
|
||||
uint32(unsafe.Sizeof(elevation)),
|
||||
&sz,
|
||||
)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return elevation != 0
|
||||
}
|
||||
|
||||
// CmdNeedsAdmin reports whether the given CLI args land in a code path
|
||||
// that requires a WinDivert handle (and therefore admin). The default
|
||||
// (no args = GUI mode) needs admin; explicit subcommands like check,
|
||||
// version, update do not.
|
||||
func CmdNeedsAdmin(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return true // bare drover.exe → GUI/engine
|
||||
}
|
||||
switch args[0] {
|
||||
case "check", "version", "--version", "-v", "update", "--help", "-h", "help":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ReElevate re-launches the current executable with the given args via
|
||||
// ShellExecuteW("runas", ...). On success the caller should os.Exit(0)
|
||||
// immediately. Returns nil even when the user cancels UAC — the caller
|
||||
// can't distinguish; we just exit cleanly afterward.
|
||||
func ReElevate(args []string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verb, err := syscall.UTF16PtrFromString("runas")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode verb: %w", err)
|
||||
}
|
||||
exePtr, err := syscall.UTF16PtrFromString(exe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode exe: %w", err)
|
||||
}
|
||||
|
||||
var paramsPtr *uint16
|
||||
if len(args) > 0 {
|
||||
// Quote each arg in case of spaces, and escape internal quotes.
|
||||
quoted := make([]string, len(args))
|
||||
for i, a := range args {
|
||||
// Escape any internal quotes with backslash (MSVC argv convention).
|
||||
escaped := strings.ReplaceAll(a, "\"", "\\\"")
|
||||
quoted[i] = `"` + escaped + `"`
|
||||
}
|
||||
joined := ""
|
||||
for i, q := range quoted {
|
||||
if i > 0 {
|
||||
joined += " "
|
||||
}
|
||||
joined += q
|
||||
}
|
||||
paramsPtr, err = syscall.UTF16PtrFromString(joined)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode params: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get cwd: %w", err)
|
||||
}
|
||||
cwdPtr, err := syscall.UTF16PtrFromString(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode cwd: %w", err)
|
||||
}
|
||||
|
||||
// SW_NORMAL = 1
|
||||
return windows.ShellExecute(0, verb, exePtr, paramsPtr, cwdPtr, 1)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsAdmin_Smoke(t *testing.T) {
|
||||
// Smoke test: IsAdmin returns a bool without panicking.
|
||||
// We can't assert true/false without knowing the test environment,
|
||||
// but we ensure the syscall path doesn't crash.
|
||||
_ = IsAdmin()
|
||||
}
|
||||
|
||||
func TestCmdNeedsAdmin_NoAdminFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
args []string
|
||||
needsAdm bool
|
||||
}{
|
||||
{[]string{}, true}, // bare drover.exe → GUI mode → needs admin
|
||||
{[]string{"check"}, false}, // diagnostic only, no driver
|
||||
{[]string{"check", "--host", "x"}, false},
|
||||
{[]string{"--version"}, false},
|
||||
{[]string{"version"}, false},
|
||||
{[]string{"update"}, false}, // self-update doesn't need driver
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := CmdNeedsAdmin(c.args)
|
||||
if got != c.needsAdm {
|
||||
t.Errorf("CmdNeedsAdmin(%v) = %v, want %v", c.args, got, c.needsAdm)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
# Checker — 7-step SOCKS5 diagnostic
|
||||
|
||||
**Status**: design accepted 2026-05-01.
|
||||
**Replaces**: stub `RunCheck` in `internal/gui/app.go` that emits fake events.
|
||||
|
||||
## Why
|
||||
|
||||
The Wails GUI exposes a "Check connection" button that the user presses
|
||||
before turning the engine on. Today it walks through a hard-coded scenario
|
||||
in Go, returning bogus metrics. The user can't tell whether their proxy
|
||||
is alive, supports UDP, or whether Discord blocks it. We need an honest
|
||||
diagnostic that tells the user exactly which capability of their SOCKS5
|
||||
proxy works and which doesn't, with hex-level evidence on failure.
|
||||
|
||||
## API surface
|
||||
|
||||
```go
|
||||
// internal/checker/checker.go
|
||||
package checker
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusPassed Status = "passed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusSkipped Status = "skipped"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
ID string `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Metric string `json:"metric,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
RawHex string `json:"raw_hex,omitempty"`
|
||||
Duration time.Duration `json:"duration_ms"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ProxyHost string
|
||||
ProxyPort int
|
||||
UseAuth bool
|
||||
ProxyLogin string
|
||||
ProxyPassword string
|
||||
|
||||
PerTestTimeout time.Duration
|
||||
MaxRetries int
|
||||
RetryBackoff time.Duration
|
||||
|
||||
DiscordGateway string
|
||||
DiscordAPI string
|
||||
StunServer string
|
||||
|
||||
// voice-quality burst tuning
|
||||
VoiceBurstCount int // default 30
|
||||
VoiceBurstInterval time.Duration // default 20ms
|
||||
}
|
||||
|
||||
// StatusWarn is a "soft pass" — the test technically succeeded but
|
||||
// the user should know about a degradation (e.g. voice quality at the
|
||||
// upper end of acceptable). Frontend renders it like StatusPassed but
|
||||
// keeps the Hint visible.
|
||||
const StatusWarn Status = "warn"
|
||||
|
||||
// Run streams Results to the returned channel and closes it when finished
|
||||
// or when ctx is cancelled. The first event for each test is Status=running;
|
||||
// the next is the final state (passed/failed/skipped). On retry, another
|
||||
// running+final pair is emitted with Attempt > 1.
|
||||
func Run(ctx context.Context, cfg Config) <-chan Result
|
||||
```
|
||||
|
||||
Defaults applied when zero values are passed: PerTestTimeout=5s, MaxRetries=1,
|
||||
RetryBackoff=500ms, DiscordGateway="gateway.discord.gg:443",
|
||||
DiscordAPI="https://discord.com/api/v9/gateway",
|
||||
StunServer="stun.l.google.com:19302".
|
||||
|
||||
## The seven tests
|
||||
|
||||
Sequential. Each test reuses sockets opened by previous tests when sensible.
|
||||
|
||||
| ID | What it does | Considered failed when | Skip rule |
|
||||
|----|--------------|------------------------|-----------|
|
||||
| `tcp` | `net.DialTimeout("tcp", host:port)` | dial error | never |
|
||||
| `greet` | Sends SOCKS5 client greeting `05 02 00 02` (or `05 01 00` if UseAuth=false). Reads 2 bytes. Pass = `05 00` (no auth) or `05 02` (auth required). Fail on `05 FF`, anything else, or short read | proxy returned non-SOCKS5 / refused all auth methods | skipped if `tcp` failed |
|
||||
| `auth` | Only emitted when UseAuth=true. RFC 1929 sub-negotiation: `01 LEN_LOGIN LOGIN LEN_PASS PASS`. Reads 2 bytes, expects `01 00`. | bad credentials (`01 != 00`) / short read | not in test list when UseAuth=false; skipped if `greet` failed |
|
||||
| `connect` | SOCKS5 CONNECT to `gateway.discord.gg:443` (ATYP=03 domain). Reads 10 bytes. Pass = REP=0x00. | REP != 0 (0x05 = connection refused, etc) / timeout | skipped if `greet`/`auth` failed |
|
||||
| `udp` | UDP ASSOCIATE: opens **second** TCP control channel, redoes greeting+auth there, sends `05 03 00 01 00000000 0000`, reads 10-byte reply. Pass = REP=0x00 + valid relay endpoint in BND.ADDR/BND.PORT. | REP=0x07 (cmd unsupported), other REP, short read | skipped if `greet` failed |
|
||||
| `voice-quality` | Through the relay: send `VoiceBurstCount` (default 30) STUN binding requests to `cfg.StunServer`, spaced `VoiceBurstInterval` (default 20ms). Listen until `last_send + 1.5*PerTestTimeout`. Compute `loss%`, `jitter` (mean abs delta of inter-arrival deltas, à la RFC 3550 simplified), `p50 RTT`. Metric = `"loss=2% jitter=14ms p50=42ms"`. **Pass** = loss ≤ 5% AND jitter ≤ 30ms AND p50 ≤ 250ms. **Warn-pass** (status=passed but Hint set) = loss ≤ 15% AND jitter ≤ 60ms — voice will work with audible glitches. **Fail** = anything worse. | loss > 15% OR jitter > 60ms OR p50 > 400ms OR no replies at all | skipped if `udp` failed |
|
||||
| `api` | TCP CONNECT through the proxy to `discord.com:443`, do a tiny HTTPS GET `/api/v9/gateway`. Pass = HTTP 200 or 401 (Discord returns 401 unauthenticated, that still proves reachability). | non-200/401 / TLS handshake failed / connect refused | skipped if `connect` failed |
|
||||
|
||||
For each fail, the `Hint` field carries a Russian explanation (the GUI is
|
||||
RU-localized) and `RawHex` carries the first 32 bytes of any unexpected
|
||||
response (for the expand-debug section in the UI).
|
||||
|
||||
## Cancel & retry
|
||||
|
||||
- `ctx` is honoured at every blocking call (Dial uses DialContext, reads
|
||||
use SetDeadline derived from PerTestTimeout). On cancel, current test
|
||||
emits a final `failed` result with Error="cancelled" and the channel
|
||||
closes; remaining tests get a single `skipped` event each.
|
||||
- Auto-retry once on transient errors:
|
||||
- timeout (`net.Error.Timeout()`)
|
||||
- "connection reset by peer"
|
||||
- DNS temporary failure
|
||||
- NOT retried (likely user-config error or hard failure):
|
||||
- connection refused
|
||||
- bad credentials (REP=0x02, AUTH=0x01)
|
||||
- REP=0x07 (cmd unsupported)
|
||||
- HTTP 4xx/5xx other than 401 on `api`
|
||||
- Between attempts: sleep `RetryBackoff`.
|
||||
|
||||
## Wails integration
|
||||
|
||||
`internal/gui/app.go::RunCheck(cfg Config)` becomes:
|
||||
|
||||
```go
|
||||
func (a *App) RunCheck(cfg Config) {
|
||||
ctx, cancel := context.WithCancel(a.ctx)
|
||||
a.muCheck.Lock()
|
||||
a.cancelCheck = cancel
|
||||
a.muCheck.Unlock()
|
||||
|
||||
go func() {
|
||||
ck := mapToCheckerConfig(cfg)
|
||||
var passed, failed int
|
||||
for r := range checker.Run(ctx, ck) {
|
||||
runtime.EventsEmit(a.ctx, "check:result", r)
|
||||
if r.Status == checker.StatusPassed { passed++ }
|
||||
if r.Status == checker.StatusFailed { failed++ }
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "check:done", map[string]int{
|
||||
"total": passed + failed, "passed": passed, "failed": failed,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) CancelCheck() {
|
||||
a.muCheck.Lock()
|
||||
if a.cancelCheck != nil { a.cancelCheck() }
|
||||
a.muCheck.Unlock()
|
||||
}
|
||||
```
|
||||
|
||||
A new `CancelCheck` binding lets the GUI's Cancel button stop a running
|
||||
diagnostic. The frontend's `useDrover` hook gets a `cancelCheck()`
|
||||
callback that calls it.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for each test function with a fake SOCKS5 server (`net.Listen`,
|
||||
hand-rolled byte responses) — covers happy path, every documented failure
|
||||
mode, malformed responses (truncated, wrong protocol, garbage).
|
||||
- STUN test uses a real `pion/stun` server in-process via `net.Listen("udp")`.
|
||||
- Discord-API and `connect` tests use the same fake SOCKS5 server tunneling
|
||||
to `httptest.NewTLSServer` and `net.Listen("tcp")`.
|
||||
- One end-to-end test against a real `mihomo` instance is documented in
|
||||
`docs/testing/checker-e2e.md` but not part of `go test ./...` (requires
|
||||
network).
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
internal/checker/
|
||||
checker.go ─ public API: Run, Result, Config
|
||||
socks5.go ─ greeting, auth, CONNECT, UDP ASSOCIATE primitives
|
||||
stun.go ─ STUN binding-request encode/decode (no library —
|
||||
we already vendor enough; ~80 LOC)
|
||||
retry.go ─ classify(err) -> transient | permanent
|
||||
hints.go ─ map test failure → user hint (RU)
|
||||
checker_test.go ─ Run-level integration with fake server
|
||||
socks5_test.go ─ per-primitive table tests
|
||||
stun_test.go ─ encode/decode + RTT mock
|
||||
```
|
||||
|
||||
`internal/gui/app.go` gets `RunCheck` rewritten and a new `CancelCheck`
|
||||
method. The fake SCENARIOS path in app.go is removed.
|
||||
|
||||
## Out of scope (future work)
|
||||
|
||||
- IPv6 SOCKS5 ATYP=04. Discord today is IPv4; we'll add when we hit a
|
||||
proxy that's v6-only.
|
||||
- Parallel test execution (e.g. running `connect` and `udp` simultaneously
|
||||
on separate sessions). Sequential is clearer for the UI; we'll revisit
|
||||
if total runtime exceeds 10s on common networks.
|
||||
- TLS certificate pinning on `api`. The `tls.Config` is default — fine for
|
||||
reachability check.
|
||||
@@ -0,0 +1,651 @@
|
||||
# Engine — WinDivert + SOCKS5 transparent proxy for Discord
|
||||
|
||||
**Status**: design accepted 2026-05-01.
|
||||
**Replaces**: stub `StartEngine`/`StopEngine` in `internal/gui/app.go` that just toggle a flag.
|
||||
**Implements**: Phase 2 from `docs/planning/cuddly-baking-taco.md`.
|
||||
|
||||
## Why
|
||||
|
||||
The checker proves the upstream SOCKS5 proxy works. The engine is what
|
||||
actually routes Discord's traffic through it. Without the engine, every
|
||||
diagnostic in the world is theatre — the GUI just sits there saying
|
||||
"Active" while Discord still talks direct to discord.com. Phase 2 turns
|
||||
that "Active" state into reality: kernel-level packet capture (WinDivert),
|
||||
NAT-style TCP redirect to a loopback listener, SOCKS5 UDP ASSOCIATE for
|
||||
voice, and a polished lifecycle so the user can install once, click
|
||||
"autostart at login", and forget the thing exists until Discord stops
|
||||
working — at which point the tray icon turns yellow and explains why.
|
||||
|
||||
## Architecture decisions (locked-in 2026-05-01)
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| **A** | GUI-only single-process; no Windows service | Friends-and-family Windows-PC, Discord runs only when user is logged in. Service mode is overengineering for v1; can be added in v0.4 if a power user asks. |
|
||||
| **B1** | UAC prompt at every launch; no scheduled-task trampoline | User chose simplicity over polish. Each `drover.exe` invocation re-elevates if not admin. Autostart via `HKCU\...\Run` triggers the same prompt at login. |
|
||||
| **C1** | No DPI bypass (no fake QUIC injection) | Start with the simplest pipeline that works. If a friend reports voice not working on a DPI-active provider, add C2/C3 in v0.4. |
|
||||
| **D1** | Window X = hide-to-tray + first-time toast; quit only via tray menu | Industry-standard (Steam, Discord, Telegram). One-shot toast prevents the "where did it go?" surprise. |
|
||||
| **E3** | Contextual recovery: driver-loss → 1 reopen retry → fail-stop; proxy-loss → infinite exp-backoff (Reconnecting state); panic → fail-stop with crash dump; sleep/resume → graceful pause/resume | Different failure classes need different responses. Aggressive auto-restart on every error masks bugs; honest fail-stop on every error annoys the user during transient network blips. |
|
||||
|
||||
## High-level architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ drover.exe (single binary) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Wails GUI │ │ systray │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ └───────┬────────┘ │
|
||||
│ ┌─────────▼──────────┐ │
|
||||
│ │ Engine │ │
|
||||
│ │ state machine │ │
|
||||
│ │ Idle / Starting / │ │
|
||||
│ │ Active / Reconn / │ │
|
||||
│ │ Failed │ │
|
||||
│ └─────────┬──────────┘ │
|
||||
│ ┌─────────┼─────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────┐ ┌────────┐ ┌──────────┐ │
|
||||
│ │divert│ │redirect│ │ procscan │ │
|
||||
│ │ pkt │ │ TCP+UDP│ │ (2s tick)│ │
|
||||
│ └──┬───┘ └───┬────┘ └────┬─────┘ │
|
||||
│ ▼ ▼ │ │
|
||||
│ WinDivert socks5 │ │
|
||||
│ .sys client │ │
|
||||
└──────────────────────────────┼──────┘
|
||||
│
|
||||
┌────────────┐ ┌─────────────▼───┐
|
||||
│ kernel │ │ upstream SOCKS5 │
|
||||
│ packet cap │ │ (mihomo) │
|
||||
└────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
cmd/drover/
|
||||
main.go existing — extend with engine startup, single-instance check
|
||||
uac_windows.go new — IsAdmin, ReElevate
|
||||
console_windows.go existing
|
||||
autoupdate_windows.go existing
|
||||
|
||||
internal/engine/
|
||||
engine.go new — orchestration, state machine, lifecycle
|
||||
state.go new — Idle/Starting/Active/Reconnecting/Failed enum + transitions
|
||||
recovery.go new — failure classifier → action mapper
|
||||
health.go new — heartbeat timer, traffic detector
|
||||
power_windows.go new — WM_POWERBROADCAST listener (sleep/resume)
|
||||
|
||||
internal/divert/
|
||||
divert.go new — WinDivert handle wrapper
|
||||
filter.go new — filter expression builder
|
||||
packet.go new — IPv4 + TCP/UDP parse + checksum recompute
|
||||
installer.go new — extract embedded WinDivert.sys/.dll on first run
|
||||
divert_arm64.go new — stub returning "ARM64 not supported"
|
||||
|
||||
internal/socks5/ NEW — production client (separate from internal/checker/socks5.go)
|
||||
client.go new — TCP CONNECT + greet/auth
|
||||
udp.go new — UDP ASSOCIATE + encapsulate/decapsulate
|
||||
pool.go new — control-channel pool (deferred to P2.5 if needed)
|
||||
|
||||
internal/redirect/
|
||||
tcp.go new — NAT-loopback redirect listener + per-flow pump
|
||||
udp.go new — per-flow UDP tracker + encap/decap
|
||||
|
||||
internal/procscan/
|
||||
procscan.go new — Toolhelp32 snapshot, periodic PID resolver
|
||||
|
||||
internal/tray/
|
||||
tray.go new — getlantern/systray icon + menu
|
||||
icons.go new — embed idle/active/reconnecting/error ICOs
|
||||
|
||||
internal/autostart/
|
||||
autostart_windows.go new — HKCU\...\Run registry toggle
|
||||
|
||||
internal/single/
|
||||
single_windows.go new — named mutex + activation pipe
|
||||
|
||||
internal/config/
|
||||
config.go new — TOML schema + defaults
|
||||
loader.go new — load/save with file lock
|
||||
watcher.go new — fsnotify hot-reload
|
||||
|
||||
internal/gui/
|
||||
app.go existing — extend with engine bindings
|
||||
frontend/... existing — wire engine controls + autostart checkbox
|
||||
|
||||
third_party/windivert/ existing — WinDivert64.sys, WinDivert.dll, LICENSE-LGPL
|
||||
third_party/icons/ new — tray/{idle,active,reconnecting,error}.ico
|
||||
```
|
||||
|
||||
## Engine state machine
|
||||
|
||||
```
|
||||
┌────────┐
|
||||
│ Idle │ ◄────────────────── (initial)
|
||||
└────┬───┘
|
||||
│ user clicks "Start engine"
|
||||
▼
|
||||
┌────────────┐
|
||||
┌──────│ Starting │── any error ───┐
|
||||
│ └─────┬──────┘ │
|
||||
│ │ all checks ok │
|
||||
│ ▼ │
|
||||
│ ┌────────────┐ │
|
||||
│ │ Active │ ◄─── recover ─┐ │
|
||||
│ └────┬───────┘ │
|
||||
│ │ proxy lost / SOCKS5 │
|
||||
│ │ control channels died │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │Reconnecting │── 5 min cap ──┐ │
|
||||
│ └────┬────────┘ │
|
||||
│ │ recovered │
|
||||
│ ▼ │
|
||||
│ back to Active │
|
||||
│ │
|
||||
│ Stop button ─►───────────────────┐│
|
||||
│ ▼▼
|
||||
│ ┌────────┐
|
||||
└──── Stop ───────────────────►│ Failed │
|
||||
└────┬───┘
|
||||
│ user clicks Retry
|
||||
▼
|
||||
(back to Starting)
|
||||
```
|
||||
|
||||
States visible to GUI as `EngineStatus`:
|
||||
- `Idle` — engine off, tray icon grey, GUI shows "Start" button
|
||||
- `Starting` — handle being opened, procscan running, health-check; tray yellow with spin
|
||||
- `Active` — packets flowing; tray green; live stats updating
|
||||
- `Reconnecting` — proxy unreachable, exponential backoff in progress; tray yellow; "Reconnecting (3rd attempt)"
|
||||
- `Failed` — driver lost twice OR panic OR Reconnecting hit 5 min cap. Tray red. GUI shows error message + Retry button.
|
||||
|
||||
## E3 recovery rules (failure classifier)
|
||||
|
||||
```go
|
||||
// internal/engine/recovery.go
|
||||
|
||||
type FailureClass int
|
||||
const (
|
||||
ClassDriverLost FailureClass = iota // WinDivert handle invalid, ERROR_INVALID_HANDLE on Recv
|
||||
ClassDriverGone // WinDivertOpen returns ERROR_FILE_NOT_FOUND or similar
|
||||
ClassProxyUnreachable // SOCKS5 control TCP connection rejected/timeout
|
||||
ClassPanic // recover() in goroutine
|
||||
ClassSleep // WM_POWERBROADCAST suspend
|
||||
ClassResume // WM_POWERBROADCAST resume
|
||||
ClassFatal // anything we can't classify
|
||||
)
|
||||
|
||||
type Action int
|
||||
const (
|
||||
ActionRetryOnce Action = iota // sleep 2s, reopen, if fails again → Failed
|
||||
ActionExpBackoff // 1s → 5s → 30s cap, infinite, max 5min cumulative
|
||||
ActionFailStop // straight to Failed, write crash dump
|
||||
ActionPause // drain in-flight, close sockets, transition to Reconnecting
|
||||
ActionResume // wait 5s, reopen handle, transition to Active
|
||||
)
|
||||
|
||||
func ClassifyFailure(err error, class FailureClass) Action
|
||||
```
|
||||
|
||||
| Class | Action | UI feedback |
|
||||
|---|---|---|
|
||||
| `DriverLost` | RetryOnce | Status="reopening driver" |
|
||||
| `DriverGone` | FailStop | "Driver missing — reinstall Drover" |
|
||||
| `ProxyUnreachable` | ExpBackoff | "Reconnecting (Nth attempt)…" |
|
||||
| `Panic` | FailStop | "Engine crashed — log saved to %PROGRAMDATA%\\Drover\\logs\\crash-*.txt" |
|
||||
| `Sleep` | Pause | "Paused (system sleep)" |
|
||||
| `Resume` | Resume | "Resuming…" then back to Active |
|
||||
|
||||
**Health-check before Start engine**: GUI's Start button first runs `internal/checker.Run` with a reduced subset (tcp + greet + udp tests, 2s budget, no voice-quality). If any fails, the engine doesn't start and the GUI shows what failed. Prevents the "I clicked Start but Discord still doesn't work" mystery.
|
||||
|
||||
**Heartbeat timer**: every 5s, sample `(rxBytes_now - rxBytes_5sAgo) > 0`. If false for 30s while Active and procscan reports Discord PIDs > 0, set status=`Active (no traffic)` (informational sub-state, tray green→yellow but state machine stays in Active). User sees this and can investigate (Discord might just be idle).
|
||||
|
||||
**Crash dumps**: panic recover in any engine goroutine writes `%PROGRAMDATA%\Drover\logs\crash-YYYYMMDD-HHMMSS.txt` with full stack + goroutine dump + version. Then transitions to Failed.
|
||||
|
||||
## WinDivert layer
|
||||
|
||||
### Filter expression (rebuilt on PID list change)
|
||||
|
||||
```
|
||||
outbound and (tcp or udp) and ip
|
||||
and (processId == 12345 or processId == 67890 or ...)
|
||||
and processId != <own_pid>
|
||||
and ip.DstAddr != <upstream_proxy_ip>
|
||||
and not (ip.DstAddr >= 224.0.0.0 and ip.DstAddr <= 239.255.255.255)
|
||||
and not (ip.DstAddr >= 127.0.0.0 and ip.DstAddr <= 127.255.255.255)
|
||||
and not (ip.DstAddr >= 169.254.0.0 and ip.DstAddr <= 169.254.255.255)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `ip` (IPv4) only — no `ipv6` clause. Discord client falls back to v4 in ~150ms via Happy Eyeballs.
|
||||
- `processId != own_pid` is critical — without it our own SOCKS5 traffic to upstream gets caught and infinite-looped.
|
||||
- Multicast/loopback/link-local explicitly excluded (Discord never talks to those, but extra safety).
|
||||
|
||||
If the upstream proxy IP cannot be resolved at engine start, we fail-stop with a clear message — we cannot build a correct filter without it.
|
||||
|
||||
### Library choice
|
||||
|
||||
Use `github.com/imgk/divert-go` v0.1.0 (existing dep proposal — verify it still maintained when implementing P2.1). If unmaintained / broken, write thin syscall bindings directly — WinDivert C API is small (~6 functions used).
|
||||
|
||||
### Driver lifecycle
|
||||
|
||||
1. **First run**: extract embedded `WinDivert64.sys` + `WinDivert.dll` from Go `embed.FS` into `%PROGRAMDATA%\Drover\windivert\`. SHA256-verify against expected hashes (compiled in at build time).
|
||||
2. **Open handle**: `WinDivertOpen(filter, layer=NETWORK, priority=0, flags=0)`. The driver auto-installs as a Windows service named "WinDivert" on first open.
|
||||
3. **Driver remains installed across reboots** — we don't uninstall on Stop. Uninstaller (Inno Setup) explicitly does `sc stop WinDivert && sc delete WinDivert` on uninstall.
|
||||
|
||||
### Driver edge cases (D-series in matrix)
|
||||
|
||||
- **D-1: not installed** → embedded copy + auto-install on WinDivertOpen.
|
||||
- **D-2: old v1.x** (zapret legacy) → `WinDivertOpen` returns `ERROR_DRIVER_FAILED_PRIOR_UNLOAD`. Detect: query service "WinDivert" via `OpenServiceW` + `QueryServiceStatusEx` to read binary path → check version resource. Show "Outdated WinDivert detected from another tool. Stop the other tool and reboot."
|
||||
- **D-3: corrupted .sys** → SHA256 mismatch on extract. Reinstall path (delete + recopy + retry).
|
||||
- **D-4: AV quarantine** → embedded bytes don't match expected → show specific error: "Antivirus may have quarantined WinDivert64.sys. Add `%PROGRAMDATA%\Drover\` to your AV exclusions and restart Drover."
|
||||
- **D-5: reboot pending** → install successful but service not started → show "Reboot required to activate driver" with no retry button.
|
||||
- **D-7: ARM64** → `runtime.GOARCH` check at startup; on ARM64 show "Drover requires x86-64 Windows. WinDivert does not support ARM64."
|
||||
|
||||
## TCP redirect (NAT-loopback)
|
||||
|
||||
### Mechanism
|
||||
|
||||
1. On engine start, bind a TCP listener on `127.0.0.1:0` (OS picks unused port). Save the port number.
|
||||
2. WinDivert sees a new SYN from `Discord.exe → real_target_ip:real_target_port`. Engine:
|
||||
a. Modifies the IP header: `dst_addr = 127.0.0.1`, `dst_port = listener_port`. Stores mapping `(src_port → real_target_ip:port)` in a `sync.Map` with TTL 30 min.
|
||||
b. Recomputes IP + TCP checksums.
|
||||
c. Reinjects via `WinDivertSend` with direction=outbound. The kernel routes to loopback because dst is now 127.0.0.1.
|
||||
3. Listener `accept()` returns a conn from `127.0.0.1:src_port`. Engine looks up mapping by `src_port`, finds real_target.
|
||||
4. Engine opens fresh SOCKS5 control TCP to upstream, does greet + (auth if config) + CONNECT to real_target_ip:port.
|
||||
5. Once SOCKS5 returns REP=00, `io.Copy` pumps bytes both directions until EOF on either side.
|
||||
6. Conn close → drop mapping.
|
||||
|
||||
### TCP edge cases
|
||||
|
||||
- **T-1: listener bind fails** → fail-stop "could not bind loopback listener". Should never happen (random unused port).
|
||||
- **T-2: 100+ concurrent flows** — sync.Map scales fine. Bound only by Discord's TCP usage (typically 50).
|
||||
- **T-3: TCP retransmits** — handled by OS at both sides of the loopback.
|
||||
- **T-4: IPv6** — dropped at filter level. Discord falls back to v4.
|
||||
- **T-5: half-closed** — `io.Copy` returns on EOF in one direction; we close the other side via `defer conn.Close()`.
|
||||
- **T-6: mapping leak** if conn never properly closes — TTL 30min sweeper goroutine deletes stale entries.
|
||||
|
||||
## UDP redirect (SOCKS5 UDP ASSOCIATE)
|
||||
|
||||
### Mechanism
|
||||
|
||||
1. WinDivert sees outbound UDP from `Discord.exe:src_port → real_target_ip:port`. Engine:
|
||||
a. Looks up mapping by `(src_ip, src_port, real_target_ip, real_target_port)`. If absent:
|
||||
b. **Open new SOCKS5 control TCP** to upstream. Greet + (auth) + UDP ASSOCIATE.
|
||||
c. Receive relay endpoint `(relay_ip, relay_port)` — if BND.ADDR is `0.0.0.0` substitute `upstream_proxy_ip`.
|
||||
d. Open client-side UDP socket on `127.0.0.1:0`. Save mapping `flow_id → {control_tcp, relay, client_udp}`.
|
||||
2. **Outbound packet path**: encap with SOCKS5 UDP header `00 00 | 00 | ATYP=01 | DST_IP(4) | DST_PORT(2) | DATA`. Send via `client_udp.WriteTo(packet, relay)`. Don't reinject the original packet — drop it (we sent the encapsulated version through the relay).
|
||||
3. **Inbound packet path** (separate goroutine per flow): `client_udp.ReadFrom(buf)` → strip 10-byte SOCKS5 header → fabricate an IPv4+UDP packet with `src=real_target_ip:port, dst=Discord_src_ip:src_port`, recompute checksums → `WinDivertSend` direction=inbound. Discord sees a normal reply from real_target.
|
||||
4. Idle TTL 5 min: any flow with no packets for 5 min → close control_tcp + client_udp + remove mapping.
|
||||
|
||||
### UDP edge cases
|
||||
|
||||
- **U-1**: each flow gets its own control TCP. No pool in v1 (overhead is ~5KB per flow, fine for ~10 active flows).
|
||||
- **U-2: idle leak** → 5min TTL.
|
||||
- **U-3: Discord changes voice region** mid-call → old flow goes idle (5min TTL), new flow opens. Brief glitch.
|
||||
- **U-4: UDP fragments** → SOCKS5 RFC 1928 doesn't support FRAG. Drop. Discord packets are typically <1500 bytes; fragmentation rare.
|
||||
- **U-5: control TCP dies** → next packet detects via `Write` error → close mapping → next-next packet opens fresh control. Audio glitch ~2-3s.
|
||||
|
||||
## Process scanning
|
||||
|
||||
### Mechanism
|
||||
|
||||
`internal/procscan` runs every 2 seconds:
|
||||
1. `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)` → enumerate via `Process32First`/`Process32Next`. Microseconds.
|
||||
2. Filter by `szExeFile` against config `targets.processes` (case-insensitive on Windows).
|
||||
3. Diff vs previous PID set. If different → notify engine to rebuild filter expression and reopen WinDivert handle.
|
||||
|
||||
### Race: Discord starts up to 2s before procscan catches it
|
||||
|
||||
Mitigation: at engine `Start`, do **synchronous initial scan** before opening WinDivert handle. After that, the periodic 2s tick handles ongoing changes.
|
||||
|
||||
### Process edge cases
|
||||
|
||||
- **P-1: Discord PID changes** → 2s scan + 50ms reopen gap with direct traffic. Acceptable.
|
||||
- **P-2: multiple Discord variants**: default config includes `Discord.exe`, `DiscordCanary.exe`, `DiscordPTB.exe`, `Update.exe`. Vesktop **opt-in** via config (not default).
|
||||
- **P-3: Update.exe** (Discord's updater) included in default — it downloads patches via HTTP and we want those proxied too.
|
||||
- **P-5: PID re-use** (Discord exits, Chrome takes the PID before next scan) → 2s window where Chrome packets get proxied. Cosmetic, low-impact.
|
||||
|
||||
## Self-loop protection
|
||||
|
||||
The engine itself opens TCP/UDP connections to the upstream proxy. Without protection, the WinDivert filter would catch our own packets, encapsulate them in another SOCKS5 layer, infinite loop in seconds.
|
||||
|
||||
Three layers of defense:
|
||||
|
||||
1. `processId != own_pid` in the filter expression.
|
||||
2. `ip.DstAddr != <upstream_proxy_ip>` (resolved once at engine start; if upstream uses DDNS we re-resolve every 30s of failed reconnects).
|
||||
3. Listener and SOCKS5 client always bind to `127.0.0.1` — even if filter leaks, loopback traffic is excluded by `not (ip.DstAddr >= 127.0.0.0 ...)`.
|
||||
|
||||
## UAC + autostart (B1)
|
||||
|
||||
### Elevation
|
||||
|
||||
`cmd/drover/main.go` startup sequence:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
// 1. AttachConsole for CLI compatibility (existing)
|
||||
attachConsole()
|
||||
|
||||
// 2. Single-instance check (mutex). If second instance, send "show" to first and exit.
|
||||
if !single.AcquireMutex() {
|
||||
single.ActivateExistingInstance()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 3. Parse Cobra commands. CLI sub-commands like `--check` and `--version` don't need admin
|
||||
// and can run as user. The default GUI mode requires admin for WinDivert.
|
||||
if cmdNeedsAdmin() && !uac.IsAdmin() {
|
||||
uac.ReElevate(os.Args[1:]) // ShellExecute("runas", ...) + exit
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 4. Auto-update check (existing). Replace exe + relaunch if needed.
|
||||
autoUpdateOnStartup()
|
||||
|
||||
// 5. Boot Wails GUI + engine.
|
||||
gui.Run(Version)
|
||||
}
|
||||
```
|
||||
|
||||
`uac.ReElevate` uses `ShellExecuteW` with `lpVerb="runas"`. If user cancels UAC, `ShellExecute` returns `SE_ERR_ACCESSDENIED` → we exit cleanly without an error dialog (the user already saw their cancel intent).
|
||||
|
||||
### Autostart
|
||||
|
||||
Implemented via `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\DroverGo`:
|
||||
- Value type: REG_SZ, value: full path to `drover.exe` with no args
|
||||
- Set on toggle ON, deleted on toggle OFF
|
||||
- GUI Settings tab has a checkbox "Запускать при входе в Windows" that reads/writes this key
|
||||
|
||||
**Edge case A-5**: User disables autostart via Task Manager → Startup Apps. Windows writes a `Disabled` mark in `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run`. On GUI mount we check both keys; if Disabled → checkbox shown unchecked (user wins).
|
||||
|
||||
**Edge case A-6**: Stale path (drover.exe was moved). On every launch we re-write the key value to `os.Executable()` if autostart is enabled. Self-healing.
|
||||
|
||||
## Tray + window (D1)
|
||||
|
||||
### Tray icon (4 ICO files embedded)
|
||||
|
||||
| State | Icon | When shown |
|
||||
|---|---|---|
|
||||
| `idle` | grey | Engine not running |
|
||||
| `active` | green | Engine running, traffic flowing |
|
||||
| `reconnecting` | yellow | Reconnecting state OR no-traffic-detected |
|
||||
| `error` | red | Failed state |
|
||||
|
||||
### Tray menu (right-click)
|
||||
|
||||
```
|
||||
[●] Active · 2h 14m · ↑ 142 KB/s ↓ 1.2 MB/s [disabled status row, dynamic]
|
||||
─────────────────────────────────────
|
||||
[⏸] Stop proxying [primary action, contextual]
|
||||
[🔍] Run check [opens window + auto-runs check]
|
||||
─────────────────────────────────────
|
||||
[🪟] Show window [hidden when window is visible]
|
||||
[📁] Open log file
|
||||
─────────────────────────────────────
|
||||
[🔄] Check for updates
|
||||
[ℹ] About
|
||||
─────────────────────────────────────
|
||||
[✕] Quit
|
||||
```
|
||||
|
||||
The status row is updated every 1s while engine is running.
|
||||
|
||||
### Click behaviors
|
||||
|
||||
- Single-click tray icon → toggle window visibility
|
||||
- Double-click tray icon → open window (no toggle, always show)
|
||||
- X on window title bar → hide to tray (D1)
|
||||
- First-time only: toast "Drover свёрнут в трей. Engine продолжает работать. Закрыть полностью — через меню трея → Quit." Track via `config.ui.shown_tray_toast = true`.
|
||||
- Quit from tray menu → graceful engine stop → exit cleanly
|
||||
|
||||
### Library
|
||||
|
||||
`github.com/getlantern/systray`. Stable on Win10/11 modulo the explorer-restart edge case which the library handles internally.
|
||||
|
||||
## Single-instance enforcement
|
||||
|
||||
Mutex name: `Global\DroverGoInstance-<installID>` where `installID = SHA256(os.Executable())[:16]`. This way:
|
||||
- Installed copy at `C:\Program Files\Drover\drover.exe` and a portable copy at `D:\portable\drover.exe` get different mutexes — both can run.
|
||||
- Two simultaneous launches of the same install fight over the mutex; second loses.
|
||||
|
||||
Activation pipe: `\\.\pipe\drover-gui-<installID>`. Second instance opens it, writes `{"action":"show"}`, closes. First instance's listener goroutine pops the window to foreground.
|
||||
|
||||
If first instance crashes without cleanup → mutex disappears at process death (kernel handle table cleanup). Next launch acquires normally.
|
||||
|
||||
## Sleep/resume handling
|
||||
|
||||
`WM_POWERBROADCAST` listener via Windows message loop in a dedicated goroutine. Uses `RegisterPowerSettingNotification` for fine-grained events.
|
||||
|
||||
| Event | Action |
|
||||
|---|---|
|
||||
| `PBT_APMSUSPEND` | Engine: drain in-flight packets (give 200ms), close all SOCKS5 control TCPs, close WinDivert handle, set status="paused (sleep)" |
|
||||
| `PBT_APMRESUMEAUTOMATIC` or `PBT_APMRESUMESUSPEND` | Wait 5s for network reconnect (poll `GetIpForwardTable2` for default route presence), reopen WinDivert handle, run health-check, transition Active |
|
||||
|
||||
## Stats counters
|
||||
|
||||
Atomic counters in `internal/engine/stats.go`:
|
||||
- `bytesIn uint64` — bytes received from upstream (decapsulated UDP + TCP `io.Copy` returns)
|
||||
- `bytesOut uint64` — bytes sent to upstream
|
||||
- `tcpFlowsActive int32` — current count of open TCP redirects
|
||||
- `udpFlowsActive int32` — current count of open UDP flows
|
||||
- `startedAt time.Time` — engine start time (for uptime)
|
||||
|
||||
Per-flow counters discarded on flow close (no aggregation needed for v1).
|
||||
|
||||
Tray status row updates from these every 1s. GUI live stats panel does the same via Wails event `stats:update` (existing path).
|
||||
|
||||
Lifetime totals persisted to `%PROGRAMDATA%\Drover\stats.json` every 60s and on Stop.
|
||||
|
||||
## Config schema (TOML)
|
||||
|
||||
`%APPDATA%\Drover\config.toml`:
|
||||
|
||||
```toml
|
||||
# Drover-Go config — auto-managed by GUI; manual edits hot-reload via fsnotify.
|
||||
|
||||
version = 1
|
||||
|
||||
[proxy]
|
||||
host = "95.165.72.59"
|
||||
port = 12334
|
||||
auth = false
|
||||
login = ""
|
||||
password = ""
|
||||
udp_associate_timeout = "5s"
|
||||
tcp_connect_timeout = "10s"
|
||||
|
||||
[targets]
|
||||
processes = ["Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"]
|
||||
include_vesktop = false
|
||||
|
||||
[skip]
|
||||
# CIDR ranges to never proxy. Local + link-local always implicitly skipped at filter level.
|
||||
extra_skip_cidrs = []
|
||||
multicast = true
|
||||
|
||||
[ui]
|
||||
log_level = "info"
|
||||
log_max_mb = 10
|
||||
log_backups = 3
|
||||
tray_icon = true
|
||||
auto_start = false # mirror of HKCU\...\Run
|
||||
shown_tray_toast = false # one-shot first-close toast tracking
|
||||
theme = "dark" # dark | light | auto
|
||||
|
||||
[update]
|
||||
check_on_startup = true
|
||||
forgejo_repo = "git.okcu.io/root/drover-go"
|
||||
|
||||
[engine]
|
||||
heartbeat_interval = "5s"
|
||||
no_traffic_warn_after = "30s"
|
||||
reconnect_backoff_initial = "1s"
|
||||
reconnect_backoff_max = "30s"
|
||||
reconnect_total_cap = "5m"
|
||||
```
|
||||
|
||||
Edge cases:
|
||||
- **M-4 corrupted TOML** → log warning + use defaults + GUI shows banner "Config error line N — running with defaults".
|
||||
- **M-7 hot-reload** → fsnotify on the file. On change: re-parse → if proxy section changed → engine restart (Stop → wait clean → Start). Other sections apply live.
|
||||
- **Config migration** v1→v2 handled by `version` field; missing version assumes 1.
|
||||
|
||||
## Edge case matrix (full)
|
||||
|
||||
This is the master list. Every row must have a corresponding test or explicit "verified manually" note in the implementation plan.
|
||||
|
||||
| # | Edge case | Mitigation | Test |
|
||||
|---|---|---|---|
|
||||
| **D-1** | WinDivert.sys not installed | Embed binary, copy to %PROGRAMDATA%, WinDivertOpen auto-loads | manual: clean Win11 VM |
|
||||
| **D-2** | Old WinDivert v1.x present (zapret legacy) | Service version query → "remove old version first" error | manual: install zapret first, verify error |
|
||||
| **D-3** | Driver corrupted | SHA256 verify on extract → reinstall flow with progress | unit test: SHA256 mismatch path |
|
||||
| **D-4** | AV quarantines our embedded .sys | Specific AV-friendly error message + README link | manual: Defender enabled + first run |
|
||||
| **D-5** | Reboot pending after install | Show "Reboot to activate driver" | manual: trigger via DISM |
|
||||
| **D-7** | ARM64 Windows | Detect at startup, refuse install | unit: GOARCH=arm64 build returns expected error |
|
||||
| **P-1** | Discord PID changes | 2s procscan + filter rebuild | integration: kill+restart Discord, verify continuity |
|
||||
| **P-3** | Update.exe traffic | Default list includes it | integration: trigger Discord update, verify Update.exe traffic proxied |
|
||||
| **P-5** | PID re-use | Cosmetic 2s window | accept |
|
||||
| **L-1** | Self-loop (drover's own SOCKS5 traffic) | Filter excludes own_pid + upstream IP | unit: filter expression builder verifies own PID in output |
|
||||
| **T-4** | IPv6 Discord targets | Drop at filter level; Happy Eyeballs falls back | manual: verify with `netsh interface ipv6 set route ::/0 disabled` |
|
||||
| **T-6** | TCP mapping leak | 30min TTL cleanup | unit: TTL sweeper test |
|
||||
| **U-2** | Idle UDP flow leak | 5min TTL cleanup | unit: TTL sweeper test |
|
||||
| **U-4** | UDP fragments | Drop (SOCKS5 doesn't support FRAG) | accept (rare) |
|
||||
| **A-1** | User non-admin | UAC re-launch on startup | manual: standard user account |
|
||||
| **A-2** | UAC cancelled | Clean exit, no error dialog | manual: cancel UAC prompt |
|
||||
| **A-3** | UAC at every login (autostart) | Accepted per B1 | document in README |
|
||||
| **A-5** | Autostart disabled via Task Manager | Detect StartupApproved key, sync GUI checkbox | unit: registry mock |
|
||||
| **TR-1** | Tray icon disappears on explorer.exe restart | systray library handles re-attach | manual: kill+restart explorer.exe |
|
||||
| **TR-3** | First-time tray toast | Track `ui.shown_tray_toast` in config | unit: config writer |
|
||||
| **SI-1** | Mutex collision portable vs installed | installID = SHA256(exe path)[:16] | unit: two paths → two mutexes |
|
||||
| **SI-3** | First instance crashed without cleanup | Kernel cleans mutex on process death | manual: kill -9 first, launch second |
|
||||
| **SR-1** | System sleep | WM_POWERBROADCAST listener → graceful pause | manual: trigger sleep on test machine |
|
||||
| **SR-2** | System resume | Wait 5s network → reopen handle → resume | manual: wake from sleep |
|
||||
| **UP-1** | Auto-update during active engine | Graceful shutdown → replace exe → relaunch with prior state | manual: stage v0.1 → v0.2 update during voice call |
|
||||
| **M-1** | VPN concurrent | WinDivert ловит до VPN encap; SOCKS5 traffic to upstream IP — норма | manual: with WireGuard + Drover both active |
|
||||
| **M-4** | Config corrupted | Use defaults + warning banner | unit: malformed TOML → defaults applied |
|
||||
| **M-5** | Proxy IP changed (DDNS) | Re-resolve hostname every 30s of failed reconnect | unit: hostname resolver retry |
|
||||
| **M-7** | Hot-reload config | fsnotify → engine restart | integration: edit TOML, observe restart |
|
||||
|
||||
## Out of scope (Phase 3+)
|
||||
|
||||
- DPI bypass / fake QUIC injection (decision **C1**) — add as opt-in toggle in v0.4 if needed
|
||||
- Windows service mode (decision **A**) — add for power users in v0.4 if requested
|
||||
- IPv6 SOCKS5 ATYP=04 — add when we hit a v6-only proxy
|
||||
- ARM64 Windows — add when WinDivert ships ARM64 driver (waiting on basil00 upstream)
|
||||
- Multi-user PC scenarios — single-user assumption baked in
|
||||
- Vesktop default-on — stays opt-in via `targets.include_vesktop = true`
|
||||
- Custom DNS resolver / DNS-over-proxy — out of scope; DNS goes direct, document in README
|
||||
|
||||
## Phase 2 milestones
|
||||
|
||||
Each milestone is a separate `writing-plans` invocation followed by `subagent-driven-development` execution.
|
||||
|
||||
### P2.1 — TCP-only MVP (3-4 days)
|
||||
|
||||
**Scope**: WinDivert handle, filter expression, packet parser, TCP NAT-loopback redirect, SOCKS5 client (TCP CONNECT only), procscan, self-loop protection, basic engine state machine (Idle/Starting/Active/Failed without Reconnecting yet).
|
||||
|
||||
**Acceptance**:
|
||||
- Run drover.exe on Win11 with admin
|
||||
- Discord chat + Discord API requests routed through SOCKS5 (verify via tcpdump on mihomo: should see TCP CONNECT to discord.com:443 from upstream IP)
|
||||
- Voice does NOT yet work (UDP path absent) — documented expectation
|
||||
- Stop button cleanly closes everything in <500ms
|
||||
- Driver remains installed after exit (verify `sc query WinDivert`)
|
||||
- No self-loop infinite traffic (verify: bytes in == bytes out, not exponentially growing)
|
||||
|
||||
### P2.2 — UDP voice (3-4 days)
|
||||
|
||||
**Scope**: SOCKS5 UDP ASSOCIATE primitives (production-grade, not the diagnostic-only fork in checker), UDP flow tracker, packet encap/decap, IPv4-fabrication-and-reinject for inbound path.
|
||||
|
||||
**Acceptance**:
|
||||
- Voice call in Discord through proxy works without audible degradation
|
||||
- Up to 4 simultaneous voice calls (ish) work without flow leakage
|
||||
- Idle voice flow cleanup at 5min TTL (verified via debug log)
|
||||
- Mid-call proxy disconnect → flow drops → re-opens within 2s on next outbound packet → ~2-3s audible glitch
|
||||
- No memory leak after 1h voice call (RSS stable ±5MB)
|
||||
|
||||
### P2.3 — E3 recovery + sleep/resume (2 days)
|
||||
|
||||
**Scope**: failure classifier, contextual retry policies, Reconnecting state, exponential backoff, WM_POWERBROADCAST listener, heartbeat health-check.
|
||||
|
||||
**Acceptance**:
|
||||
- Stop mihomo on LXC 102 mid-session → engine transitions Active → Reconnecting → Active when mihomo back up (within 30s of recovery)
|
||||
- Trigger machine sleep mid-voice-call → engine pauses gracefully → wake → engine resumes within 10s after network up → voice continues (Discord client itself reconnects)
|
||||
- WinDivert handle externally killed (`sc stop WinDivert && sc start WinDivert`) → engine reopens once → if second kill within 30s → Failed with crash log
|
||||
- Heartbeat detects "no traffic" while Discord open and idle → tray turns yellow with "no traffic" tooltip → no Failed transition
|
||||
|
||||
### P2.4 — Tray + autostart + engine UI (2-3 days)
|
||||
|
||||
**Scope**: getlantern/systray integration, 4 ICO icons, tray menu (D1 + first-time toast), autostart checkbox in GUI Settings tab, Start/Stop buttons in main window wired to engine, status indicator with state machine awareness, single-instance enforcement.
|
||||
|
||||
**Acceptance**:
|
||||
- Toggle autostart on → reboot → drover launches at login (after UAC accept)
|
||||
- X on window → first-time toast → second X → silent hide
|
||||
- Start button only enabled when checker passed (or in Failed state with Retry)
|
||||
- Tray icon updates within 200ms of state change
|
||||
- Two simultaneous launches → second activates first's window and exits silently
|
||||
- Status row in tray menu updates every 1s while Active
|
||||
|
||||
### P2.5 — Polish (2-3 days)
|
||||
|
||||
**Scope**: crash dumps, config hot-reload via fsnotify, AV-friendly error messages, all remaining edge cases from matrix, README troubleshooting, install/uninstall verification on clean Win11 VM.
|
||||
|
||||
**Acceptance**:
|
||||
- Every edge case in the matrix has either a passing test or a verified manual reproduction note in `docs/testing/p2-edge-cases.md`
|
||||
- Install on clean Win11 VM, run for 1 hour without intervention, no errors
|
||||
- Uninstall via Apps & Features removes everything except optionally-kept config (asked at uninstall)
|
||||
- README has SmartScreen + AV troubleshooting sections with screenshots
|
||||
|
||||
**Total**: ~12-16 days to v1.0.0.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Unit tests (per-package)
|
||||
|
||||
- `divert/filter`: filter expression builder produces expected strings for various PID lists
|
||||
- `divert/packet`: parse + serialize + checksum recompute is round-trip identity
|
||||
- `engine/recovery`: failure classifier returns expected Action for each FailureClass
|
||||
- `socks5/udp`: encap/decap round-trip
|
||||
- `procscan`: snapshot diffing, mocked toolhelp32
|
||||
- `autostart`: registry read/write/disabled-detection (with mock registry)
|
||||
- `single`: mutex acquire + release lifecycle
|
||||
- `config`: defaults applied, malformed TOML → defaults + warning, version migration
|
||||
|
||||
### Integration tests (each milestone has its own)
|
||||
|
||||
- `engine_test.go`: mock WinDivert + mock SOCKS5 server in-process, exercise full pipeline
|
||||
- `redirect_test.go`: spin up TCP listener, fake Discord client, fake SOCKS5 server, verify bytes flow
|
||||
|
||||
### Manual test plan (per milestone, in `docs/testing/p2-<milestone>-manual.md`)
|
||||
|
||||
Each manual test case is a numbered step-by-step with expected outcome. Run on clean Win11 VM snapshot before each milestone tag.
|
||||
|
||||
### End-to-end (manual, before v1.0.0)
|
||||
|
||||
Full user journey in `docs/testing/p2-e2e.md`:
|
||||
1. Download installer from Forgejo release
|
||||
2. Install via setup.exe (UAC prompt)
|
||||
3. First launch: configure proxy, run check, click Start
|
||||
4. Run Discord, place voice call → verify routing via mihomo logs
|
||||
5. Toggle autostart on
|
||||
6. Reboot → verify drover starts at login (UAC accept)
|
||||
7. Sleep + wake cycle → verify continuity
|
||||
8. Stop mihomo → verify Reconnecting state → restart mihomo → verify recovery
|
||||
9. Quit via tray menu → verify clean shutdown
|
||||
10. Uninstall → verify cleanup
|
||||
|
||||
## Open questions / assumptions to validate during P2.1
|
||||
|
||||
1. **`imgk/divert-go` v0.1.0 still works with WinDivert v2.2.2?** If not, switch to direct syscall bindings. Verify in P2.1 day 1.
|
||||
2. **Filter expression length limit** — WinDivert filter expressions have a max length. With 4 Discord PIDs + own PID + upstream IP exclusion + multicast we should be well under, but if user adds 10+ Vesktop variants we might hit it. Verify and document limit during P2.1.
|
||||
3. **`WinDivertSend` for inbound packets we synthesize** — does the kernel correctly route a fabricated `dst=Discord_IP, src=real_target_IP` packet back to Discord's socket? Most divert-based tools do this; verify in P2.2 day 1 with a tracer.
|
||||
4. **Embedded ICO size on disk** — 4 icons × ~5KB = 20KB. Negligible.
|
||||
|
||||
## Files to read before implementation
|
||||
|
||||
- `imgk/shadow/pkg/divert/` — opens handle + read packets pattern (downloaded already)
|
||||
- `imgk/divert-go` README + `addr.go` — API surface
|
||||
- `runetfreedom/force-proxy/proxy.cpp` — correct SOCKS5 UDP ASSOCIATE flow (local at `/tmp/drover-cmp/force-proxy/`)
|
||||
- `wailsapp/wails/v2/examples/react` — Wails patterns for Engine bindings
|
||||
- This spec.
|
||||
@@ -5,13 +5,45 @@ go 1.23
|
||||
require (
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/mod v0.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
golang.org/x/mod v0.23.0
|
||||
golang.org/x/sys v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.2.0 // indirect
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/imgk/divert-go v0.1.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,34 +1,118 @@
|
||||
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
||||
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/imgk/divert-go v0.1.0 h1:PTB6jsmj5j2ymDBaiyhh0mzQ8ldma10mNaq1tknJysM=
|
||||
github.com/imgk/divert-go v0.1.0/go.mod h1:8j670dnMAWuHP3AHj7Zd8b4HhGw4mdTo8aYhCWNsAeU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package app wires the Wails application (Go ↔ JS bindings).
|
||||
package app
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package bypass implements DPI bypass via fake QUIC injection.
|
||||
package bypass
|
||||
@@ -0,0 +1,675 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status represents the lifecycle state of a single test.
|
||||
type Status string
|
||||
|
||||
// Result statuses emitted on the channel.
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusPassed Status = "passed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusSkipped Status = "skipped"
|
||||
// StatusWarn is a "soft pass" — the test technically succeeded but
|
||||
// the user should know about a degradation (e.g. voice quality at the
|
||||
// upper end of acceptable, or all Discord voice domains resolve but
|
||||
// the proxy filters their TCP). Frontend renders it like StatusPassed
|
||||
// but keeps the Hint visible.
|
||||
StatusWarn Status = "warn"
|
||||
)
|
||||
|
||||
// Result is one event in the diagnostic stream. Multiple Results may be
|
||||
// emitted per test (one per attempt: running + passed/failed; on retry,
|
||||
// running again then passed/failed).
|
||||
type Result struct {
|
||||
ID string `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Metric string `json:"metric,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
RawHex string `json:"raw_hex,omitempty"`
|
||||
Duration time.Duration `json:"duration_ms"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
// Config drives Run. Zero-value fields receive defaults via applyDefaults.
|
||||
type Config struct {
|
||||
ProxyHost string
|
||||
ProxyPort int
|
||||
UseAuth bool
|
||||
ProxyLogin string
|
||||
ProxyPassword string
|
||||
|
||||
PerTestTimeout time.Duration
|
||||
MaxRetries int
|
||||
RetryBackoff time.Duration
|
||||
|
||||
DiscordGateway string
|
||||
DiscordAPI string
|
||||
StunServer string
|
||||
|
||||
// Voice-quality burst tuning (see runVoiceQuality). Defaults: 30
|
||||
// packets, 20ms between sends.
|
||||
VoiceBurstCount int
|
||||
VoiceBurstInterval time.Duration
|
||||
}
|
||||
|
||||
// applyDefaults returns a copy of cfg with zero-valued knobs filled in.
|
||||
func applyDefaults(cfg Config) Config {
|
||||
if cfg.PerTestTimeout <= 0 {
|
||||
cfg.PerTestTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.MaxRetries < 0 {
|
||||
cfg.MaxRetries = 0
|
||||
}
|
||||
if cfg.MaxRetries == 0 {
|
||||
// Distinguish "explicit 0" from "unset" — spec says default is 1.
|
||||
// applyDefaults runs on a copy of the caller's Config; we treat
|
||||
// a literal zero as "use default" so a fresh `Config{}` works.
|
||||
cfg.MaxRetries = 1
|
||||
}
|
||||
if cfg.RetryBackoff < 0 {
|
||||
cfg.RetryBackoff = 500 * time.Millisecond
|
||||
}
|
||||
if cfg.RetryBackoff == 0 {
|
||||
cfg.RetryBackoff = 500 * time.Millisecond
|
||||
}
|
||||
if cfg.DiscordGateway == "" {
|
||||
cfg.DiscordGateway = "gateway.discord.gg:443"
|
||||
}
|
||||
if cfg.DiscordAPI == "" {
|
||||
cfg.DiscordAPI = "https://discord.com/api/v9/gateway"
|
||||
}
|
||||
if cfg.StunServer == "" {
|
||||
cfg.StunServer = "stun.l.google.com:19302"
|
||||
}
|
||||
if cfg.VoiceBurstCount <= 0 {
|
||||
cfg.VoiceBurstCount = 30
|
||||
}
|
||||
if cfg.VoiceBurstInterval <= 0 {
|
||||
cfg.VoiceBurstInterval = 20 * time.Millisecond
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Run executes the 7-step diagnostic and streams Results on the returned
|
||||
// channel. The channel is closed when the run finishes (or is cancelled).
|
||||
//
|
||||
// Cancel ctx to abort: the in-flight test emits a Failed Result with
|
||||
// Error="cancelled", and remaining tests each emit a single Skipped Result.
|
||||
func Run(ctx context.Context, cfg Config) <-chan Result {
|
||||
cfg = applyDefaults(cfg)
|
||||
ch := make(chan Result, 16)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
e := &executor{ctx: ctx, cfg: cfg, ch: ch}
|
||||
defer e.cleanup()
|
||||
|
||||
e.runTCP()
|
||||
e.runGreet()
|
||||
if cfg.UseAuth {
|
||||
e.runAuth()
|
||||
}
|
||||
e.runConnect()
|
||||
e.runUDP()
|
||||
e.runAPI()
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// executor carries shared state across the 7 test methods.
|
||||
type executor struct {
|
||||
ctx context.Context
|
||||
cfg Config
|
||||
ch chan<- Result
|
||||
|
||||
// tcpConn is opened in runTCP and reused by greet/auth/connect.
|
||||
tcpConn net.Conn
|
||||
|
||||
// udpConn2 is the SECOND TCP control channel opened in runUDP.
|
||||
// Must stay alive until stun finishes — the SOCKS5 spec requires
|
||||
// the control TCP connection to remain up for the relay to be
|
||||
// valid.
|
||||
udpConn2 net.Conn
|
||||
|
||||
// udpRelay is the UDP relay endpoint announced by the proxy in
|
||||
// the UDP ASSOCIATE reply.
|
||||
udpRelay *net.UDPAddr
|
||||
|
||||
// udpClient is our local UDP socket used to talk to the relay.
|
||||
udpClient net.PacketConn
|
||||
|
||||
// Step gating: each xOK is set true on success (or "soft pass"
|
||||
// warn for voice-quality).
|
||||
tcpOK, greetOK, authOK, connectOK, udpOK, voiceQualityOK bool
|
||||
|
||||
// Cancellation latch. Once any test emits a "cancelled" failure,
|
||||
// remaining tests emit a single Skipped result with the same reason.
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
// cleanup closes any state opened during the run.
|
||||
func (e *executor) cleanup() {
|
||||
if e.tcpConn != nil {
|
||||
_ = e.tcpConn.Close()
|
||||
}
|
||||
if e.udpConn2 != nil {
|
||||
_ = e.udpConn2.Close()
|
||||
}
|
||||
if e.udpClient != nil {
|
||||
_ = e.udpClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// emit sends a Result on the channel, respecting ctx so a stalled consumer
|
||||
// doesn't block us forever.
|
||||
func (e *executor) emit(r Result) {
|
||||
select {
|
||||
case e.ch <- r:
|
||||
case <-e.ctx.Done():
|
||||
// Best-effort: try once more so we don't drop user-visible
|
||||
// information just because cancel raced the send.
|
||||
select {
|
||||
case e.ch <- r:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emitSkipped pushes a single skipped Result with a constant reason.
|
||||
func (e *executor) emitSkipped(id, reason string) {
|
||||
e.emit(Result{ID: id, Status: StatusSkipped, Error: reason})
|
||||
}
|
||||
|
||||
// emitCancelled pushes a single failed Result with Error="cancelled".
|
||||
func (e *executor) emitCancelled(id string, attempt int, dur time.Duration) {
|
||||
e.cancelled = true
|
||||
e.emit(Result{
|
||||
ID: id,
|
||||
Status: StatusFailed,
|
||||
Error: "cancelled",
|
||||
Hint: hintFor(id, context.Canceled),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
}
|
||||
|
||||
// shouldSkip checks high-level guard conditions and emits the appropriate
|
||||
// pre-test Result if we shouldn't run. Returns true if the caller should
|
||||
// abort the test.
|
||||
func (e *executor) shouldSkip(id string, depOK bool) bool {
|
||||
if e.cancelled {
|
||||
e.emitSkipped(id, "cancelled")
|
||||
return true
|
||||
}
|
||||
if !depOK {
|
||||
e.emitSkipped(id, skipReason)
|
||||
return true
|
||||
}
|
||||
if err := e.ctx.Err(); err != nil {
|
||||
e.emitCancelled(id, 1, 0)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const skipReason = "depends on previous failed step"
|
||||
|
||||
// rawHexRE pulls "...(raw=DEADBEEF)" out of a wrapped error string.
|
||||
var rawHexRE = regexp.MustCompile(`\(raw=([0-9a-fA-F]+)\)`)
|
||||
|
||||
// extractRawHex pulls the hex payload out of our `(raw=XX...)` error
|
||||
// wrapping convention. Returns "" if absent.
|
||||
func extractRawHex(s string) string {
|
||||
m := rawHexRE.FindStringSubmatch(s)
|
||||
if len(m) == 2 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runAttempt is the inner loop shared by all tests. It handles emitting
|
||||
// running/passed/failed results, retry classification and backoff.
|
||||
//
|
||||
// run does the actual work for one attempt and returns metric + err.
|
||||
func (e *executor) runAttempt(id string, run func(ctx context.Context) (string, error)) (ok bool) {
|
||||
maxAttempts := 1 + e.cfg.MaxRetries
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if err := e.ctx.Err(); err != nil {
|
||||
e.emitCancelled(id, attempt, 0)
|
||||
return false
|
||||
}
|
||||
|
||||
// Emit running for this attempt.
|
||||
e.emit(Result{ID: id, Status: StatusRunning, Attempt: attempt})
|
||||
|
||||
attemptCtx, cancel := context.WithTimeout(e.ctx, e.cfg.PerTestTimeout)
|
||||
start := time.Now()
|
||||
metric, err := run(attemptCtx)
|
||||
dur := time.Since(start)
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
e.emit(Result{
|
||||
ID: id,
|
||||
Status: StatusPassed,
|
||||
Metric: metric,
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Parent-ctx cancelled? Emit cancelled and stop (no retry
|
||||
// into a cancelled context). We check the PARENT ctx, not
|
||||
// attemptCtx (which always expires after PerTestTimeout).
|
||||
if e.ctx.Err() != nil {
|
||||
e.emitCancelled(id, attempt, dur)
|
||||
return false
|
||||
}
|
||||
|
||||
// Per-attempt deadline expired (PerTestTimeout fired) —
|
||||
// treat as a transient timeout. We need to override
|
||||
// classifyError here because err's chain contains
|
||||
// context.DeadlineExceeded (joinCtxErr embeds attemptCtx.Err)
|
||||
// which classifyError treats as permanent. The semantic
|
||||
// distinction is "our per-test budget vs caller cancel" —
|
||||
// the former is exactly what retries are for.
|
||||
var class Classification
|
||||
if isContextErr(err) {
|
||||
// Parent ctx is fine (checked above), so this is a
|
||||
// per-attempt deadline = transient.
|
||||
class = ClassificationTransient
|
||||
} else {
|
||||
class = classifyError(err)
|
||||
}
|
||||
canRetry := class == ClassificationTransient && attempt < maxAttempts
|
||||
if canRetry {
|
||||
// Failed-but-will-retry: still emit Failed for the
|
||||
// observer (so they see the attempt happened), but
|
||||
// loop. Some consumers only show the LAST failure;
|
||||
// emitting every attempt is the more transparent
|
||||
// option. Spec says "emit running + passed/failed
|
||||
// per attempt".
|
||||
e.emit(Result{
|
||||
ID: id,
|
||||
Status: StatusFailed,
|
||||
Error: err.Error(),
|
||||
Hint: hintFor(id, err),
|
||||
RawHex: extractRawHex(err.Error()),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
// Sleep with cancel awareness.
|
||||
select {
|
||||
case <-time.After(e.cfg.RetryBackoff):
|
||||
case <-e.ctx.Done():
|
||||
// Caller cancelled during backoff — stop without retry.
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Final failure (permanent or out of retries).
|
||||
e.emit(Result{
|
||||
ID: id,
|
||||
Status: StatusFailed,
|
||||
Error: err.Error(),
|
||||
Hint: hintFor(id, err),
|
||||
RawHex: extractRawHex(err.Error()),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// proxyAddr returns the SOCKS5 proxy host:port string.
|
||||
func (e *executor) proxyAddr() string {
|
||||
return net.JoinHostPort(e.cfg.ProxyHost, strconv.Itoa(e.cfg.ProxyPort))
|
||||
}
|
||||
|
||||
// runTCP — Test 1: dial the proxy.
|
||||
func (e *executor) runTCP() {
|
||||
if e.cancelled {
|
||||
e.emitSkipped("tcp", "cancelled")
|
||||
return
|
||||
}
|
||||
if err := e.ctx.Err(); err != nil {
|
||||
e.emitCancelled("tcp", 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
ok := e.runAttempt("tcp", func(ctx context.Context) (string, error) {
|
||||
// Close any prior conn from a previous attempt.
|
||||
if e.tcpConn != nil {
|
||||
_ = e.tcpConn.Close()
|
||||
e.tcpConn = nil
|
||||
}
|
||||
var d net.Dialer
|
||||
start := time.Now()
|
||||
conn, err := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
e.tcpConn = conn
|
||||
ms := time.Since(start).Milliseconds()
|
||||
return fmt.Sprintf("%dms", ms), nil
|
||||
})
|
||||
|
||||
e.tcpOK = ok
|
||||
}
|
||||
|
||||
// runGreet — Test 2: SOCKS5 method negotiation.
|
||||
func (e *executor) runGreet() {
|
||||
if e.shouldSkip("greet", e.tcpOK) {
|
||||
return
|
||||
}
|
||||
|
||||
ok := e.runAttempt("greet", func(ctx context.Context) (string, error) {
|
||||
// Each attempt needs a fresh conn — the previous attempt
|
||||
// may have written bytes that left the proxy mid-handshake.
|
||||
if err := e.redialTCPIfNeeded(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
method, _, err := socks5Greeting(ctx, e.tcpConn, e.cfg.UseAuth)
|
||||
if err != nil {
|
||||
// Force redial on next attempt.
|
||||
_ = e.tcpConn.Close()
|
||||
e.tcpConn = nil
|
||||
return "", err
|
||||
}
|
||||
switch method {
|
||||
case 0x00:
|
||||
return "no auth", nil
|
||||
case 0x02:
|
||||
return "auth required", nil
|
||||
default:
|
||||
return fmt.Sprintf("method=0x%02X", method), nil
|
||||
}
|
||||
})
|
||||
e.greetOK = ok
|
||||
}
|
||||
|
||||
// redialTCPIfNeeded drops and re-opens tcpConn. This is called at the
|
||||
// start of each greet/auth/connect attempt after the first to give every
|
||||
// attempt a fresh connection — the proxy may have advanced state on the
|
||||
// previous attempt that we can't roll back.
|
||||
//
|
||||
// On the FIRST attempt for greet, we expect tcpConn to already be open
|
||||
// (from runTCP). The simple rule: if tcpConn==nil, redial; otherwise
|
||||
// keep it. The retry path closes tcpConn before re-running this loop.
|
||||
func (e *executor) redialTCPIfNeeded(ctx context.Context) error {
|
||||
if e.tcpConn != nil {
|
||||
return nil
|
||||
}
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.tcpConn = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAuth — Test 3: user/pass sub-negotiation. Only emitted when UseAuth.
|
||||
func (e *executor) runAuth() {
|
||||
if e.shouldSkip("auth", e.greetOK) {
|
||||
return
|
||||
}
|
||||
|
||||
ok := e.runAttempt("auth", func(ctx context.Context) (string, error) {
|
||||
// On retry: drop the conn and start fresh from greet+auth.
|
||||
// (We can't replay only auth — the proxy has already moved
|
||||
// past method negotiation.)
|
||||
// retry detection: if we have nil tcpConn here, we lost it
|
||||
// in a prior failed attempt and need to redial+regreet.
|
||||
if e.tcpConn == nil {
|
||||
var d net.Dialer
|
||||
conn, derr := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if derr != nil {
|
||||
return "", derr
|
||||
}
|
||||
e.tcpConn = conn
|
||||
if _, _, gerr := socks5Greeting(ctx, e.tcpConn, true); gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
}
|
||||
_, err := socks5Auth(ctx, e.tcpConn, e.cfg.ProxyLogin, e.cfg.ProxyPassword)
|
||||
if err != nil {
|
||||
// Force redial+regreet on next attempt.
|
||||
_ = e.tcpConn.Close()
|
||||
e.tcpConn = nil
|
||||
return "", err
|
||||
}
|
||||
return "ok", nil
|
||||
})
|
||||
e.authOK = ok
|
||||
}
|
||||
|
||||
// runConnect — Test 4: SOCKS5 CONNECT to Discord gateway.
|
||||
func (e *executor) runConnect() {
|
||||
dep := e.greetOK && (!e.cfg.UseAuth || e.authOK)
|
||||
if e.shouldSkip("connect", dep) {
|
||||
return
|
||||
}
|
||||
|
||||
host, portStr, splitErr := net.SplitHostPort(e.cfg.DiscordGateway)
|
||||
if splitErr != nil {
|
||||
e.emit(Result{
|
||||
ID: "connect",
|
||||
Status: StatusFailed,
|
||||
Error: fmt.Sprintf("bad DiscordGateway %q: %s", e.cfg.DiscordGateway, splitErr.Error()),
|
||||
Hint: hintFor("connect", splitErr),
|
||||
Attempt: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
port64, perr := strconv.ParseUint(portStr, 10, 16)
|
||||
if perr != nil {
|
||||
e.emit(Result{
|
||||
ID: "connect",
|
||||
Status: StatusFailed,
|
||||
Error: fmt.Sprintf("bad DiscordGateway port %q: %s", portStr, perr.Error()),
|
||||
Hint: hintFor("connect", perr),
|
||||
Attempt: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
port := uint16(port64)
|
||||
|
||||
ok := e.runAttempt("connect", func(ctx context.Context) (string, error) {
|
||||
// On retry: redial+greet+(auth) before re-CONNECT.
|
||||
if e.tcpConn == nil {
|
||||
var d net.Dialer
|
||||
conn, derr := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if derr != nil {
|
||||
return "", derr
|
||||
}
|
||||
e.tcpConn = conn
|
||||
if _, _, gerr := socks5Greeting(ctx, e.tcpConn, e.cfg.UseAuth); gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
if e.cfg.UseAuth {
|
||||
if _, aerr := socks5Auth(ctx, e.tcpConn, e.cfg.ProxyLogin, e.cfg.ProxyPassword); aerr != nil {
|
||||
return "", aerr
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := socks5Connect(ctx, e.tcpConn, host, port)
|
||||
if err != nil {
|
||||
_ = e.tcpConn.Close()
|
||||
e.tcpConn = nil
|
||||
return "", err
|
||||
}
|
||||
return "REP=00", nil
|
||||
})
|
||||
e.connectOK = ok
|
||||
}
|
||||
|
||||
// runUDP — Test 5: open second TCP control channel and UDP ASSOCIATE.
|
||||
// isUnroutableRelayIP returns true for IPs we shouldn't trust as the
|
||||
// real relay endpoint when the proxy advertised them in BND.ADDR:
|
||||
// 0.0.0.0 (per RFC 1928 spec), private RFC 1918 ranges (mihomo on a
|
||||
// LAN can return its 192.168.x.x interface), and loopback. Caller
|
||||
// should substitute the proxy host instead.
|
||||
func isUnroutableRelayIP(ip net.IP) bool {
|
||||
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
v4 := ip.To4()
|
||||
if v4 == nil {
|
||||
return false
|
||||
}
|
||||
// 10.0.0.0/8
|
||||
if v4[0] == 10 {
|
||||
return true
|
||||
}
|
||||
// 172.16.0.0/12
|
||||
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
|
||||
return true
|
||||
}
|
||||
// 192.168.0.0/16
|
||||
if v4[0] == 192 && v4[1] == 168 {
|
||||
return true
|
||||
}
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if v4[0] == 169 && v4[1] == 254 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *executor) runUDP() {
|
||||
dep := e.greetOK && (!e.cfg.UseAuth || e.authOK)
|
||||
if e.shouldSkip("udp", dep) {
|
||||
return
|
||||
}
|
||||
|
||||
ok := e.runAttempt("udp", func(ctx context.Context) (string, error) {
|
||||
// Always use a fresh control channel for UDP ASSOCIATE.
|
||||
if e.udpConn2 != nil {
|
||||
_ = e.udpConn2.Close()
|
||||
e.udpConn2 = nil
|
||||
}
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
e.udpConn2 = conn
|
||||
if _, _, gerr := socks5Greeting(ctx, conn, e.cfg.UseAuth); gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
if e.cfg.UseAuth {
|
||||
if _, aerr := socks5Auth(ctx, conn, e.cfg.ProxyLogin, e.cfg.ProxyPassword); aerr != nil {
|
||||
return "", aerr
|
||||
}
|
||||
}
|
||||
relay, _, uerr := socks5UDPAssociate(ctx, conn)
|
||||
if uerr != nil {
|
||||
return "", uerr
|
||||
}
|
||||
// RFC 1928 says when BND.ADDR == 0.0.0.0, substitute the proxy
|
||||
// host. We extend that: when the proxy returns a *private* IP
|
||||
// (mihomo on LAN often advertises its 192.168.x.x interface
|
||||
// because that's the iface it bound), it's unreachable for
|
||||
// clients outside that LAN — substitute with the proxy host
|
||||
// the user is already connecting to.
|
||||
if isUnroutableRelayIP(relay.IP) {
|
||||
if hostIP := net.ParseIP(e.cfg.ProxyHost); hostIP != nil {
|
||||
relay.IP = hostIP
|
||||
}
|
||||
}
|
||||
e.udpRelay = relay
|
||||
return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil
|
||||
})
|
||||
e.udpOK = ok
|
||||
}
|
||||
|
||||
// runAPI — Test 6: HTTP GET Discord API gateway URL through the proxy.
|
||||
func (e *executor) runAPI() {
|
||||
if e.shouldSkip("api", e.connectOK) {
|
||||
return
|
||||
}
|
||||
|
||||
e.runAttempt("api", func(ctx context.Context) (string, error) {
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _network, addr string) (net.Conn, error) {
|
||||
return e.dialThroughProxy(ctx, addr)
|
||||
},
|
||||
TLSClientConfig: &tls.Config{},
|
||||
DisableKeepAlives: true,
|
||||
ResponseHeaderTimeout: e.cfg.PerTestTimeout,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: e.cfg.PerTestTimeout,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", e.cfg.DiscordAPI, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 || resp.StatusCode == 401 {
|
||||
return fmt.Sprintf("HTTP %d", resp.StatusCode), nil
|
||||
}
|
||||
return "", fmt.Errorf("api: HTTP %d", resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// dialThroughProxy is the http.Transport.DialContext used by runAPI. It
|
||||
// opens a TCP connection to the SOCKS5 proxy, performs greet+(auth)+CONNECT
|
||||
// to addr, then returns the established conn.
|
||||
func (e *executor) dialThroughProxy(ctx context.Context, addr string) (net.Conn, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("api: split %q: %w", addr, err)
|
||||
}
|
||||
port64, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("api: bad port %q: %w", portStr, err)
|
||||
}
|
||||
port := uint16(port64)
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, gerr := socks5Greeting(ctx, conn, e.cfg.UseAuth); gerr != nil {
|
||||
_ = conn.Close()
|
||||
return nil, gerr
|
||||
}
|
||||
if e.cfg.UseAuth {
|
||||
if _, aerr := socks5Auth(ctx, conn, e.cfg.ProxyLogin, e.cfg.ProxyPassword); aerr != nil {
|
||||
_ = conn.Close()
|
||||
return nil, aerr
|
||||
}
|
||||
}
|
||||
if _, cerr := socks5Connect(ctx, conn, host, port); cerr != nil {
|
||||
_ = conn.Close()
|
||||
return nil, cerr
|
||||
}
|
||||
// Clear the deadline socks5* primitives applied — http.Transport
|
||||
// manages timing past this point.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return conn, nil
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeProxy is a test SOCKS5 server with per-scenario behaviour. It also
|
||||
// optionally runs a UDP relay that echoes STUN-shaped responses crafted
|
||||
// to look like Binding Success Responses with XOR-MAPPED-ADDRESS pointing
|
||||
// back at the client's source IP.
|
||||
//
|
||||
// The TCP-side splice for the API test detects CONNECT requests targeting
|
||||
// apiTargetHost:apiTargetPort and, instead of sending a synthetic reply,
|
||||
// dials apiTargetAddr and bridges the two conns. This lets a real
|
||||
// httptest.NewServer be used as the API endpoint.
|
||||
type fakeProxy struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
scenario string
|
||||
|
||||
udpRelayAddr *net.UDPAddr // announced in UDP ASSOCIATE reply
|
||||
|
||||
// udpDropEveryN, when > 0, drops every Nth packet through the relay
|
||||
// (counted across the whole listener lifetime). N=2 → 50% loss; N=10
|
||||
// → 10%; N=1 → 100% loss; 0 → no drops.
|
||||
udpDropEveryN atomic.Int32
|
||||
udpRelayCount atomic.Int32
|
||||
|
||||
// API-passthrough hook: when a CONNECT targets this host:port,
|
||||
// the proxy dials apiTargetAddr and splices the conns instead of
|
||||
// sending a fake REP=00 + close.
|
||||
apiTargetHost string
|
||||
apiTargetPort uint16
|
||||
apiTargetAddr string
|
||||
|
||||
// timeoutFirstAttempt stalls the first connection on greet to
|
||||
// drive a timeout. Subsequent connections behave normally.
|
||||
timeoutFirstAttempt atomic.Int32
|
||||
}
|
||||
|
||||
// newFakeProxy starts a TCP listener and a UDP relay (if relevant for
|
||||
// the scenario). Both are torn down via t.Cleanup.
|
||||
func newFakeProxy(t *testing.T, scenario string) *fakeProxy {
|
||||
t.Helper()
|
||||
|
||||
fp := &fakeProxy{t: t, scenario: scenario}
|
||||
|
||||
// Start UDP relay for scenarios that need it.
|
||||
if needsUDPRelay(scenario) {
|
||||
ua, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
uconn, err := net.ListenUDP("udp", ua)
|
||||
require.NoError(t, err)
|
||||
fp.udpRelayAddr = uconn.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
t.Cleanup(func() { _ = uconn.Close() })
|
||||
go fp.runRelay(uconn)
|
||||
}
|
||||
|
||||
// Start TCP listener.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
fp.addr = ln.Addr().String()
|
||||
|
||||
if scenario == "timeout_then_ok" {
|
||||
fp.timeoutFirstAttempt.Store(1)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
go fp.serve(ln)
|
||||
|
||||
return fp
|
||||
}
|
||||
|
||||
func needsUDPRelay(scenario string) bool {
|
||||
switch scenario {
|
||||
case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok",
|
||||
"voice_quality_warn", "voice_quality_fail":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// serve accepts connections forever until the listener is closed.
|
||||
func (fp *fakeProxy) serve(ln net.Listener) {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go fp.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (fp *fakeProxy) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
// First-attempt-timeout scenario: read greet, then sleep past
|
||||
// the per-test timeout to force a deadline error.
|
||||
if fp.timeoutFirstAttempt.CompareAndSwap(1, 0) {
|
||||
buf := make([]byte, 1024)
|
||||
_, _ = conn.Read(buf)
|
||||
time.Sleep(2 * time.Second)
|
||||
return
|
||||
}
|
||||
|
||||
br := newPeekReader(conn)
|
||||
|
||||
// Step 1: greeting.
|
||||
greet, err := readGreeting(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch fp.scenario {
|
||||
case "all_methods_rejected":
|
||||
_, _ = conn.Write([]byte{0x05, 0xFF})
|
||||
return
|
||||
case "auth_rejected":
|
||||
// Server picks user/pass.
|
||||
_, _ = conn.Write([]byte{0x05, 0x02})
|
||||
// Read auth.
|
||||
_ = readAuth(br)
|
||||
_, _ = conn.Write([]byte{0x01, 0x01}) // status=fail
|
||||
return
|
||||
}
|
||||
|
||||
// Method selection: scenarios that involve auth force 0x02 if
|
||||
// offered; otherwise prefer 0x00.
|
||||
preferAuth := fp.scenario == "happy_with_auth"
|
||||
chosen := byte(0xFF)
|
||||
if preferAuth {
|
||||
for _, m := range greet.methods {
|
||||
if m == 0x02 {
|
||||
chosen = 0x02
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == 0xFF {
|
||||
for _, m := range greet.methods {
|
||||
if m == 0x00 {
|
||||
chosen = 0x00
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == 0xFF {
|
||||
for _, m := range greet.methods {
|
||||
if m == 0x02 {
|
||||
chosen = 0x02
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == 0xFF {
|
||||
_, _ = conn.Write([]byte{0x05, 0xFF})
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte{0x05, chosen})
|
||||
|
||||
if chosen == 0x02 {
|
||||
if err := readAuth(br); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte{0x01, 0x00}) // success
|
||||
}
|
||||
|
||||
// Step 2: read CMD request.
|
||||
cmdReq, err := readSocks5Request(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch cmdReq.cmd {
|
||||
case 0x01: // CONNECT
|
||||
switch fp.scenario {
|
||||
case "connect_refused":
|
||||
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
// API passthrough?
|
||||
if fp.apiTargetHost != "" && cmdReq.host == fp.apiTargetHost && cmdReq.port == fp.apiTargetPort {
|
||||
// Dial real target, splice.
|
||||
target, derr := net.Dial("tcp", fp.apiTargetAddr)
|
||||
if derr != nil {
|
||||
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
// Clear deadline for the splice.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
_ = target.SetDeadline(time.Time{})
|
||||
// Splice. We can't get already-buffered bytes back
|
||||
// out of br trivially, but the client only sent the
|
||||
// 7+len bytes for CONNECT and we read exactly that —
|
||||
// so br has no leftover buffered bytes here.
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = io.Copy(target, conn); done <- struct{}{} }()
|
||||
go func() { _, _ = io.Copy(conn, target); done <- struct{}{} }()
|
||||
<-done
|
||||
_ = target.Close()
|
||||
return
|
||||
}
|
||||
// Default happy CONNECT.
|
||||
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
// Keep conn open briefly so client doesn't see EOF before
|
||||
// reading the 10-byte reply.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return
|
||||
case 0x03: // UDP ASSOCIATE
|
||||
if fp.scenario == "udp_unsupported" {
|
||||
_, _ = conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
// Reply with our UDP relay endpoint.
|
||||
ip4 := fp.udpRelayAddr.IP.To4()
|
||||
if ip4 == nil {
|
||||
_, _ = conn.Write([]byte{0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01,
|
||||
ip4[0], ip4[1], ip4[2], ip4[3],
|
||||
byte(fp.udpRelayAddr.Port >> 8), byte(fp.udpRelayAddr.Port)}
|
||||
_, _ = conn.Write(reply)
|
||||
// Keep TCP control channel open so the relay stays valid.
|
||||
// The client will close conn when done. We just block on
|
||||
// read until peer closes.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
_, _ = io.Copy(io.Discard, conn)
|
||||
return
|
||||
default:
|
||||
_, _ = conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// runRelay reads SOCKS5 UDP datagrams, parses the embedded STUN binding
|
||||
// request, and replies with a synthetic Binding Success Response carrying
|
||||
// XOR-MAPPED-ADDRESS = client's source.
|
||||
func (fp *fakeProxy) runRelay(uconn *net.UDPConn) {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, src, err := uconn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Optional packet-drop simulation. udpDropEveryN of value 1 drops
|
||||
// everything; 2 drops every other packet; 10 drops 10%.
|
||||
if dropN := fp.udpDropEveryN.Load(); dropN > 0 {
|
||||
c := fp.udpRelayCount.Add(1)
|
||||
if c%dropN == 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
fp.udpRelayCount.Add(1)
|
||||
}
|
||||
if n < 10 {
|
||||
continue
|
||||
}
|
||||
// Parse SOCKS5 UDP wrapper. Expect ATYP=01.
|
||||
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
||||
continue
|
||||
}
|
||||
var hdrLen int
|
||||
switch buf[3] {
|
||||
case 0x01:
|
||||
hdrLen = 10
|
||||
case 0x04:
|
||||
hdrLen = 22
|
||||
case 0x03:
|
||||
if n < 5 {
|
||||
continue
|
||||
}
|
||||
hdrLen = 4 + 1 + int(buf[4]) + 2
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if n < hdrLen+20 {
|
||||
continue
|
||||
}
|
||||
stunReq := buf[hdrLen:n]
|
||||
// Expect a binding request.
|
||||
if len(stunReq) < 20 {
|
||||
continue
|
||||
}
|
||||
var txID [12]byte
|
||||
copy(txID[:], stunReq[8:20])
|
||||
|
||||
// Build XOR-MAPPED-ADDRESS attribute value for src.
|
||||
ip4 := src.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16)
|
||||
xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
||||
|
||||
// Build STUN Binding Success Response.
|
||||
stunResp := make([]byte, 20+12) // header + 4-byte attr header + 8-byte XMA
|
||||
binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(stunResp[2:4], 12) // attr length
|
||||
binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie)
|
||||
copy(stunResp[8:20], txID[:])
|
||||
// Attribute header: type, length.
|
||||
binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(stunResp[22:24], 8)
|
||||
// Value: 0, family=01, x-port, x-addr.
|
||||
stunResp[24] = 0
|
||||
stunResp[25] = 0x01
|
||||
binary.BigEndian.PutUint16(stunResp[26:28], xport)
|
||||
binary.BigEndian.PutUint32(stunResp[28:32], xaddr)
|
||||
|
||||
// Wrap in SOCKS5 UDP header.
|
||||
out := make([]byte, 0, 10+len(stunResp))
|
||||
out = append(out, 0x00, 0x00, 0x00, 0x01)
|
||||
out = append(out, ip4...)
|
||||
var portBuf [2]byte
|
||||
binary.BigEndian.PutUint16(portBuf[:], uint16(src.Port))
|
||||
out = append(out, portBuf[:]...)
|
||||
out = append(out, stunResp...)
|
||||
|
||||
_, _ = uconn.WriteToUDP(out, src)
|
||||
}
|
||||
}
|
||||
|
||||
// peekReader wraps net.Conn so we can read variable-length SOCKS5 frames.
|
||||
type peekReader struct {
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func newPeekReader(r io.Reader) *peekReader { return &peekReader{r: r} }
|
||||
|
||||
func (p *peekReader) ReadFull(n int) ([]byte, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(p.r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
type greetingMsg struct {
|
||||
methods []byte
|
||||
}
|
||||
|
||||
func readGreeting(r *peekReader) (*greetingMsg, error) {
|
||||
hdr, err := r.ReadFull(2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr[0] != 0x05 {
|
||||
return nil, fmt.Errorf("bad ver")
|
||||
}
|
||||
nMethods := int(hdr[1])
|
||||
methods, err := r.ReadFull(nMethods)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &greetingMsg{methods: methods}, nil
|
||||
}
|
||||
|
||||
func readAuth(r *peekReader) error {
|
||||
hdr, err := r.ReadFull(2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hdr[0] != 0x01 {
|
||||
return fmt.Errorf("bad auth ver")
|
||||
}
|
||||
ulen := int(hdr[1])
|
||||
if _, err := r.ReadFull(ulen); err != nil {
|
||||
return err
|
||||
}
|
||||
plenBuf, err := r.ReadFull(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plen := int(plenBuf[0])
|
||||
if _, err := r.ReadFull(plen); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type socks5Request struct {
|
||||
cmd byte
|
||||
atyp byte
|
||||
host string
|
||||
port uint16
|
||||
}
|
||||
|
||||
func readSocks5Request(r *peekReader) (*socks5Request, error) {
|
||||
hdr, err := r.ReadFull(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr[0] != 0x05 {
|
||||
return nil, fmt.Errorf("bad ver")
|
||||
}
|
||||
out := &socks5Request{cmd: hdr[1], atyp: hdr[3]}
|
||||
switch hdr[3] {
|
||||
case 0x01:
|
||||
ipBuf, err := r.ReadFull(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.host = net.IP(ipBuf).String()
|
||||
case 0x03:
|
||||
lenBuf, err := r.ReadFull(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hostBuf, err := r.ReadFull(int(lenBuf[0]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.host = string(hostBuf)
|
||||
case 0x04:
|
||||
ipBuf, err := r.ReadFull(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.host = net.IP(ipBuf).String()
|
||||
default:
|
||||
return nil, fmt.Errorf("bad atyp")
|
||||
}
|
||||
portBuf, err := r.ReadFull(2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.port = binary.BigEndian.Uint16(portBuf)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func methodChosen(cur, _ byte) bool { return cur != 0xFF }
|
||||
|
||||
// drainResults pulls every Result off ch into a slice (with a hard timeout
|
||||
// so a hung implementation doesn't hang the test).
|
||||
func drainResults(t *testing.T, ch <-chan Result, timeout time.Duration) []Result {
|
||||
t.Helper()
|
||||
var out []Result
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case r, ok := <-ch:
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
out = append(out, r)
|
||||
case <-deadline.C:
|
||||
t.Fatalf("checker.Run did not finish within %s; got %d results so far: %+v", timeout, len(out), out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finalByID returns the LAST result emitted for the given test id, or zero.
|
||||
func finalByID(results []Result, id string) (Result, bool) {
|
||||
for i := len(results) - 1; i >= 0; i-- {
|
||||
if results[i].ID == id && results[i].Status != StatusRunning {
|
||||
return results[i], true
|
||||
}
|
||||
}
|
||||
return Result{}, false
|
||||
}
|
||||
|
||||
// hostPort splits an addr returned by net.Listener.Addr().String().
|
||||
func hostPort(addr string) (string, int) {
|
||||
host, p, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
pn, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return host, pn
|
||||
}
|
||||
|
||||
// proxyConfig builds a Config pointed at the given fakeProxy with sane
|
||||
// short timeouts for tests.
|
||||
func proxyConfig(fp *fakeProxy, useAuth bool) Config {
|
||||
host, port := hostPort(fp.addr)
|
||||
cfg := Config{
|
||||
ProxyHost: host,
|
||||
ProxyPort: port,
|
||||
UseAuth: useAuth,
|
||||
PerTestTimeout: 500 * time.Millisecond,
|
||||
MaxRetries: 1,
|
||||
RetryBackoff: 30 * time.Millisecond,
|
||||
VoiceBurstCount: 10,
|
||||
VoiceBurstInterval: 5 * time.Millisecond,
|
||||
}
|
||||
if useAuth {
|
||||
cfg.ProxyLogin = "u"
|
||||
cfg.ProxyPassword = "p"
|
||||
}
|
||||
if fp.udpRelayAddr != nil {
|
||||
// no-op; relay is announced via UDP ASSOCIATE reply
|
||||
_ = fp.udpRelayAddr
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// stubAPIServer starts an httptest server returning HTTP 200 with a tiny
|
||||
// JSON body, plus arranges fakeProxy to splice CONNECTs targeting it.
|
||||
func stubAPIServer(t *testing.T, fp *fakeProxy, status int) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, `{"url":"wss://gateway.discord.gg"}`)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// Parse the test server's host:port.
|
||||
host, port := hostPort(strings.TrimPrefix(srv.URL, "http://"))
|
||||
fp.apiTargetHost = host
|
||||
fp.apiTargetPort = uint16(port)
|
||||
fp.apiTargetAddr = srv.Listener.Addr().String()
|
||||
return srv.URL + "/api/v9/gateway"
|
||||
}
|
||||
|
||||
// stubGatewayServer stands in for gateway.discord.gg:443 so the connect
|
||||
// test has a real target. We don't actually speak TLS — the client's
|
||||
// CONNECT only reads the 10-byte SOCKS5 reply, so as long as we send
|
||||
// REP=00 the test passes. proxyConfig points DiscordGateway at this addr.
|
||||
//
|
||||
// We piggy-back on a TCP listener that does nothing.
|
||||
func stubGatewayAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Just keep open; the splice will read/write nothing
|
||||
// useful (the SOCKS5 reply is fake REP=00 from the
|
||||
// proxy itself, not from us — see fakeProxy.handle).
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_, _ = io.Copy(io.Discard, c)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
func TestRun_HappyNoAuth(t *testing.T) {
|
||||
fp := newFakeProxy(t, "happy_no_auth")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:1" // unused: we patch via direct relay; see below
|
||||
|
||||
// We don't actually need DNS — runStun does net.LookupIP("ip4", host).
|
||||
// Use a literal IP so the resolver returns it.
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
expected := []string{"tcp", "greet", "connect", "udp", "api"}
|
||||
finals := map[string]Result{}
|
||||
for _, id := range expected {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing final result for %q in %+v", id, results)
|
||||
finals[id] = r
|
||||
}
|
||||
for _, id := range expected {
|
||||
assert.Equal(t, StatusPassed, finals[id].Status, "test %s should pass; got %+v", id, finals[id])
|
||||
}
|
||||
|
||||
// auth must not appear (UseAuth=false).
|
||||
for _, r := range results {
|
||||
assert.NotEqual(t, "auth", r.ID, "auth must not be emitted when UseAuth=false")
|
||||
}
|
||||
|
||||
// Metrics format spot-checks.
|
||||
assert.Contains(t, finals["greet"].Metric, "no auth")
|
||||
assert.Equal(t, "REP=00", finals["connect"].Metric)
|
||||
assert.Equal(t, "HTTP 200", finals["api"].Metric)
|
||||
}
|
||||
|
||||
func TestRun_HappyWithAuth(t *testing.T) {
|
||||
fp := newFakeProxy(t, "happy_with_auth")
|
||||
cfg := proxyConfig(fp, true)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
expected := []string{"tcp", "greet", "auth", "connect", "udp", "api"}
|
||||
for _, id := range expected {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s; results=%+v", id, results)
|
||||
assert.Equal(t, StatusPassed, r.Status, "id=%s", id)
|
||||
}
|
||||
r, _ := finalByID(results, "auth")
|
||||
assert.Equal(t, "ok", r.Metric)
|
||||
}
|
||||
|
||||
func TestRun_AuthRejected(t *testing.T) {
|
||||
fp := newFakeProxy(t, "auth_rejected")
|
||||
cfg := proxyConfig(fp, true)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
// tcp + greet pass, auth fails.
|
||||
rTCP, _ := finalByID(results, "tcp")
|
||||
assert.Equal(t, StatusPassed, rTCP.Status)
|
||||
rG, _ := finalByID(results, "greet")
|
||||
assert.Equal(t, StatusPassed, rG.Status)
|
||||
|
||||
rA, ok := finalByID(results, "auth")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusFailed, rA.Status)
|
||||
assert.NotEmpty(t, rA.Hint)
|
||||
|
||||
for _, id := range []string{"connect", "udp", "api"} {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_AllMethodsRejected(t *testing.T) {
|
||||
fp := newFakeProxy(t, "all_methods_rejected")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
rTCP, _ := finalByID(results, "tcp")
|
||||
assert.Equal(t, StatusPassed, rTCP.Status)
|
||||
|
||||
rG, ok := finalByID(results, "greet")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusFailed, rG.Status)
|
||||
assert.NotEmpty(t, rG.Hint)
|
||||
|
||||
for _, id := range []string{"connect", "udp", "api"} {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ConnectRefused(t *testing.T) {
|
||||
fp := newFakeProxy(t, "connect_refused")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
rT, _ := finalByID(results, "tcp")
|
||||
assert.Equal(t, StatusPassed, rT.Status)
|
||||
rG, _ := finalByID(results, "greet")
|
||||
assert.Equal(t, StatusPassed, rG.Status)
|
||||
|
||||
rC, ok := finalByID(results, "connect")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusFailed, rC.Status)
|
||||
assert.NotEmpty(t, rC.Hint)
|
||||
assert.NotEmpty(t, rC.RawHex)
|
||||
|
||||
// udp goes through a SECOND conn → unaffected; should pass.
|
||||
rU, _ := finalByID(results, "udp")
|
||||
assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect")
|
||||
|
||||
// api depends on connect → skipped.
|
||||
rA, _ := finalByID(results, "api")
|
||||
assert.Equal(t, StatusSkipped, rA.Status)
|
||||
}
|
||||
|
||||
func TestRun_UDPUnsupported(t *testing.T) {
|
||||
fp := newFakeProxy(t, "udp_unsupported")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
for _, id := range []string{"tcp", "greet", "connect"} {
|
||||
r, _ := finalByID(results, id)
|
||||
assert.Equal(t, StatusPassed, r.Status, "id=%s", id)
|
||||
}
|
||||
|
||||
rU, _ := finalByID(results, "udp")
|
||||
require.Equal(t, StatusFailed, rU.Status)
|
||||
assert.NotEmpty(t, rU.Hint)
|
||||
|
||||
rA, _ := finalByID(results, "api")
|
||||
assert.Equal(t, StatusPassed, rA.Status)
|
||||
}
|
||||
|
||||
func TestRun_TimeoutThenOK(t *testing.T) {
|
||||
fp := newFakeProxy(t, "timeout_then_ok")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 401)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
cfg.PerTestTimeout = 200 * time.Millisecond
|
||||
cfg.RetryBackoff = 20 * time.Millisecond
|
||||
cfg.MaxRetries = 1
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 15*time.Second)
|
||||
|
||||
// Find the greet results.
|
||||
var greetEvents []Result
|
||||
for _, r := range results {
|
||||
if r.ID == "greet" {
|
||||
greetEvents = append(greetEvents, r)
|
||||
}
|
||||
}
|
||||
// Expect: running(1), failed(1), running(2), passed(2). 4 events.
|
||||
require.Len(t, greetEvents, 4, "events=%+v all=%+v", greetEvents, results)
|
||||
assert.Equal(t, StatusRunning, greetEvents[0].Status)
|
||||
assert.Equal(t, 1, greetEvents[0].Attempt)
|
||||
assert.Equal(t, StatusFailed, greetEvents[1].Status)
|
||||
assert.Equal(t, 1, greetEvents[1].Attempt)
|
||||
assert.Equal(t, StatusRunning, greetEvents[2].Status)
|
||||
assert.Equal(t, 2, greetEvents[2].Attempt)
|
||||
assert.Equal(t, StatusPassed, greetEvents[3].Status)
|
||||
assert.Equal(t, 2, greetEvents[3].Attempt)
|
||||
|
||||
// All non-auth tests should ultimately pass.
|
||||
for _, id := range []string{"tcp", "greet", "connect", "udp", "api"} {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r)
|
||||
}
|
||||
|
||||
// API should report 401.
|
||||
rA, _ := finalByID(results, "api")
|
||||
assert.Equal(t, "HTTP 401", rA.Metric)
|
||||
}
|
||||
|
||||
func TestRun_CancelledMidFlight(t *testing.T) {
|
||||
fp := newFakeProxy(t, "happy_no_auth")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ch := Run(ctx, cfg)
|
||||
|
||||
var (
|
||||
results []Result
|
||||
mu sync.Mutex
|
||||
)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for r := range ch {
|
||||
mu.Lock()
|
||||
results = append(results, r)
|
||||
mu.Unlock()
|
||||
// Cancel as soon as we see tcp pass.
|
||||
if r.ID == "tcp" && r.Status == StatusPassed {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("timed out waiting for cancelled run to finish")
|
||||
}
|
||||
|
||||
// At least one Failed/Skipped after tcp Pass.
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var failed, skipped int
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case StatusFailed:
|
||||
if r.Error == "cancelled" {
|
||||
failed++
|
||||
}
|
||||
case StatusSkipped:
|
||||
if r.Error == "cancelled" {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
// Either: one cancelled-failed + rest cancelled-skipped, OR all
|
||||
// cancelled-skipped (if cancellation hit before next test even
|
||||
// started). Both are acceptable.
|
||||
// Without auth, 5 tests remain after tcp (greet/connect/udp/
|
||||
// voice-quality/api). Cancel may race with greet
|
||||
// completing successfully, so accept ≥3.
|
||||
assert.GreaterOrEqual(t, failed+skipped, 3, "expected at least 3 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results)
|
||||
}
|
||||
|
||||
func TestRun_AppliesDefaults(t *testing.T) {
|
||||
// Use a Config{} with only ProxyHost/Port populated; everything
|
||||
// else should fall back to spec defaults.
|
||||
fp := newFakeProxy(t, "happy_no_auth")
|
||||
host, port := hostPort(fp.addr)
|
||||
cfg := Config{
|
||||
ProxyHost: host,
|
||||
ProxyPort: port,
|
||||
}
|
||||
|
||||
// Verify applyDefaults produces expected values.
|
||||
out := applyDefaults(cfg)
|
||||
assert.Equal(t, 5*time.Second, out.PerTestTimeout)
|
||||
assert.Equal(t, 1, out.MaxRetries)
|
||||
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
|
||||
assert.Equal(t, "gateway.discord.gg:443", out.DiscordGateway)
|
||||
assert.Equal(t, "https://discord.com/api/v9/gateway", out.DiscordAPI)
|
||||
assert.Equal(t, "stun.l.google.com:19302", out.StunServer)
|
||||
|
||||
// Behavioral: passing a zero Config to Run should not panic and
|
||||
// should at minimum emit a TCP result. We override defaults to
|
||||
// shorter values so the test isn't slow when the public Discord
|
||||
// targets are unreachable.
|
||||
cfg.PerTestTimeout = 200 * time.Millisecond
|
||||
cfg.RetryBackoff = 20 * time.Millisecond
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
rT, ok := finalByID(results, "tcp")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusPassed, rT.Status)
|
||||
}
|
||||
|
||||
func TestRun_NegativeRetryClamped(t *testing.T) {
|
||||
cfg := Config{MaxRetries: -5, RetryBackoff: -1 * time.Second, PerTestTimeout: -1 * time.Second}
|
||||
out := applyDefaults(cfg)
|
||||
// Spec: MaxRetries < 0 → 0. But our default for "not set" is 1.
|
||||
// We treat <0 as 0, then bump 0→1 (default for zero).
|
||||
// Either 0 or 1 is acceptable per spec wording; we settled on 1.
|
||||
assert.True(t, out.MaxRetries == 0 || out.MaxRetries == 1)
|
||||
assert.Equal(t, 5*time.Second, out.PerTestTimeout)
|
||||
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
|
||||
}
|
||||
|
||||
func TestExtractRawHex(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"socks5: bad version (raw=05ff)", "05ff"},
|
||||
{"socks5: bad version (raw=DEADBEEF)", "DEADBEEF"},
|
||||
{"no raw here", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.want, extractRawHex(c.in), "input=%q", c.in)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// socks5ReplyHints maps SOCKS5 REP codes to short Russian explanations
|
||||
// used by hintFor for the "connect" and "udp" steps. Codes outside this
|
||||
// table fall back to a generic "unknown REP" message.
|
||||
var socks5ReplyHints = map[byte]string{
|
||||
0x01: "общий сбой SOCKS5-сервера",
|
||||
0x02: "правила прокси запрещают это соединение",
|
||||
0x03: "сеть назначения недоступна",
|
||||
0x04: "хост назначения недоступен",
|
||||
0x05: "connection refused",
|
||||
0x06: "истёк TTL",
|
||||
0x07: "команда не поддерживается",
|
||||
0x08: "тип адреса не поддерживается",
|
||||
}
|
||||
|
||||
// tcpFriendlyName turns a testID into a Russian-friendly label for the
|
||||
// generic fallback hint.
|
||||
func tcpFriendlyName(testID string) string {
|
||||
switch testID {
|
||||
case "tcp":
|
||||
return "TCP"
|
||||
case "greet":
|
||||
return "приветствие SOCKS5"
|
||||
case "auth":
|
||||
return "авторизация SOCKS5"
|
||||
case "connect":
|
||||
return "TCP-туннель к Discord"
|
||||
case "udp":
|
||||
return "UDP ASSOCIATE"
|
||||
case "voice-quality":
|
||||
return "качество UDP-канала"
|
||||
case "api":
|
||||
return "Discord API"
|
||||
default:
|
||||
return testID
|
||||
}
|
||||
}
|
||||
|
||||
// hintFor returns a short Russian-language explanation of why a test
|
||||
// failed. Returns "" when err is nil.
|
||||
func hintFor(testID string, err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if isContextErr(err) {
|
||||
return "Проверка отменена."
|
||||
}
|
||||
|
||||
// Common error shapes we recognise across all testIDs.
|
||||
var ne net.Error
|
||||
isTimeout := errors.As(err, &ne) && ne.Timeout()
|
||||
|
||||
var rep ErrSocks5Reply
|
||||
hasReply := errors.As(err, &rep)
|
||||
|
||||
switch testID {
|
||||
case "tcp":
|
||||
switch {
|
||||
case isTimeout:
|
||||
return "Превышен таймаут подключения — прокси может быть выключен или брандмауэр режет порт."
|
||||
case errors.Is(err, syscall.ECONNREFUSED):
|
||||
return "Прокси отклонил TCP-соединение — порт закрыт или сервис не запущен."
|
||||
}
|
||||
return fmt.Sprintf("Прокси не отвечает по TCP — проверь host и port (%s).", err.Error())
|
||||
|
||||
case "greet":
|
||||
switch {
|
||||
case errors.Is(err, ErrSocks5BadVersion):
|
||||
return "Сервер вернул не SOCKS5 — возможно, это HTTP-прокси."
|
||||
case errors.Is(err, ErrSocks5RejectedAllAuth):
|
||||
return "Прокси требует авторизацию, но мы её не предложили (или прокси не принимает наши методы)."
|
||||
case errors.Is(err, ErrShortReply):
|
||||
return "SOCKS5-сервер прислал укороченный ответ на приветствие."
|
||||
case isTimeout:
|
||||
return "SOCKS5-сервер не ответил на приветствие вовремя."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "auth":
|
||||
switch {
|
||||
case errors.Is(err, ErrAuthRejected):
|
||||
return "Логин или пароль неверны."
|
||||
case errors.Is(err, ErrCredentialTooLong):
|
||||
return "Логин или пароль длиннее 255 байт — SOCKS5 такого не позволяет."
|
||||
case errors.Is(err, ErrShortReply):
|
||||
return "SOCKS5-сервер прислал укороченный ответ на авторизацию."
|
||||
case isTimeout:
|
||||
return "SOCKS5-сервер не ответил на авторизацию вовремя."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "connect":
|
||||
if hasReply {
|
||||
return socks5ReplyHint("connect", rep.Code)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrHostTooLong):
|
||||
return "Имя хоста длиннее 255 байт — SOCKS5 такого не позволяет."
|
||||
case errors.Is(err, ErrShortReply):
|
||||
return "SOCKS5-сервер прислал укороченный ответ на CONNECT."
|
||||
case isTimeout:
|
||||
return "SOCKS5-сервер не ответил на CONNECT вовремя."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "udp":
|
||||
if hasReply {
|
||||
return socks5ReplyHint("udp", rep.Code)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrUnsupportedRelayATYP):
|
||||
return "Прокси выдал IPv6 relay для UDP — пока не поддерживается, голос работать не будет."
|
||||
case errors.Is(err, ErrShortReply):
|
||||
return "SOCKS5-сервер прислал укороченный ответ на UDP ASSOCIATE."
|
||||
case isTimeout:
|
||||
return "SOCKS5-сервер не ответил на UDP ASSOCIATE вовремя."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "voice-quality":
|
||||
switch {
|
||||
case errors.Is(err, ErrSTUNNoMappedAddress):
|
||||
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
|
||||
case errors.Is(err, ErrSTUNTooShort),
|
||||
errors.Is(err, ErrSTUNBadMagicCookie),
|
||||
errors.Is(err, ErrSTUNNotSuccess),
|
||||
errors.Is(err, ErrSTUNTxIDMismatch),
|
||||
errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||
return "STUN-релей возвращает мусор — голос работать не будет."
|
||||
case isTimeout:
|
||||
return "STUN-релей не отвечает — UDP через прокси сильно теряет пакеты."
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return "Не удалось разрезолвить STUN-сервер — проверь системный DNS."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "api":
|
||||
switch {
|
||||
case isTimeout:
|
||||
return "Discord API не ответил вовремя через прокси — таймаут."
|
||||
}
|
||||
return fmt.Sprintf("Discord API недоступен через прокси — TLS handshake упал (%s).", err.Error())
|
||||
}
|
||||
|
||||
return genericFallback(testID, err)
|
||||
}
|
||||
|
||||
// socks5ReplyHint formats a SOCKS5 REP-code hint specialised by step.
|
||||
// "connect" wording references Discord; "udp" wording references voice.
|
||||
func socks5ReplyHint(step string, code byte) string {
|
||||
desc, ok := socks5ReplyHints[code]
|
||||
if !ok {
|
||||
desc = "неизвестная REP"
|
||||
}
|
||||
switch step {
|
||||
case "udp":
|
||||
// 0x07 (cmd not supported) is the headline UDP failure mode.
|
||||
if code == 0x07 {
|
||||
return "Прокси не поддерживает UDP ASSOCIATE — голос Discord работать не будет."
|
||||
}
|
||||
return fmt.Sprintf("Прокси отклонил UDP ASSOCIATE (REP=%02X, %s).", code, desc)
|
||||
case "connect":
|
||||
if code == 0x05 {
|
||||
return "Прокси не смог подключиться к Discord (REP=05, connection refused)."
|
||||
}
|
||||
if code == 0x07 {
|
||||
return "Прокси не поддерживает CONNECT (REP=07)."
|
||||
}
|
||||
return fmt.Sprintf("Прокси отклонил CONNECT к Discord (REP=%02X, %s).", code, desc)
|
||||
}
|
||||
return fmt.Sprintf("Прокси отклонил запрос (REP=%02X, %s).", code, desc)
|
||||
}
|
||||
|
||||
// genericFallback is the catch-all used when we don't recognise the
|
||||
// (testID, err) shape. Keeps the user informed without exposing raw Go
|
||||
// error wrapping.
|
||||
func genericFallback(testID string, err error) string {
|
||||
return fmt.Sprintf("Не удалось выполнить шаг «%s»: %s", tcpFriendlyName(testID), err.Error())
|
||||
}
|
||||
|
||||
// voiceQualityWarnHint composes a warn-tier hint based on which threshold
|
||||
// was violated. Thresholds match runVoiceQuality's warn band: loss>5,
|
||||
// jitter>30, p50>250. Always returns non-empty.
|
||||
func voiceQualityWarnHint(loss, jitter, p50 float64) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if loss > 5.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос будет с заиканиями", loss))
|
||||
}
|
||||
if jitter > 30.0 {
|
||||
parts = append(parts, fmt.Sprintf("большой джиттер %.1fms — звук будет дёргаться", jitter))
|
||||
}
|
||||
if p50 > 250.0 {
|
||||
parts = append(parts, fmt.Sprintf("высокая задержка %.0fms — заметная рассинхронизация при разговоре", p50))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
// Shouldn't happen — caller only invokes us in the warn band.
|
||||
return "UDP-канал на грани приемлемого — возможны помехи в голосе."
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
|
||||
// voiceQualityFailHint composes a fail-tier hint. p95 is informational —
|
||||
// included only when notably worse than p50.
|
||||
func voiceQualityFailHint(loss, jitter, p50, p95 float64) string {
|
||||
_ = p95
|
||||
parts := make([]string, 0, 3)
|
||||
if loss > 15.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос работать не будет", loss))
|
||||
} else if loss > 5.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%%", loss))
|
||||
}
|
||||
if jitter > 60.0 {
|
||||
parts = append(parts, fmt.Sprintf("джиттер %.1fms — звук развалится", jitter))
|
||||
} else if jitter > 30.0 {
|
||||
parts = append(parts, fmt.Sprintf("джиттер %.1fms", jitter))
|
||||
}
|
||||
if p50 > 400.0 {
|
||||
parts = append(parts, fmt.Sprintf("задержка %.0fms — голос идёт со значительной паузой", p50))
|
||||
} else if p50 > 250.0 {
|
||||
parts = append(parts, fmt.Sprintf("задержка %.0fms", p50))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "UDP-канал не пригоден для голоса."
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHintFor(t *testing.T) {
|
||||
t.Run("nil_err_returns_empty", func(t *testing.T) {
|
||||
assert.Equal(t, "", hintFor("tcp", nil))
|
||||
assert.Equal(t, "", hintFor("anything", nil))
|
||||
})
|
||||
|
||||
t.Run("context_canceled_uniform", func(t *testing.T) {
|
||||
// Cancellation is always reported as «Проверка отменена.» across
|
||||
// all testIDs.
|
||||
for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "api", "unknown"} {
|
||||
assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id)
|
||||
assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id)
|
||||
}
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
testID string
|
||||
err error
|
||||
substring string
|
||||
}{
|
||||
{"tcp_timeout", "tcp", &timeoutOnlyError{}, "таймаут"},
|
||||
{"greet_bad_version_mentions_socks5", "greet", ErrSocks5BadVersion, "SOCKS5"},
|
||||
{"greet_bad_version_mentions_negation", "greet", ErrSocks5BadVersion, "не"},
|
||||
{"greet_rejected_all_auth_mentions_auth_or_methods", "greet", ErrSocks5RejectedAllAuth, "авторизаци"},
|
||||
{"auth_login", "auth", ErrAuthRejected, "Логин"},
|
||||
{"auth_password", "auth", ErrAuthRejected, "паро"},
|
||||
{"connect_refused_rep05", "connect", ErrSocks5Reply{Code: 0x05}, "REP=05"},
|
||||
{"connect_refused_text", "connect", ErrSocks5Reply{Code: 0x05}, "connection refused"},
|
||||
{"connect_unsupported_rep07", "connect", ErrSocks5Reply{Code: 0x07}, "REP=07"},
|
||||
{"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"},
|
||||
{"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"},
|
||||
{"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"},
|
||||
{"voice_quality_no_mapped_xor", "voice-quality", ErrSTUNNoMappedAddress, "XOR-MAPPED"},
|
||||
{"voice_quality_timeout_mentions_stun", "voice-quality", &timeoutOnlyError{}, "STUN"},
|
||||
{"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
||||
{"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"},
|
||||
{"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"},
|
||||
{"tcp_fallback_friendly_name", "tcp", errors.New("weird"), "TCP"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := hintFor(c.testID, c.err)
|
||||
assert.Contains(t, got, c.substring, "got=%q", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHintFor_AllSocks5ReplyCodesCovered(t *testing.T) {
|
||||
// Every documented REP code (0x01..0x08) should produce a non-empty
|
||||
// hint when surfaced via "connect" or "udp".
|
||||
for code := byte(0x01); code <= 0x08; code++ {
|
||||
err := ErrSocks5Reply{Code: code}
|
||||
assert.NotEmpty(t, hintFor("connect", err), "connect code=%02X", code)
|
||||
assert.NotEmpty(t, hintFor("udp", err), "udp code=%02X", code)
|
||||
}
|
||||
// Unknown REP code (0xFE) still gets a sensible fallback rather than
|
||||
// an empty string.
|
||||
err := ErrSocks5Reply{Code: 0xFE}
|
||||
assert.NotEmpty(t, hintFor("connect", err))
|
||||
assert.NotEmpty(t, hintFor("udp", err))
|
||||
}
|
||||
|
||||
func TestHintFor_PerStepBranches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
testID string
|
||||
err error
|
||||
substring string
|
||||
}{
|
||||
// tcp: ECONNREFUSED + generic fallback
|
||||
{"tcp_econnrefused", "tcp", syscall.ECONNREFUSED, "отклонил"},
|
||||
{"tcp_generic", "tcp", errors.New("dial fail"), "TCP"},
|
||||
|
||||
// greet: short reply, timeout, fallback
|
||||
{"greet_short_reply", "greet", ErrShortReply, "укороченный"},
|
||||
{"greet_timeout", "greet", &timeoutOnlyError{}, "вовремя"},
|
||||
{"greet_fallback", "greet", errors.New("weird"), "приветствие"},
|
||||
|
||||
// auth: credential too long, short reply, timeout, fallback
|
||||
{"auth_cred_too_long", "auth", ErrCredentialTooLong, "255"},
|
||||
{"auth_short_reply", "auth", ErrShortReply, "укороченный"},
|
||||
{"auth_timeout", "auth", &timeoutOnlyError{}, "вовремя"},
|
||||
{"auth_fallback", "auth", errors.New("weird"), "авторизация"},
|
||||
|
||||
// connect: host too long, short reply, timeout, generic REP, fallback
|
||||
{"connect_host_too_long", "connect", ErrHostTooLong, "255"},
|
||||
{"connect_short_reply", "connect", ErrShortReply, "укороченный"},
|
||||
{"connect_timeout", "connect", &timeoutOnlyError{}, "вовремя"},
|
||||
{"connect_generic_rep", "connect", ErrSocks5Reply{Code: 0x03}, "REP=03"},
|
||||
{"connect_unknown_rep", "connect", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
|
||||
{"connect_fallback", "connect", errors.New("weird"), "TCP-туннель"},
|
||||
|
||||
// udp: short reply, timeout, fallback, non-7 REP
|
||||
{"udp_short_reply", "udp", ErrShortReply, "укороченный"},
|
||||
{"udp_timeout", "udp", &timeoutOnlyError{}, "вовремя"},
|
||||
{"udp_other_rep", "udp", ErrSocks5Reply{Code: 0x05}, "REP=05"},
|
||||
{"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
|
||||
{"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"},
|
||||
|
||||
// voice-quality: every sentinel branch (collapsed in 2026-05-01
|
||||
// rewrite into a single user-visible message rather than
|
||||
// per-error "магник cookie" / "семейство адресов" exposition)
|
||||
{"voice_quality_too_short", "voice-quality", ErrSTUNTooShort, "мусор"},
|
||||
{"voice_quality_bad_magic", "voice-quality", ErrSTUNBadMagicCookie, "мусор"},
|
||||
{"voice_quality_not_success", "voice-quality", ErrSTUNNotSuccess, "мусор"},
|
||||
{"voice_quality_txid_mismatch", "voice-quality", ErrSTUNTxIDMismatch, "мусор"},
|
||||
{"voice_quality_unsupported_family", "voice-quality", ErrSTUNUnsupportedFamily, "мусор"},
|
||||
{"voice_quality_fallback", "voice-quality", errors.New("weird"), "качество"},
|
||||
|
||||
// api: timeout vs generic
|
||||
{"api_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
||||
{"api_generic", "api", errors.New("tls boom"), "TLS"},
|
||||
|
||||
// socks5ReplyHint via uncategorised step (default branch)
|
||||
// — we can't reach it via hintFor with current testIDs, but the
|
||||
// default formatter still needs to be exercised.
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := hintFor(c.testID, c.err)
|
||||
assert.Contains(t, got, c.substring, "got=%q", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocks5ReplyHint_DefaultStep(t *testing.T) {
|
||||
// socks5ReplyHint("", code) hits the final fallback formatter.
|
||||
got := socks5ReplyHint("", 0x03)
|
||||
assert.Contains(t, got, "REP=03")
|
||||
got = socks5ReplyHint("", 0xFE)
|
||||
assert.Contains(t, got, "REP=FE")
|
||||
}
|
||||
|
||||
func TestTcpFriendlyName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"tcp": "TCP",
|
||||
"greet": "приветствие SOCKS5",
|
||||
"auth": "авторизация SOCKS5",
|
||||
"connect": "TCP-туннель к Discord",
|
||||
"udp": "UDP ASSOCIATE",
|
||||
"voice-quality": "качество UDP-канала",
|
||||
"api": "Discord API",
|
||||
"weirdo": "weirdo",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
assert.Equal(t, want, tcpFriendlyName(in))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Classification is the result of classifyError. Transient errors are
|
||||
// candidates for one auto-retry (governed by Config.MaxRetries in
|
||||
// checker.go). Permanent errors are reported to the user as-is.
|
||||
type Classification int
|
||||
|
||||
const (
|
||||
// ClassificationPermanent — caller should NOT retry. Either the user
|
||||
// config is wrong (bad credentials, refused), the proxy is broken in
|
||||
// a way retry won't fix (bad SOCKS5 version, malformed STUN reply),
|
||||
// or the caller's context is already cancelled.
|
||||
ClassificationPermanent Classification = iota
|
||||
// ClassificationTransient — caller MAY retry once. Network blip,
|
||||
// timeout, RST mid-handshake, DNS temporary failure.
|
||||
ClassificationTransient
|
||||
)
|
||||
|
||||
// classifyError decides whether err is worth retrying.
|
||||
//
|
||||
// Transient (retry):
|
||||
// - net.Error.Timeout() == true
|
||||
// - errors.Is(err, syscall.ECONNRESET)
|
||||
// - net.DNSError.IsTemporary || .IsTimeout
|
||||
// - io.ErrUnexpectedEOF wrapped inside a *net.OpError on a Read (proxy
|
||||
// hung up mid-reply mid-flight; bare io.ErrUnexpectedEOF without an
|
||||
// OpError wrapper means we got a malformed reply and should not retry)
|
||||
//
|
||||
// Permanent (don't retry):
|
||||
// - context.Canceled / context.DeadlineExceeded
|
||||
// - errors.Is(err, syscall.ECONNREFUSED)
|
||||
// - any of our SOCKS5/STUN sentinels
|
||||
// - everything else we don't explicitly classify
|
||||
//
|
||||
// Returns ClassificationPermanent on nil err (defensive).
|
||||
func classifyError(err error) Classification {
|
||||
if err == nil {
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Context cancellation always wins — don't retry into a cancelled
|
||||
// context, even if the chain also contains a timeout error.
|
||||
if isContextErr(err) {
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Permanent: explicit refused.
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Permanent: our SOCKS5 sentinels (auth refused, bad version,
|
||||
// malformed credentials, etc.). Retrying won't fix any of these.
|
||||
switch {
|
||||
case errors.Is(err, ErrSocks5BadVersion),
|
||||
errors.Is(err, ErrSocks5RejectedAllAuth),
|
||||
errors.Is(err, ErrAuthRejected),
|
||||
errors.Is(err, ErrCredentialTooLong),
|
||||
errors.Is(err, ErrHostTooLong),
|
||||
errors.Is(err, ErrUnsupportedRelayATYP),
|
||||
errors.Is(err, ErrShortReply):
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Permanent: any non-zero SOCKS5 REP code. Includes 0x05 refused,
|
||||
// 0x07 cmd unsupported, 0x02 not allowed by ruleset — none of which
|
||||
// retry will fix.
|
||||
var rep ErrSocks5Reply
|
||||
if errors.As(err, &rep) {
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Permanent: STUN sentinels (malformed responses, missing attrs).
|
||||
switch {
|
||||
case errors.Is(err, ErrSTUNTooShort),
|
||||
errors.Is(err, ErrSTUNBadMagicCookie),
|
||||
errors.Is(err, ErrSTUNNotSuccess),
|
||||
errors.Is(err, ErrSTUNTxIDMismatch),
|
||||
errors.Is(err, ErrSTUNNoMappedAddress),
|
||||
errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Transient: ECONNRESET (peer hung up mid-stream).
|
||||
if errors.Is(err, syscall.ECONNRESET) {
|
||||
return ClassificationTransient
|
||||
}
|
||||
|
||||
// Transient: DNS temporary failure or DNS timeout.
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsTemporary || dnsErr.IsTimeout {
|
||||
return ClassificationTransient
|
||||
}
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Transient: io.ErrUnexpectedEOF wrapped inside a net.OpError. Bare
|
||||
// io.ErrUnexpectedEOF (synthesised by our SOCKS5 readers) is a
|
||||
// malformed-reply signal and stays permanent.
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if errors.Is(opErr.Err, io.ErrUnexpectedEOF) {
|
||||
return ClassificationTransient
|
||||
}
|
||||
}
|
||||
|
||||
// Transient: net.Error.Timeout(). Checked AFTER the typed sentinels
|
||||
// so that a timeout-shaped error wrapping a permanent sentinel still
|
||||
// classifies permanent.
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return ClassificationTransient
|
||||
}
|
||||
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// isContextErr returns true when err's chain contains context.Canceled
|
||||
// or context.DeadlineExceeded. Used by checker.go to label cancelled
|
||||
// tests as Error="cancelled".
|
||||
func isContextErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// timeoutOnlyError is a minimal net.Error that reports Timeout()=true.
|
||||
// Used to drive the net.Error.Timeout() branch in classifyError.
|
||||
type timeoutOnlyError struct{}
|
||||
|
||||
func (timeoutOnlyError) Error() string { return "i/o timeout" }
|
||||
func (timeoutOnlyError) Timeout() bool { return true }
|
||||
func (timeoutOnlyError) Temporary() bool { return true }
|
||||
|
||||
func TestClassifyError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want Classification
|
||||
}{
|
||||
{"nil", nil, ClassificationPermanent},
|
||||
{"context_canceled", context.Canceled, ClassificationPermanent},
|
||||
{"context_deadline", context.DeadlineExceeded, ClassificationPermanent},
|
||||
{"econnrefused", syscall.ECONNREFUSED, ClassificationPermanent},
|
||||
{"econnreset", syscall.ECONNRESET, ClassificationTransient},
|
||||
{"econnreset_wrapped", &net.OpError{Op: "read", Err: syscall.ECONNRESET}, ClassificationTransient},
|
||||
{"net_timeout", &timeoutOnlyError{}, ClassificationTransient},
|
||||
{"dns_temporary", &net.DNSError{IsTemporary: true}, ClassificationTransient},
|
||||
{"dns_timeout", &net.DNSError{IsTimeout: true}, ClassificationTransient},
|
||||
{"dns_permanent", &net.DNSError{IsNotFound: true}, ClassificationPermanent},
|
||||
{"socks5_auth_rejected", ErrAuthRejected, ClassificationPermanent},
|
||||
{"socks5_bad_version", ErrSocks5BadVersion, ClassificationPermanent},
|
||||
{"socks5_rejected_all_auth", ErrSocks5RejectedAllAuth, ClassificationPermanent},
|
||||
{"socks5_credential_too_long", ErrCredentialTooLong, ClassificationPermanent},
|
||||
{"socks5_host_too_long", ErrHostTooLong, ClassificationPermanent},
|
||||
{"socks5_unsupported_relay_atyp", ErrUnsupportedRelayATYP, ClassificationPermanent},
|
||||
{"socks5_short_reply", ErrShortReply, ClassificationPermanent},
|
||||
{"socks5_reply_general_failure", ErrSocks5Reply{Code: 0x01}, ClassificationPermanent},
|
||||
{"socks5_reply_not_allowed", ErrSocks5Reply{Code: 0x02}, ClassificationPermanent},
|
||||
{"socks5_reply_refused", ErrSocks5Reply{Code: 0x05}, ClassificationPermanent},
|
||||
{"socks5_reply_unsupported", ErrSocks5Reply{Code: 0x07}, ClassificationPermanent},
|
||||
{"stun_too_short", ErrSTUNTooShort, ClassificationPermanent},
|
||||
{"stun_bad_magic", ErrSTUNBadMagicCookie, ClassificationPermanent},
|
||||
{"stun_not_success", ErrSTUNNotSuccess, ClassificationPermanent},
|
||||
{"stun_txid_mismatch", ErrSTUNTxIDMismatch, ClassificationPermanent},
|
||||
{"stun_no_mapped", ErrSTUNNoMappedAddress, ClassificationPermanent},
|
||||
{"stun_unsupported_family", ErrSTUNUnsupportedFamily, ClassificationPermanent},
|
||||
{"unexpected_eof", io.ErrUnexpectedEOF, ClassificationPermanent},
|
||||
{"unexpected_eof_in_op", &net.OpError{Op: "read", Err: io.ErrUnexpectedEOF}, ClassificationTransient},
|
||||
{"joined_canceled_with_timeout", errors.Join(context.Canceled, &timeoutOnlyError{}), ClassificationPermanent},
|
||||
{"joined_canceled_with_econnreset", errors.Join(context.Canceled, syscall.ECONNRESET), ClassificationPermanent},
|
||||
{"random", errors.New("ouch"), ClassificationPermanent},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := classifyError(c.err)
|
||||
assert.Equal(t, c.want, got, "err=%v", c.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsContextErr(t *testing.T) {
|
||||
t.Run("nil", func(t *testing.T) {
|
||||
assert.False(t, isContextErr(nil))
|
||||
})
|
||||
t.Run("canceled", func(t *testing.T) {
|
||||
assert.True(t, isContextErr(context.Canceled))
|
||||
})
|
||||
t.Run("deadline", func(t *testing.T) {
|
||||
assert.True(t, isContextErr(context.DeadlineExceeded))
|
||||
})
|
||||
t.Run("joined_canceled_with_econnreset", func(t *testing.T) {
|
||||
assert.True(t, isContextErr(errors.Join(context.Canceled, syscall.ECONNRESET)))
|
||||
})
|
||||
t.Run("random", func(t *testing.T) {
|
||||
assert.False(t, isContextErr(errors.New("nope")))
|
||||
})
|
||||
t.Run("oprrror_wrapping_deadline", func(t *testing.T) {
|
||||
err := &net.OpError{Op: "read", Err: context.DeadlineExceeded}
|
||||
assert.True(t, isContextErr(err))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the SOCKS5 primitives.
|
||||
var (
|
||||
ErrSocks5BadVersion = errors.New("socks5: server returned wrong version")
|
||||
ErrSocks5RejectedAllAuth = errors.New("socks5: server rejected all offered auth methods (0xFF)")
|
||||
ErrAuthRejected = errors.New("socks5: user/pass authentication rejected")
|
||||
ErrCredentialTooLong = errors.New("socks5: login or password longer than 255 bytes")
|
||||
ErrHostTooLong = errors.New("socks5: target hostname longer than 255 bytes")
|
||||
ErrUnsupportedRelayATYP = errors.New("socks5: udp associate replied with non-IPv4 ATYP")
|
||||
ErrShortReply = errors.New("socks5: short server reply")
|
||||
)
|
||||
|
||||
// ErrSocks5Reply wraps a non-zero REP code so callers can react to specific
|
||||
// SOCKS5 reply codes (e.g. REP=0x07 = command not supported, REP=0x05 =
|
||||
// connection refused).
|
||||
type ErrSocks5Reply struct{ Code byte }
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e ErrSocks5Reply) Error() string {
|
||||
return fmt.Sprintf("socks5: server replied with non-zero REP code 0x%02X", e.Code)
|
||||
}
|
||||
|
||||
// Is reports whether target matches this reply error by Code.
|
||||
func (e ErrSocks5Reply) Is(target error) bool {
|
||||
t, ok := target.(ErrSocks5Reply)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return t.Code == e.Code
|
||||
}
|
||||
|
||||
// applyDeadline applies the deadline from ctx (if any) to conn. Returns a
|
||||
// function to clear the deadline.
|
||||
func applyDeadline(ctx context.Context, conn net.Conn) {
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// joinCtxErr wraps err with ctx.Err() if ctx has been cancelled or expired,
|
||||
// so that callers see context.Canceled / context.DeadlineExceeded in the
|
||||
// error chain even when the underlying I/O reported a deadline-based error.
|
||||
func joinCtxErr(ctx context.Context, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if cerr := ctx.Err(); cerr != nil {
|
||||
return errors.Join(err, cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// socks5Greeting performs the RFC 1928 client greeting on conn.
|
||||
// useAuth=true sends "05 02 00 02" (offer no-auth and user/pass);
|
||||
// useAuth=false sends "05 01 00" (offer no-auth only).
|
||||
func socks5Greeting(ctx context.Context, conn net.Conn, useAuth bool) (method byte, rawReply []byte, err error) {
|
||||
applyDeadline(ctx, conn)
|
||||
|
||||
var greet []byte
|
||||
if useAuth {
|
||||
greet = []byte{0x05, 0x02, 0x00, 0x02}
|
||||
} else {
|
||||
greet = []byte{0x05, 0x01, 0x00}
|
||||
}
|
||||
|
||||
if _, werr := conn.Write(greet); werr != nil {
|
||||
return 0, nil, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: write: %w", werr))
|
||||
}
|
||||
|
||||
reply := make([]byte, 2)
|
||||
n, rerr := io.ReadFull(conn, reply)
|
||||
if rerr != nil {
|
||||
partial := reply[:n]
|
||||
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
|
||||
return 0, partial, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrShortReply, partial))
|
||||
}
|
||||
return 0, partial, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: read: %w (raw=%x)", rerr, partial))
|
||||
}
|
||||
|
||||
if reply[0] != 0x05 {
|
||||
return 0, reply, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrSocks5BadVersion, reply)
|
||||
}
|
||||
if reply[1] == 0xFF {
|
||||
return reply[1], reply, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrSocks5RejectedAllAuth, reply)
|
||||
}
|
||||
return reply[1], reply, nil
|
||||
}
|
||||
|
||||
// socks5Auth performs RFC 1929 user/pass sub-negotiation on conn,
|
||||
// after greeting selected method 0x02.
|
||||
func socks5Auth(ctx context.Context, conn net.Conn, login, password string) (rawReply []byte, err error) {
|
||||
if len(login) > 255 || len(password) > 255 {
|
||||
return nil, ErrCredentialTooLong
|
||||
}
|
||||
|
||||
applyDeadline(ctx, conn)
|
||||
|
||||
buf := make([]byte, 0, 3+len(login)+len(password))
|
||||
buf = append(buf, 0x01) // VER
|
||||
buf = append(buf, byte(len(login))) // ULEN
|
||||
buf = append(buf, []byte(login)...) // UNAME
|
||||
buf = append(buf, byte(len(password)))
|
||||
buf = append(buf, []byte(password)...)
|
||||
|
||||
if _, werr := conn.Write(buf); werr != nil {
|
||||
return nil, joinCtxErr(ctx, fmt.Errorf("socks5 auth: write: %w", werr))
|
||||
}
|
||||
|
||||
reply := make([]byte, 2)
|
||||
n, rerr := io.ReadFull(conn, reply)
|
||||
if rerr != nil {
|
||||
partial := reply[:n]
|
||||
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
|
||||
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 auth: %w (raw=%x)", ErrShortReply, partial))
|
||||
}
|
||||
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 auth: read: %w (raw=%x)", rerr, partial))
|
||||
}
|
||||
|
||||
if reply[0] != 0x01 {
|
||||
return reply, fmt.Errorf("socks5 auth: auth subneg version mismatch: got 0x%02X want 0x01 (raw=%x)", reply[0], reply)
|
||||
}
|
||||
if reply[1] != 0x00 {
|
||||
return reply, fmt.Errorf("socks5 auth: %w (raw=%x)", ErrAuthRejected, reply)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// socks5Connect performs SOCKS5 CONNECT (CMD=01) to host:port using
|
||||
// ATYP=03 (domain name).
|
||||
func socks5Connect(ctx context.Context, conn net.Conn, host string, port uint16) (rawReply []byte, err error) {
|
||||
if len(host) > 255 {
|
||||
return nil, ErrHostTooLong
|
||||
}
|
||||
|
||||
applyDeadline(ctx, conn)
|
||||
|
||||
// VER=05 CMD=01 RSV=00 ATYP=03 LEN host port
|
||||
req := make([]byte, 0, 7+len(host))
|
||||
req = append(req, 0x05, 0x01, 0x00, 0x03)
|
||||
req = append(req, byte(len(host)))
|
||||
req = append(req, []byte(host)...)
|
||||
var portBuf [2]byte
|
||||
binary.BigEndian.PutUint16(portBuf[:], port)
|
||||
req = append(req, portBuf[:]...)
|
||||
|
||||
if _, werr := conn.Write(req); werr != nil {
|
||||
return nil, joinCtxErr(ctx, fmt.Errorf("socks5 connect: write: %w", werr))
|
||||
}
|
||||
|
||||
// We always read 10 bytes (assuming ATYP=01 IPv4 reply, the most
|
||||
// common case from real proxies). Parsing variable-length BND is
|
||||
// out of scope for the diagnostic.
|
||||
reply := make([]byte, 10)
|
||||
n, rerr := io.ReadFull(conn, reply)
|
||||
if rerr != nil {
|
||||
partial := reply[:n]
|
||||
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
|
||||
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrShortReply, partial))
|
||||
}
|
||||
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 connect: read: %w (raw=%x)", rerr, partial))
|
||||
}
|
||||
|
||||
if reply[0] != 0x05 {
|
||||
return reply, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrSocks5BadVersion, reply)
|
||||
}
|
||||
if reply[1] != 0x00 {
|
||||
return reply, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrSocks5Reply{Code: reply[1]}, reply)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// socks5UDPAssociate performs SOCKS5 UDP ASSOCIATE (CMD=03) on conn.
|
||||
func socks5UDPAssociate(ctx context.Context, conn net.Conn) (relay *net.UDPAddr, rawReply []byte, err error) {
|
||||
applyDeadline(ctx, conn)
|
||||
|
||||
// VER=05 CMD=03 RSV=00 ATYP=01 DST.ADDR=0.0.0.0 DST.PORT=0
|
||||
req := []byte{0x05, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
if _, werr := conn.Write(req); werr != nil {
|
||||
return nil, nil, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: write: %w", werr))
|
||||
}
|
||||
|
||||
reply := make([]byte, 10)
|
||||
n, rerr := io.ReadFull(conn, reply)
|
||||
if rerr != nil {
|
||||
partial := reply[:n]
|
||||
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
|
||||
return nil, partial, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrShortReply, partial))
|
||||
}
|
||||
return nil, partial, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: read: %w (raw=%x)", rerr, partial))
|
||||
}
|
||||
|
||||
if reply[0] != 0x05 {
|
||||
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrSocks5BadVersion, reply)
|
||||
}
|
||||
if reply[1] != 0x00 {
|
||||
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrSocks5Reply{Code: reply[1]}, reply)
|
||||
}
|
||||
if reply[3] != 0x01 {
|
||||
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (atyp=0x%02X raw=%x)", ErrUnsupportedRelayATYP, reply[3], reply)
|
||||
}
|
||||
|
||||
ip := net.IPv4(reply[4], reply[5], reply[6], reply[7])
|
||||
port := binary.BigEndian.Uint16(reply[8:10])
|
||||
relay = &net.UDPAddr{IP: ip, Port: int(port)}
|
||||
return relay, reply, nil
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newFakeSocks5Server starts a TCP listener on 127.0.0.1:0. On the first
|
||||
// accepted connection it reads up to 1024 bytes (enough for any of our
|
||||
// primitives' fixed-length frames in a single Write), then writes
|
||||
// scriptedReply, then closes the connection. The listener is closed by
|
||||
// t.Cleanup.
|
||||
func newFakeSocks5Server(t *testing.T, scriptedReply []byte) (addr string) {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "listen")
|
||||
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
_ = ln.Close()
|
||||
<-done
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
buf := make([]byte, 1024)
|
||||
_, _ = conn.Read(buf)
|
||||
if len(scriptedReply) > 0 {
|
||||
_, _ = conn.Write(scriptedReply)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// dial connects to addr and registers t.Cleanup to close the conn.
|
||||
func dial(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", addr, 1*time.Second)
|
||||
require.NoError(t, err, "dial")
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func ctxShort(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestSocks5Greeting(t *testing.T) {
|
||||
t.Run("happy_no_auth", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x00), method)
|
||||
assert.Equal(t, []byte{0x05, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("happy_userpass_selected", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x02})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x02), method)
|
||||
assert.Equal(t, []byte{0x05, 0x02}, raw)
|
||||
})
|
||||
|
||||
t.Run("happy_no_auth_when_offered_both", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x00), method)
|
||||
assert.Equal(t, []byte{0x05, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("rejected_all_auth", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0xFF})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5RejectedAllAuth), "expected ErrSocks5RejectedAllAuth in chain, got: %v", err)
|
||||
assert.Equal(t, []byte{0x05, 0xFF}, raw)
|
||||
})
|
||||
|
||||
t.Run("bad_version", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x04, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion in chain, got: %v", err)
|
||||
assert.Equal(t, []byte{0x04, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, _, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply in chain, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("garbage_http_response", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte("HTTP/1.1 200 OK\r\n"))
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion, got: %v", err)
|
||||
// First two bytes "HT" = 0x48 0x54
|
||||
assert.Equal(t, []byte{'H', 'T'}, raw)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5Auth(t *testing.T) {
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte{0x01, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("rejected", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01, 0x01})
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrAuthRejected), "expected ErrAuthRejected, got: %v", err)
|
||||
assert.Equal(t, []byte{0x01, 0x01}, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("bad_subneg_version", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x02, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "auth subneg version", "want subneg version mention, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("login_too_long", func(t *testing.T) {
|
||||
// 300 chars, no I/O should occur
|
||||
conn := &noopConn{}
|
||||
long := strings.Repeat("a", 300)
|
||||
_, err := socks5Auth(context.Background(), conn, long, "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrCredentialTooLong), "expected ErrCredentialTooLong, got: %v", err)
|
||||
assert.False(t, conn.touched, "no I/O should occur for over-long credential")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5Connect(t *testing.T) {
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
// 05 00 00 01 00000000 0000
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_connection_refused", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x05}), "expected ErrSocks5Reply{Code:5}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_cmd_not_supported", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x07}), "expected ErrSocks5Reply{Code:7}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
// And it should NOT match other codes:
|
||||
assert.False(t, errors.Is(err, ErrSocks5Reply{Code: 0x05}))
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("bad_version", func(t *testing.T) {
|
||||
reply := []byte{0x04, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("host_too_long", func(t *testing.T) {
|
||||
conn := &noopConn{}
|
||||
long := strings.Repeat("h", 300)
|
||||
_, err := socks5Connect(context.Background(), conn, long, 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrHostTooLong), "expected ErrHostTooLong, got: %v", err)
|
||||
assert.False(t, conn.touched, "no I/O should occur for over-long host")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5UDPAssociate(t *testing.T) {
|
||||
t.Run("happy_ipv4", func(t *testing.T) {
|
||||
// 05 00 00 01 7F000001 0539 -> 127.0.0.1:1337
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x7F, 0x00, 0x00, 0x01, 0x05, 0x39}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, relay)
|
||||
assert.True(t, relay.IP.Equal(net.IPv4(127, 0, 0, 1)), "ip=%s", relay.IP)
|
||||
assert.Equal(t, 1337, relay.Port)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_cmd_not_supported", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, relay)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x07}), "expected ErrSocks5Reply{Code:7}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("atyp_ipv6_unsupported", func(t *testing.T) {
|
||||
// REP=0x00 (success), ATYP=0x04 (IPv6) — unsupported by us. We
|
||||
// only read 10 bytes total so the trailing IPv6 bytes are
|
||||
// implicitly ignored on the wire.
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, relay)
|
||||
assert.True(t, errors.Is(err, ErrUnsupportedRelayATYP), "expected ErrUnsupportedRelayATYP, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, _, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSocks5GreetingCtxCancel verifies that a cancelled ctx surfaces
|
||||
// context.Canceled in the error chain even if the underlying I/O fails
|
||||
// with a deadline-style error.
|
||||
func TestSocks5GreetingCtxCancel(t *testing.T) {
|
||||
// Server that accepts but never replies — read will hang until ctx
|
||||
// deadline triggers SetDeadline-induced timeout.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
accepted := make(chan struct{})
|
||||
go func() {
|
||||
defer close(accepted)
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Hold the connection open without writing anything.
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
<-accepted // intentionally blocks; actually we close immediately on test end
|
||||
}()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", ln.Addr().String(), 1*time.Second)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, _, err = socks5Greeting(ctx, conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t,
|
||||
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled),
|
||||
"expected ctx error in chain, got: %v", err)
|
||||
}
|
||||
|
||||
// noopConn is a minimal net.Conn that records whether any I/O was
|
||||
// attempted. Used to assert that pre-I/O validation rejects oversized
|
||||
// inputs without ever touching the wire.
|
||||
type noopConn struct {
|
||||
touched bool
|
||||
}
|
||||
|
||||
func (c *noopConn) Read(b []byte) (int, error) { c.touched = true; return 0, io.EOF }
|
||||
func (c *noopConn) Write(b []byte) (int, error) { c.touched = true; return len(b), nil }
|
||||
func (c *noopConn) Close() error { return nil }
|
||||
func (c *noopConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *noopConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *noopConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *noopConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *noopConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package checker — STUN binding-request codec.
|
||||
//
|
||||
// Hand-rolled RFC 5389 binding request encoder + binding success response
|
||||
// parser. Just enough to extract XOR-MAPPED-ADDRESS — no message integrity,
|
||||
// no fingerprint, no ALTERNATE-SERVER, no STUN-USE-CANDIDATE. Used after
|
||||
// socks5UDPAssociate succeeds to verify the relay actually forwards UDP
|
||||
// to the public Internet.
|
||||
//
|
||||
// We deliberately avoid pulling in pion/stun: ~80 LOC of encoding/binary,
|
||||
// one attribute type. Adding ~50 KB of compiled code + a transitive
|
||||
// dependency for one round trip is overkill.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Magic cookie defined by RFC 5389 §6.
|
||||
const stunMagicCookie = 0x2112A442
|
||||
|
||||
// STUN message types and attribute types we care about.
|
||||
const (
|
||||
stunBindingRequest = 0x0001
|
||||
stunBindingSuccessResponse = 0x0101
|
||||
stunAttrXORMappedAddress = 0x0020
|
||||
stunAddressFamilyIPv4 = 0x01
|
||||
stunAddressFamilyIPv6 = 0x02
|
||||
)
|
||||
|
||||
// Sentinel errors so HintFor (and tests) can match specific failure modes.
|
||||
var (
|
||||
ErrSTUNTooShort = errors.New("stun: response shorter than 20-byte header")
|
||||
ErrSTUNBadMagicCookie = errors.New("stun: magic cookie mismatch")
|
||||
ErrSTUNNotSuccess = errors.New("stun: response is not a Binding Success Response")
|
||||
ErrSTUNTxIDMismatch = errors.New("stun: transaction ID mismatch")
|
||||
ErrSTUNNoMappedAddress = errors.New("stun: response has no XOR-MAPPED-ADDRESS attribute")
|
||||
ErrSTUNUnsupportedFamily = errors.New("stun: unsupported XOR-MAPPED-ADDRESS family")
|
||||
)
|
||||
|
||||
// NewTransactionID returns 12 cryptographically-random bytes suitable for
|
||||
// use as a STUN transaction ID. Errors only if rand.Reader fails — caller
|
||||
// should propagate (the runtime is in trouble at that point anyway).
|
||||
func NewTransactionID() ([12]byte, error) {
|
||||
var id [12]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return id, fmt.Errorf("stun: read random transaction id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// EncodeBindingRequest builds a 20-byte STUN Binding Request with the given
|
||||
// transaction ID. RFC 5389 allows empty request bodies.
|
||||
func EncodeBindingRequest(txID [12]byte) []byte {
|
||||
buf := make([]byte, 20)
|
||||
binary.BigEndian.PutUint16(buf[0:2], stunBindingRequest)
|
||||
binary.BigEndian.PutUint16(buf[2:4], 0) // attribute length
|
||||
binary.BigEndian.PutUint32(buf[4:8], stunMagicCookie)
|
||||
copy(buf[8:20], txID[:])
|
||||
return buf
|
||||
}
|
||||
|
||||
// ParseBindingResponse decodes a STUN Binding Success Response and returns
|
||||
// the public IP+port advertised in XOR-MAPPED-ADDRESS.
|
||||
//
|
||||
// Validates header (length, magic, message type, transaction ID), then
|
||||
// walks the TLV attribute section and extracts the first XOR-MAPPED-ADDRESS
|
||||
// attribute. Other attributes are skipped (per RFC 5389 §15 the
|
||||
// "comprehension-optional" range is everything ≥ 0x8000; we skip every
|
||||
// non-XOR-MAPPED-ADDRESS attribute regardless, since this is the only one
|
||||
// we care about).
|
||||
func ParseBindingResponse(buf []byte, expectedTxID [12]byte) (net.IP, uint16, error) {
|
||||
if len(buf) < 20 {
|
||||
return nil, 0, ErrSTUNTooShort
|
||||
}
|
||||
|
||||
msgType := binary.BigEndian.Uint16(buf[0:2])
|
||||
attrLen := binary.BigEndian.Uint16(buf[2:4])
|
||||
cookie := binary.BigEndian.Uint32(buf[4:8])
|
||||
if cookie != stunMagicCookie {
|
||||
return nil, 0, ErrSTUNBadMagicCookie
|
||||
}
|
||||
if msgType != stunBindingSuccessResponse {
|
||||
return nil, 0, fmt.Errorf("%w: type=0x%04x", ErrSTUNNotSuccess, msgType)
|
||||
}
|
||||
|
||||
var txID [12]byte
|
||||
copy(txID[:], buf[8:20])
|
||||
if txID != expectedTxID {
|
||||
return nil, 0, ErrSTUNTxIDMismatch
|
||||
}
|
||||
|
||||
// Sanity check: attrLen must not exceed buffer.
|
||||
if int(attrLen) > len(buf)-20 {
|
||||
return nil, 0, fmt.Errorf("stun: attribute section length %d exceeds buffer (%d bytes after header)", attrLen, len(buf)-20)
|
||||
}
|
||||
|
||||
attrs := buf[20 : 20+int(attrLen)]
|
||||
off := 0
|
||||
for off < len(attrs) {
|
||||
// Each attribute header is 4 bytes (type + length).
|
||||
if len(attrs)-off < 4 {
|
||||
return nil, 0, fmt.Errorf("stun: truncated attribute header at offset %d", off)
|
||||
}
|
||||
aType := binary.BigEndian.Uint16(attrs[off : off+2])
|
||||
aLen := binary.BigEndian.Uint16(attrs[off+2 : off+4])
|
||||
valStart := off + 4
|
||||
valEnd := valStart + int(aLen)
|
||||
if valEnd > len(attrs) {
|
||||
return nil, 0, fmt.Errorf("stun: attribute at offset %d claims length %d but only %d bytes remain", off, aLen, len(attrs)-valStart)
|
||||
}
|
||||
|
||||
if aType == stunAttrXORMappedAddress {
|
||||
ip, port, err := parseXORMappedAddress(attrs[valStart:valEnd], txID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
// Skip attribute including padding to next 4-byte boundary.
|
||||
paddedLen := (int(aLen) + 3) &^ 3
|
||||
next := valStart + paddedLen
|
||||
if next > len(attrs) {
|
||||
// Padding runs past end — treat as truncation.
|
||||
return nil, 0, fmt.Errorf("stun: attribute padding at offset %d runs past end", off)
|
||||
}
|
||||
off = next
|
||||
}
|
||||
|
||||
return nil, 0, ErrSTUNNoMappedAddress
|
||||
}
|
||||
|
||||
// parseXORMappedAddress decodes the value of an XOR-MAPPED-ADDRESS attribute.
|
||||
//
|
||||
// Layout (RFC 5389 §15.2):
|
||||
//
|
||||
// 0 1 2 3
|
||||
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
// |0 0 0 0 0 0 0 0| Family | X-Port |
|
||||
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
// | X-Address (Variable)
|
||||
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
func parseXORMappedAddress(val []byte, txID [12]byte) (net.IP, uint16, error) {
|
||||
if len(val) < 4 {
|
||||
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS truncated (got %d bytes, need ≥4)", len(val))
|
||||
}
|
||||
family := val[1]
|
||||
xPort := binary.BigEndian.Uint16(val[2:4])
|
||||
port := xPort ^ uint16(stunMagicCookie>>16)
|
||||
|
||||
switch family {
|
||||
case stunAddressFamilyIPv4:
|
||||
if len(val) < 8 {
|
||||
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS IPv4 truncated (got %d bytes, need 8)", len(val))
|
||||
}
|
||||
xAddr := binary.BigEndian.Uint32(val[4:8])
|
||||
addr := xAddr ^ stunMagicCookie
|
||||
ip := make(net.IP, 4)
|
||||
binary.BigEndian.PutUint32(ip, addr)
|
||||
return ip, port, nil
|
||||
|
||||
case stunAddressFamilyIPv6:
|
||||
if len(val) < 20 {
|
||||
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS IPv6 truncated (got %d bytes, need 20)", len(val))
|
||||
}
|
||||
// XOR with magic_cookie || transaction_id (16 bytes total).
|
||||
var key [16]byte
|
||||
binary.BigEndian.PutUint32(key[0:4], stunMagicCookie)
|
||||
copy(key[4:16], txID[:])
|
||||
ip := make(net.IP, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
ip[i] = val[4+i] ^ key[i]
|
||||
}
|
||||
return ip, port, nil
|
||||
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("%w: family=0x%02x", ErrSTUNUnsupportedFamily, family)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mkXorMappedV4 builds a synthetic STUN binding success response carrying an
|
||||
// XOR-MAPPED-ADDRESS attribute for an IPv4 endpoint. Used by several test
|
||||
// cases to keep byte-construction DRY.
|
||||
func mkXorMappedV4(t *testing.T, ip net.IP, port uint16, txID [12]byte) []byte {
|
||||
t.Helper()
|
||||
ip4 := ip.To4()
|
||||
require.NotNil(t, ip4, "ip must be IPv4")
|
||||
|
||||
// Attribute value: 1B reserved + 1B family + 2B xPort + 4B xAddr = 8 bytes.
|
||||
attrVal := make([]byte, 8)
|
||||
attrVal[0] = 0
|
||||
attrVal[1] = stunAddressFamilyIPv4
|
||||
binary.BigEndian.PutUint16(attrVal[2:4], port^uint16(stunMagicCookie>>16))
|
||||
xAddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
||||
binary.BigEndian.PutUint32(attrVal[4:8], xAddr)
|
||||
|
||||
// Attribute header (4B) + value (8B) = 12 bytes total, no padding needed.
|
||||
attr := make([]byte, 4+len(attrVal))
|
||||
binary.BigEndian.PutUint16(attr[0:2], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(attr[2:4], uint16(len(attrVal)))
|
||||
copy(attr[4:], attrVal)
|
||||
|
||||
// 20B header + attrs.
|
||||
resp := make([]byte, 20+len(attr))
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], uint16(len(attr)))
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
copy(resp[20:], attr)
|
||||
return resp
|
||||
}
|
||||
|
||||
// mkXorMappedV6 builds a synthetic response with an IPv6 XOR-MAPPED-ADDRESS.
|
||||
func mkXorMappedV6(t *testing.T, ip net.IP, port uint16, txID [12]byte) []byte {
|
||||
t.Helper()
|
||||
ip6 := ip.To16()
|
||||
require.NotNil(t, ip6, "ip must be IPv6")
|
||||
require.Equal(t, net.IPv6len, len(ip6))
|
||||
|
||||
attrVal := make([]byte, 4+16)
|
||||
attrVal[0] = 0
|
||||
attrVal[1] = stunAddressFamilyIPv6
|
||||
binary.BigEndian.PutUint16(attrVal[2:4], port^uint16(stunMagicCookie>>16))
|
||||
|
||||
var key [16]byte
|
||||
binary.BigEndian.PutUint32(key[0:4], stunMagicCookie)
|
||||
copy(key[4:16], txID[:])
|
||||
for i := 0; i < 16; i++ {
|
||||
attrVal[4+i] = ip6[i] ^ key[i]
|
||||
}
|
||||
|
||||
attr := make([]byte, 4+len(attrVal))
|
||||
binary.BigEndian.PutUint16(attr[0:2], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(attr[2:4], uint16(len(attrVal)))
|
||||
copy(attr[4:], attrVal)
|
||||
|
||||
resp := make([]byte, 20+len(attr))
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], uint16(len(attr)))
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
copy(resp[20:], attr)
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestNewTransactionID(t *testing.T) {
|
||||
a, err := NewTransactionID()
|
||||
require.NoError(t, err)
|
||||
b, err := NewTransactionID()
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, a, b, "two consecutive transaction IDs should differ (cryptographic randomness)")
|
||||
assert.Len(t, a[:], 12)
|
||||
}
|
||||
|
||||
func TestEncodeBindingRequest(t *testing.T) {
|
||||
txID := [12]byte{0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
||||
got := EncodeBindingRequest(txID)
|
||||
|
||||
require.Len(t, got, 20)
|
||||
assert.Equal(t, byte(0x00), got[0])
|
||||
assert.Equal(t, byte(0x01), got[1], "type LSB = 0x01 (binding request)")
|
||||
assert.Equal(t, byte(0x00), got[2])
|
||||
assert.Equal(t, byte(0x00), got[3], "attribute length = 0 (empty body)")
|
||||
assert.Equal(t, []byte{0x21, 0x12, 0xA4, 0x42}, got[4:8], "magic cookie")
|
||||
assert.Equal(t, txID[:], got[8:20], "transaction id")
|
||||
}
|
||||
|
||||
func TestParseBindingResponse_HappyV4(t *testing.T) {
|
||||
txID := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
||||
ip := net.IPv4(198, 51, 100, 1).To4()
|
||||
const port uint16 = 42
|
||||
resp := mkXorMappedV4(t, ip, port, txID)
|
||||
|
||||
// Sanity-check the bytes match the worked example in the task description.
|
||||
// xPort = 42 ^ 0x2112 = 0x2138
|
||||
assert.Equal(t, byte(0x21), resp[20+4+2])
|
||||
assert.Equal(t, byte(0x38), resp[20+4+3])
|
||||
// xIP = 0xC6336401 ^ 0x2112A442 = 0xE721C043
|
||||
assert.Equal(t, []byte{0xE7, 0x21, 0xC0, 0x43}, resp[20+4+4:20+4+8])
|
||||
|
||||
gotIP, gotPort, err := ParseBindingResponse(resp, txID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gotIP.Equal(ip), "got %s want %s", gotIP, ip)
|
||||
assert.Equal(t, port, gotPort)
|
||||
assert.Len(t, gotIP, 4, "IPv4 result should be 4-byte slice")
|
||||
}
|
||||
|
||||
func TestParseBindingResponse_HappyV6(t *testing.T) {
|
||||
txID := [12]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC}
|
||||
ip := net.ParseIP("2001:db8::1")
|
||||
require.NotNil(t, ip)
|
||||
const port uint16 = 0x1234
|
||||
resp := mkXorMappedV6(t, ip, port, txID)
|
||||
|
||||
gotIP, gotPort, err := ParseBindingResponse(resp, txID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gotIP.Equal(ip), "got %s want %s", gotIP, ip)
|
||||
assert.Equal(t, port, gotPort)
|
||||
assert.Len(t, gotIP, 16, "IPv6 result should be 16-byte slice")
|
||||
}
|
||||
|
||||
func TestParseBindingResponse_MultipleUnknownAttributesThenMapped(t *testing.T) {
|
||||
txID := [12]byte{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}
|
||||
ip := net.IPv4(8, 8, 8, 8).To4()
|
||||
const port uint16 = 53
|
||||
|
||||
// Build header with three attributes:
|
||||
// 1. unknown type 0x8022 (SOFTWARE), value="abc" -> 3 bytes value + 1 byte pad
|
||||
// 2. unknown type 0x8023, value="hi" -> 2 bytes + 2 bytes pad
|
||||
// 3. real XOR-MAPPED-ADDRESS
|
||||
var attrs []byte
|
||||
|
||||
addAttr := func(t uint16, val []byte) {
|
||||
hdr := make([]byte, 4)
|
||||
binary.BigEndian.PutUint16(hdr[0:2], t)
|
||||
binary.BigEndian.PutUint16(hdr[2:4], uint16(len(val)))
|
||||
attrs = append(attrs, hdr...)
|
||||
attrs = append(attrs, val...)
|
||||
// pad to 4-byte boundary
|
||||
for len(attrs)%4 != 0 {
|
||||
attrs = append(attrs, 0)
|
||||
}
|
||||
}
|
||||
addAttr(0x8022, []byte("abc"))
|
||||
addAttr(0x8023, []byte("hi"))
|
||||
|
||||
// XOR-MAPPED-ADDRESS attribute
|
||||
xmAttrVal := make([]byte, 8)
|
||||
xmAttrVal[1] = stunAddressFamilyIPv4
|
||||
binary.BigEndian.PutUint16(xmAttrVal[2:4], port^uint16(stunMagicCookie>>16))
|
||||
xAddr := binary.BigEndian.Uint32(ip) ^ stunMagicCookie
|
||||
binary.BigEndian.PutUint32(xmAttrVal[4:8], xAddr)
|
||||
addAttr(stunAttrXORMappedAddress, xmAttrVal)
|
||||
|
||||
resp := make([]byte, 20+len(attrs))
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], uint16(len(attrs)))
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
copy(resp[20:], attrs)
|
||||
|
||||
gotIP, gotPort, err := ParseBindingResponse(resp, txID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gotIP.Equal(ip))
|
||||
assert.Equal(t, port, gotPort)
|
||||
}
|
||||
|
||||
func TestParseBindingResponse_Errors(t *testing.T) {
|
||||
txID := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
||||
otherTxID := [12]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
|
||||
t.Run("truncated", func(t *testing.T) {
|
||||
buf := make([]byte, 10)
|
||||
_, _, err := ParseBindingResponse(buf, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNTooShort)
|
||||
})
|
||||
|
||||
t.Run("bad_magic_cookie", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
copy(resp[4:8], []byte{0xAA, 0xBB, 0xCC, 0xDD})
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNBadMagicCookie)
|
||||
})
|
||||
|
||||
t.Run("not_success_request_type", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingRequest) // 0x0001
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNNotSuccess)
|
||||
})
|
||||
|
||||
t.Run("not_success_error_response_type", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
binary.BigEndian.PutUint16(resp[0:2], 0x0111) // binding error response
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNNotSuccess)
|
||||
})
|
||||
|
||||
t.Run("no_xor_mapped_address", func(t *testing.T) {
|
||||
// 20-byte header + zero attributes
|
||||
resp := make([]byte, 20)
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], 0)
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNNoMappedAddress)
|
||||
})
|
||||
|
||||
t.Run("unsupported_family", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
// Flip family byte (offset 20 + 4 + 1 = 25) to 0x03.
|
||||
resp[25] = 0x03
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNUnsupportedFamily)
|
||||
})
|
||||
|
||||
t.Run("attribute_length_overflow", func(t *testing.T) {
|
||||
// Build a header claiming 24 bytes of attrs, but only put one bogus
|
||||
// attribute of declared length 100 inside.
|
||||
resp := make([]byte, 20+24)
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], 24)
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
// attribute: type=0x0020, length=100 (lies — only 20 bytes of value follow)
|
||||
binary.BigEndian.PutUint16(resp[20:22], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(resp[22:24], 100)
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "claims length 100")
|
||||
})
|
||||
|
||||
t.Run("attribute_section_length_overflow", func(t *testing.T) {
|
||||
// Header says attrLen=200 but buffer only has 20 bytes after header.
|
||||
resp := make([]byte, 20+20)
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], 200)
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeds buffer")
|
||||
})
|
||||
|
||||
t.Run("tx_id_mismatch", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
_, _, err := ParseBindingResponse(resp, otherTxID)
|
||||
assert.ErrorIs(t, err, ErrSTUNTxIDMismatch)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRoundTripLocalhost stands up a tiny STUN server on loopback that
|
||||
// handles exactly one binding request and replies with the client's own
|
||||
// address as XOR-MAPPED-ADDRESS. Verifies the encode/parse pair end-to-end
|
||||
// against a real UDP socket.
|
||||
func TestRoundTripLocalhost(t *testing.T) {
|
||||
server, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "net.ListenPacket must succeed for round-trip test (real-network requirement)")
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
|
||||
// Server goroutine: read one request, parse minimally, reply.
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
buf := make([]byte, 1500)
|
||||
_ = server.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
n, from, rerr := server.ReadFrom(buf)
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
if n < 20 {
|
||||
return
|
||||
}
|
||||
// Verify it's a binding request with right magic.
|
||||
if binary.BigEndian.Uint16(buf[0:2]) != stunBindingRequest {
|
||||
return
|
||||
}
|
||||
if binary.BigEndian.Uint32(buf[4:8]) != stunMagicCookie {
|
||||
return
|
||||
}
|
||||
var txID [12]byte
|
||||
copy(txID[:], buf[8:20])
|
||||
|
||||
udpFrom, ok := from.(*net.UDPAddr)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reply := mkXorMappedV4(t, udpFrom.IP.To4(), uint16(udpFrom.Port), txID)
|
||||
_, _ = server.WriteTo(reply, from)
|
||||
}()
|
||||
|
||||
// Client side.
|
||||
serverAddr := server.LocalAddr().(*net.UDPAddr)
|
||||
conn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
txID, err := NewTransactionID()
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
_, err = conn.Write(EncodeBindingRequest(txID))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(time.Second)))
|
||||
respBuf := make([]byte, 1500)
|
||||
n, err := conn.Read(respBuf)
|
||||
require.NoError(t, err)
|
||||
rtt := time.Since(start)
|
||||
|
||||
gotIP, gotPort, err := ParseBindingResponse(respBuf[:n], txID)
|
||||
require.NoError(t, err)
|
||||
|
||||
clientLocal := conn.LocalAddr().(*net.UDPAddr)
|
||||
assert.True(t, gotIP.Equal(net.IPv4(127, 0, 0, 1)), "got %s want 127.0.0.1", gotIP)
|
||||
assert.Equal(t, uint16(clientLocal.Port), gotPort, "port should match client local port")
|
||||
assert.Less(t, rtt, 200*time.Millisecond, "loopback RTT should be under 200ms (got %s)", rtt)
|
||||
|
||||
// Make sure the server goroutine exits cleanly.
|
||||
select {
|
||||
case <-serverDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server goroutine did not exit")
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: errors.Is chain works for wrapped sentinels.
|
||||
func TestSentinelsAreUnique(t *testing.T) {
|
||||
all := []error{
|
||||
ErrSTUNTooShort,
|
||||
ErrSTUNBadMagicCookie,
|
||||
ErrSTUNNotSuccess,
|
||||
ErrSTUNTxIDMismatch,
|
||||
ErrSTUNNoMappedAddress,
|
||||
ErrSTUNUnsupportedFamily,
|
||||
}
|
||||
for i, a := range all {
|
||||
for j, b := range all {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
assert.False(t, errors.Is(a, b), "sentinel %d should not match sentinel %d", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package checker
|
||||
|
||||
// voice.go — predictive voice diagnostics.
|
||||
//
|
||||
// runVoiceQualityBurst fires a burst of STUN binding requests through
|
||||
// an open SOCKS5 UDP relay, then derives packet-loss / jitter /
|
||||
// percentile-RTT from the replies. A single round-trip says the relay
|
||||
// accepts UDP; a 30-packet burst tells you whether voice will actually
|
||||
// hold together.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VoiceQualityResult is the outcome of a UDP burst through a SOCKS5
|
||||
// relay. All fields are zero on a hard failure (no replies at all).
|
||||
type VoiceQualityResult struct {
|
||||
Sent int
|
||||
Received int
|
||||
LossPct float64 // 0..100
|
||||
JitterMS float64 // mean abs of inter-arrival deltas in ms
|
||||
P50RTTMS float64 // median round-trip in ms
|
||||
P95RTTMS float64 // 95th percentile (informational, not gated)
|
||||
}
|
||||
|
||||
// runVoiceQualityBurst sends `count` STUN binding requests through the
|
||||
// already-open SOCKS5 UDP relay (relayAddr) to stunHost:stunPort,
|
||||
// spaced `interval` apart. It listens on udpConn until
|
||||
// `time.Now() + max(interval, 200ms)` after the last send, then returns
|
||||
// the aggregate result.
|
||||
//
|
||||
// Each outbound datagram has the SOCKS5 UDP header
|
||||
// (RSV 00 00, FRAG 00, ATYP 01, DST_IPv4(4), DST_PORT(2)) followed by
|
||||
// a 20-byte STUN binding request. We track each request by its
|
||||
// transaction ID. Replies are stripped of their 10-byte SOCKS5 UDP
|
||||
// header before being handed to ParseBindingResponse.
|
||||
//
|
||||
// Returns an error only when ctx is cancelled or stunHost can't be
|
||||
// resolved to IPv4. A 100% loss is NOT an error — the caller decides
|
||||
// what status to assign; we just report Sent=count, Received=0.
|
||||
func runVoiceQualityBurst(
|
||||
ctx context.Context,
|
||||
udpConn net.PacketConn,
|
||||
relayAddr *net.UDPAddr,
|
||||
stunHost string,
|
||||
stunPort uint16,
|
||||
count int,
|
||||
interval time.Duration,
|
||||
) (VoiceQualityResult, error) {
|
||||
if count <= 0 {
|
||||
return VoiceQualityResult{}, errors.New("voice-quality: burst count must be > 0")
|
||||
}
|
||||
|
||||
// Resolve stunHost to IPv4.
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", stunHost)
|
||||
if err != nil {
|
||||
return VoiceQualityResult{}, fmt.Errorf("voice-quality: lookup %s: %w", stunHost, err)
|
||||
}
|
||||
var stunIP4 net.IP
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
stunIP4 = v4
|
||||
break
|
||||
}
|
||||
}
|
||||
if stunIP4 == nil {
|
||||
return VoiceQualityResult{}, fmt.Errorf("voice-quality: no IPv4 for %s", stunHost)
|
||||
}
|
||||
|
||||
// Per-tx state: send-time + arrival-time.
|
||||
type entry struct {
|
||||
sentAt time.Time
|
||||
arrivedAt time.Time
|
||||
received bool
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
entries = make(map[[12]byte]*entry, count)
|
||||
arrivals = make([]time.Time, 0, count) // for jitter (in arrival order)
|
||||
rtts = make([]float64, 0, count) // milliseconds
|
||||
)
|
||||
|
||||
// Reader goroutine: loops on ReadFrom until deadline expires.
|
||||
doneRead := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneRead)
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, _, rerr := udpConn.ReadFrom(buf)
|
||||
if rerr != nil {
|
||||
// Deadline expired or conn closed — exit.
|
||||
return
|
||||
}
|
||||
if n < 10 {
|
||||
continue
|
||||
}
|
||||
// Validate SOCKS5 UDP wrapper, derive header length.
|
||||
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
||||
continue
|
||||
}
|
||||
var hdrLen int
|
||||
switch buf[3] {
|
||||
case 0x01:
|
||||
hdrLen = 10
|
||||
case 0x04:
|
||||
hdrLen = 22
|
||||
case 0x03:
|
||||
if n < 5 {
|
||||
continue
|
||||
}
|
||||
hdrLen = 4 + 1 + int(buf[4]) + 2
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if n < hdrLen+20 {
|
||||
continue
|
||||
}
|
||||
stunReply := buf[hdrLen:n]
|
||||
// Pull the transaction ID out of the STUN header so we
|
||||
// can look up the matching send-time. ParseBindingResponse
|
||||
// rejects mismatched txIDs, so we feed it the *expected*
|
||||
// id from the entries map.
|
||||
var txID [12]byte
|
||||
copy(txID[:], stunReply[8:20])
|
||||
|
||||
now := time.Now()
|
||||
mu.Lock()
|
||||
ent, ok := entries[txID]
|
||||
if !ok || ent.received {
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if _, _, perr := ParseBindingResponse(stunReply, txID); perr != nil {
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
ent.arrivedAt = now
|
||||
ent.received = true
|
||||
arrivals = append(arrivals, now)
|
||||
rtts = append(rtts, float64(now.Sub(ent.sentAt).Microseconds())/1000.0)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Build base SOCKS5 UDP header (RSV+FRAG+ATYP+IP+PORT). STUN body
|
||||
// is per-packet (fresh tx id each).
|
||||
hdr := make([]byte, 0, 10)
|
||||
hdr = append(hdr, 0x00, 0x00, 0x00, 0x01)
|
||||
hdr = append(hdr, stunIP4...)
|
||||
var portBuf [2]byte
|
||||
binary.BigEndian.PutUint16(portBuf[:], stunPort)
|
||||
hdr = append(hdr, portBuf[:]...)
|
||||
|
||||
// Send burst.
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
sent := 0
|
||||
sendLoop:
|
||||
for sent < count {
|
||||
// Make a fresh tx id and STUN request.
|
||||
txID, terr := NewTransactionID()
|
||||
if terr != nil {
|
||||
break
|
||||
}
|
||||
stunReq := EncodeBindingRequest(txID)
|
||||
dgram := make([]byte, 0, len(hdr)+len(stunReq))
|
||||
dgram = append(dgram, hdr...)
|
||||
dgram = append(dgram, stunReq...)
|
||||
|
||||
// Record send-time *before* the write. Note: we register the
|
||||
// entry into the map BEFORE Write so the reader can never get a
|
||||
// reply for an unknown tx (would happen on a very fast localhost
|
||||
// echo).
|
||||
now := time.Now()
|
||||
mu.Lock()
|
||||
entries[txID] = &entry{sentAt: now}
|
||||
mu.Unlock()
|
||||
|
||||
if _, werr := udpConn.WriteTo(dgram, relayAddr); werr != nil {
|
||||
// Write failure aborts the burst — but we still wait for
|
||||
// any in-flight replies. Treat as "sent so far".
|
||||
break
|
||||
}
|
||||
sent++
|
||||
|
||||
if sent >= count {
|
||||
break sendLoop
|
||||
}
|
||||
|
||||
// Wait for next tick OR ctx cancel.
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx.Done():
|
||||
break sendLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait window for stragglers — at least 200ms past last send.
|
||||
wait := interval
|
||||
if wait < 200*time.Millisecond {
|
||||
wait = 200 * time.Millisecond
|
||||
}
|
||||
deadline := time.Now().Add(wait)
|
||||
_ = udpConn.SetReadDeadline(deadline)
|
||||
|
||||
// Wait for reader to exit. ctx cancel still races: bound by deadline.
|
||||
select {
|
||||
case <-doneRead:
|
||||
case <-ctx.Done():
|
||||
// Force the reader to exit ASAP by setting a past deadline.
|
||||
_ = udpConn.SetReadDeadline(time.Unix(0, 1))
|
||||
<-doneRead
|
||||
}
|
||||
// Reset deadline so subsequent users of the conn aren't surprised.
|
||||
_ = udpConn.SetReadDeadline(time.Time{})
|
||||
|
||||
// Compute aggregates.
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
received := len(rtts)
|
||||
res := VoiceQualityResult{
|
||||
Sent: sent,
|
||||
Received: received,
|
||||
}
|
||||
if sent > 0 {
|
||||
res.LossPct = float64(sent-received) / float64(sent) * 100.0
|
||||
}
|
||||
if received >= 2 {
|
||||
// Sort arrivals to compute inter-arrival jitter in chronological order.
|
||||
// arrivals is already chronological (appended as packets came in).
|
||||
var diffs []float64
|
||||
for i := 1; i < len(arrivals); i++ {
|
||||
d := float64(arrivals[i].Sub(arrivals[i-1]).Microseconds()) / 1000.0
|
||||
diffs = append(diffs, d)
|
||||
}
|
||||
// mean abs of consecutive deltas of inter-arrival diffs.
|
||||
if len(diffs) >= 2 {
|
||||
var sum float64
|
||||
for i := 1; i < len(diffs); i++ {
|
||||
sum += math.Abs(diffs[i] - diffs[i-1])
|
||||
}
|
||||
res.JitterMS = sum / float64(len(diffs)-1)
|
||||
} else if len(diffs) == 1 {
|
||||
// Only two arrivals — single delta, no second-order jitter.
|
||||
res.JitterMS = 0
|
||||
}
|
||||
}
|
||||
if received > 0 {
|
||||
// percentile.
|
||||
sorted := make([]float64, len(rtts))
|
||||
copy(sorted, rtts)
|
||||
sort.Float64s(sorted)
|
||||
p50idx := len(sorted) / 2
|
||||
if p50idx >= len(sorted) {
|
||||
p50idx = len(sorted) - 1
|
||||
}
|
||||
res.P50RTTMS = sorted[p50idx]
|
||||
p95idx := int(0.95 * float64(len(sorted)))
|
||||
if p95idx >= len(sorted) {
|
||||
p95idx = len(sorted) - 1
|
||||
}
|
||||
res.P95RTTMS = sorted[p95idx]
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeUDPRelay listens on a UDP socket and echoes SOCKS5-wrapped STUN
|
||||
// binding requests as a synthetic Binding Success Response, just like
|
||||
// fakeProxy.runRelay in checker_test.go but standalone (no SOCKS5 TCP
|
||||
// control channel needed). dropEveryN > 0 drops every Nth packet.
|
||||
type fakeUDPRelay struct {
|
||||
conn *net.UDPConn
|
||||
addr *net.UDPAddr
|
||||
dropEveryN atomic.Int32
|
||||
count atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeUDPRelay(t *testing.T) *fakeUDPRelay {
|
||||
t.Helper()
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
uconn := pc.(*net.UDPConn)
|
||||
r := &fakeUDPRelay{
|
||||
conn: uconn,
|
||||
addr: uconn.LocalAddr().(*net.UDPAddr),
|
||||
}
|
||||
t.Cleanup(func() { _ = uconn.Close() })
|
||||
go r.serve()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *fakeUDPRelay) serve() {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, src, err := r.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if dropN := r.dropEveryN.Load(); dropN > 0 {
|
||||
c := r.count.Add(1)
|
||||
if c%dropN == 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
r.count.Add(1)
|
||||
}
|
||||
if n < 10 {
|
||||
continue
|
||||
}
|
||||
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
||||
continue
|
||||
}
|
||||
var hdrLen int
|
||||
switch buf[3] {
|
||||
case 0x01:
|
||||
hdrLen = 10
|
||||
case 0x04:
|
||||
hdrLen = 22
|
||||
case 0x03:
|
||||
if n < 5 {
|
||||
continue
|
||||
}
|
||||
hdrLen = 4 + 1 + int(buf[4]) + 2
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if n < hdrLen+20 {
|
||||
continue
|
||||
}
|
||||
stunReq := buf[hdrLen:n]
|
||||
var txID [12]byte
|
||||
copy(txID[:], stunReq[8:20])
|
||||
|
||||
ip4 := src.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16)
|
||||
xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
||||
|
||||
stunResp := make([]byte, 20+12)
|
||||
binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(stunResp[2:4], 12)
|
||||
binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie)
|
||||
copy(stunResp[8:20], txID[:])
|
||||
binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(stunResp[22:24], 8)
|
||||
stunResp[24] = 0
|
||||
stunResp[25] = 0x01
|
||||
binary.BigEndian.PutUint16(stunResp[26:28], xport)
|
||||
binary.BigEndian.PutUint32(stunResp[28:32], xaddr)
|
||||
|
||||
out := make([]byte, 0, 10+len(stunResp))
|
||||
out = append(out, 0x00, 0x00, 0x00, 0x01)
|
||||
out = append(out, ip4...)
|
||||
var portBuf [2]byte
|
||||
binary.BigEndian.PutUint16(portBuf[:], uint16(src.Port))
|
||||
out = append(out, portBuf[:]...)
|
||||
out = append(out, stunResp...)
|
||||
|
||||
_, _ = r.conn.WriteToUDP(out, src)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_Math: full 30-of-30 reception on localhost, all
|
||||
// RTTs in single-digit milliseconds.
|
||||
func TestVoiceQualityBurst_Math(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 30, 5*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 30, res.Sent)
|
||||
assert.Equal(t, 30, res.Received)
|
||||
assert.InDelta(t, 0.0, res.LossPct, 0.001)
|
||||
assert.Less(t, res.P50RTTMS, 50.0, "loopback p50 should be tiny")
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_HalfLoss verifies the loss-percentage math when
|
||||
// the relay drops half the packets.
|
||||
func TestVoiceQualityBurst_HalfLoss(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
relay.dropEveryN.Store(2) // every other packet → 50% loss
|
||||
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 20, 3*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 20, res.Sent)
|
||||
assert.InDelta(t, 50.0, res.LossPct, 5.0, "expected ~50%% loss got %+v", res)
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_AllDropped: dropEveryN=1 → 100% loss. Should NOT
|
||||
// return an error; should report Sent=N, Received=0.
|
||||
func TestVoiceQualityBurst_AllDropped(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
relay.dropEveryN.Store(1)
|
||||
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 10, 3*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 10, res.Sent)
|
||||
assert.Equal(t, 0, res.Received)
|
||||
assert.InDelta(t, 100.0, res.LossPct, 0.001)
|
||||
assert.Equal(t, 0.0, res.P50RTTMS)
|
||||
assert.Equal(t, 0.0, res.JitterMS)
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_ZeroCount: count=0 → error (defensive).
|
||||
func TestVoiceQualityBurst_ZeroCount(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 0, 5*time.Millisecond)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package config loads and validates the TOML configuration.
|
||||
package config
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package divert wraps WinDivert for kernel-level packet capture.
|
||||
package divert
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package engine orchestrates the packet processing pipeline.
|
||||
package engine
|
||||
@@ -0,0 +1,278 @@
|
||||
// Package gui hosts the Wails app: the App struct (whose exported methods
|
||||
// become the JS API for the frontend) and the Run() helper invoked from
|
||||
// cmd/drover/main.go when the user double-clicks the binary.
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/checker"
|
||||
"git.okcu.io/root/drover-go/internal/sboxrun"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// App is the Wails-bound struct. Every exported method is callable from JS
|
||||
// via the auto-generated wailsjs/go/main/App.* bindings.
|
||||
//
|
||||
// Right now everything except the proxy form is a deterministic stub —
|
||||
// the real WinDivert + SOCKS5 engine arrives in Phase 1. The stubs are
|
||||
// sufficient for the UI to feel alive: Check fakes a 7-step diagnostic,
|
||||
// Start/Stop toggles a phase, GetStats emits realistic-looking numbers.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
eng *sboxrun.Engine
|
||||
startedAt time.Time
|
||||
|
||||
// muCheck guards cancelCheck and checkDone.
|
||||
// cancelCheck is the cancel func of the in-flight checker.Run context (nil
|
||||
// when no check is running). checkDone is closed by the runner goroutine
|
||||
// once it has drained the result channel — RunCheck waits on it before
|
||||
// starting a new run, so we never have two emitter goroutines alive.
|
||||
muCheck sync.Mutex
|
||||
cancelCheck context.CancelFunc
|
||||
checkDone chan struct{}
|
||||
}
|
||||
|
||||
// NewApp returns a fresh App stamped with the binary's build version
|
||||
// (so the GUI can display it in the title bar).
|
||||
func NewApp(version string) *App { return &App{version: version} }
|
||||
|
||||
// Version returns the build version (e.g. "0.2.0", "test-local", or
|
||||
// "dev"). Frontend reads it on mount to populate the custom title bar.
|
||||
func (a *App) Version() string { return a.version }
|
||||
|
||||
// Startup is called by Wails right after the window is created and the
|
||||
// JS runtime is ready. We grab the context for runtime.EventsEmit calls
|
||||
// from any subsequent method.
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
go a.statsLoop()
|
||||
}
|
||||
|
||||
// Config is the proxy/auth payload the frontend sends back from the form.
|
||||
type Config struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Auth bool `json:"auth"`
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// CheckResult is one row in the diagnostic table; the frontend listens
|
||||
// for them on the "check:result" event. Mirrors checker.Result but with
|
||||
// Duration converted to milliseconds (int) for the JS side.
|
||||
type CheckResult struct {
|
||||
ID string `json:"id"` // tcp / greet / auth / connect / udp / voice-quality / api
|
||||
Status string `json:"status"` // running | passed | warn | failed | skipped
|
||||
Metric string `json:"metric,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
Duration int64 `json:"duration_ms,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
}
|
||||
|
||||
// RunCheck runs a real 7-step SOCKS5 diagnostic via internal/checker. Each
|
||||
// Result from the checker channel is forwarded to the frontend as a
|
||||
// "check:result" event; when the channel closes (run finished, or context
|
||||
// cancelled) we emit "check:done" with the {total, passed, failed} summary.
|
||||
//
|
||||
// If a previous check is still in flight, its context is cancelled and we
|
||||
// wait for the previous goroutine to finish before launching the new one
|
||||
// — this guarantees event ordering (no two emitters alive simultaneously).
|
||||
func (a *App) RunCheck(cfg Config) {
|
||||
// Cancel any in-flight check and wait for its goroutine to drain.
|
||||
a.muCheck.Lock()
|
||||
prevCancel := a.cancelCheck
|
||||
prevDone := a.checkDone
|
||||
a.muCheck.Unlock()
|
||||
if prevCancel != nil {
|
||||
prevCancel()
|
||||
}
|
||||
if prevDone != nil {
|
||||
<-prevDone
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(a.ctx)
|
||||
done := make(chan struct{})
|
||||
|
||||
a.muCheck.Lock()
|
||||
a.cancelCheck = cancel
|
||||
a.checkDone = done
|
||||
a.muCheck.Unlock()
|
||||
|
||||
ckCfg := checker.Config{
|
||||
ProxyHost: cfg.Host,
|
||||
ProxyPort: cfg.Port,
|
||||
UseAuth: cfg.Auth,
|
||||
ProxyLogin: cfg.Login,
|
||||
ProxyPassword: cfg.Password,
|
||||
// Leave PerTestTimeout / MaxRetries / RetryBackoff /
|
||||
// DiscordGateway / DiscordAPI / StunServer at zero so the
|
||||
// checker package applies its own defaults.
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
var passed, failed int
|
||||
for r := range checker.Run(ctx, ckCfg) {
|
||||
// Always emit on a.ctx, never on the per-check ctx — the
|
||||
// per-check ctx may already be cancelled when the final
|
||||
// "cancelled" result arrives, which would silently drop it.
|
||||
runtime.EventsEmit(a.ctx, "check:result", CheckResult{
|
||||
ID: r.ID,
|
||||
Status: string(r.Status),
|
||||
Metric: r.Metric,
|
||||
Error: r.Error,
|
||||
Hint: r.Hint,
|
||||
RawHex: r.RawHex,
|
||||
Duration: r.Duration.Milliseconds(),
|
||||
Attempt: r.Attempt,
|
||||
})
|
||||
switch r.Status {
|
||||
case checker.StatusPassed, checker.StatusWarn:
|
||||
// Warn is a "soft pass" — counted as passed for the
|
||||
// final summary, but the row still surfaces the hint.
|
||||
passed++
|
||||
case checker.StatusFailed:
|
||||
failed++
|
||||
}
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "check:done", map[string]int{
|
||||
"total": passed + failed,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
})
|
||||
|
||||
// Clear cancel/done if we're still the current run (RunCheck may
|
||||
// have already replaced them with a newer run by the time we get
|
||||
// here, in which case leave those alone).
|
||||
a.muCheck.Lock()
|
||||
if a.checkDone == done {
|
||||
a.cancelCheck = nil
|
||||
a.checkDone = nil
|
||||
}
|
||||
a.muCheck.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// CancelCheck cancels the currently-running diagnostic, if any. Safe to
|
||||
// call when no check is running (no-op).
|
||||
func (a *App) CancelCheck() {
|
||||
a.muCheck.Lock()
|
||||
defer a.muCheck.Unlock()
|
||||
if a.cancelCheck != nil {
|
||||
a.cancelCheck()
|
||||
}
|
||||
}
|
||||
|
||||
// StartEngine initializes and brings up the engine with the given config.
|
||||
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() == sboxrun.StatusActive {
|
||||
log.Printf("gui: StartEngine no-op (already active)")
|
||||
return nil
|
||||
}
|
||||
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",
|
||||
"DiscordSystemHelper.exe", // elevated updater (modern builds)
|
||||
"Update.exe", // legacy Squirrel updater (older builds)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
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: sboxrun.Start failed: %v", err)
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false, "error": err.Error()})
|
||||
return err
|
||||
}
|
||||
a.eng = e
|
||||
a.startedAt = time.Now()
|
||||
log.Printf("gui: engine started, status=%s", e.Status())
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopEngine shuts down the engine.
|
||||
func (a *App) StopEngine() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.eng == nil {
|
||||
return nil
|
||||
}
|
||||
err := a.eng.Stop()
|
||||
a.eng = nil
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetStatus returns the current engine state and uptime.
|
||||
func (a *App) GetStatus() map[string]any {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
running := a.eng != nil && a.eng.Status() == sboxrun.StatusActive
|
||||
res := map[string]any{
|
||||
"running": running,
|
||||
"uptimeS": int(time.Since(a.startedAt).Seconds()),
|
||||
}
|
||||
if a.eng != nil {
|
||||
res["state"] = string(a.eng.Status())
|
||||
if err := a.eng.LastError(); err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// statsLoop emits a stats event every second when the engine is active.
|
||||
// Numbers are random but stable enough to look real. P2.4 will replace
|
||||
// with real counters from engine.Engine.
|
||||
func (a *App) statsLoop() {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
tick := time.NewTicker(time.Second)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
a.mu.Lock()
|
||||
if a.eng == nil || a.eng.Status() != sboxrun.StatusActive || a.ctx == nil {
|
||||
a.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
uptime := int(time.Since(a.startedAt).Seconds())
|
||||
a.mu.Unlock()
|
||||
|
||||
runtime.EventsEmit(a.ctx, "stats:update", map[string]any{
|
||||
"up": r.Intn(50_000) + 5_000, // bytes/sec out
|
||||
"down": r.Intn(500_000) + 50_000, // bytes/sec in
|
||||
"tcp": r.Intn(8) + 1,
|
||||
"udp": 0, // P2.1 scope: no UDP yet
|
||||
"uptimeS": uptime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Greet remains as a smoke check that the bindings pipeline survived
|
||||
// the transition. Frontend can call it from a debug button if needed.
|
||||
func (a *App) Greet(name string) string {
|
||||
return fmt.Sprintf("Hello %s — Drover-Go GUI is alive.", name)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Build Directory
|
||||
|
||||
The build directory is used to house all the build files and assets for your application.
|
||||
|
||||
The structure is:
|
||||
|
||||
* bin - Output directory
|
||||
* darwin - macOS specific files
|
||||
* windows - Windows specific files
|
||||
|
||||
## Mac
|
||||
|
||||
The `darwin` directory holds files specific to Mac builds.
|
||||
These may be customised and used as part of the build. To return these files to the default state, simply delete them
|
||||
and
|
||||
build with `wails build`.
|
||||
|
||||
The directory contains the following files:
|
||||
|
||||
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
|
||||
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
|
||||
|
||||
## Windows
|
||||
|
||||
The `windows` directory contains the manifest and rc files used when building with `wails build`.
|
||||
These may be customised for your application. To return these files to the default state, simply delete them and
|
||||
build with `wails build`.
|
||||
|
||||
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
|
||||
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
|
||||
will be created using the `appicon.png` file in the build directory.
|
||||
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
|
||||
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
|
||||
as well as the application itself (right click the exe -> properties -> details)
|
||||
- `wails.exe.manifest` - The main application manifest file.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||
## mentioned underneath.
|
||||
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
|
||||
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
|
||||
## from outside of Wails for debugging and development of the installer.
|
||||
##
|
||||
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||
## > wails build --target windows/amd64 --nsis
|
||||
## Then you can call makensis on this file with specifying the path to your binary:
|
||||
## For a AMD64 only installer:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||
## For a ARM64 only installer:
|
||||
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||
## For a installer with both architectures:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||
####
|
||||
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
|
||||
####
|
||||
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
|
||||
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
|
||||
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
|
||||
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
|
||||
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
|
||||
###
|
||||
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
####
|
||||
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||
####
|
||||
## Include the wails tools
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
RMDir /r $INSTDIR
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
@@ -0,0 +1,249 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!ifndef INFO_PROJECTNAME
|
||||
!define INFO_PROJECTNAME "{{.Name}}"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
!endif
|
||||
!ifndef UNINST_KEY_NAME
|
||||
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!ifdef ARG_WAILS_AMD64_BINARY
|
||||
!define SUPPORTS_AMD64
|
||||
!endif
|
||||
|
||||
!ifdef ARG_WAILS_ARM64_BINARY
|
||||
!define SUPPORTS_ARM64
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_AMD64
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "amd64_arm64"
|
||||
!else
|
||||
!define ARCH "amd64"
|
||||
!endif
|
||||
!else
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "arm64"
|
||||
!else
|
||||
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
IfSilent silentArch notSilentArch
|
||||
silentArch:
|
||||
SetErrorLevel 65
|
||||
Abort
|
||||
notSilentArch:
|
||||
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||
Quit
|
||||
${else}
|
||||
IfSilent silentWin notSilentWin
|
||||
silentWin:
|
||||
SetErrorLevel 64
|
||||
Abort
|
||||
notSilentWin:
|
||||
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
!macro wails.files
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.writeUninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||
SetShellVarContext all
|
||||
${else}
|
||||
SetShellVarContext current
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
# Install webview2 by launching the bootstrapper
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
# If the admin key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "tmp\MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||
!macroend
|
||||
|
||||
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||
|
||||
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||
!macroend
|
||||
|
||||
!macro wails.associateFiles
|
||||
; Create file associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
File "..\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateFiles
|
||||
; Delete app associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
|
||||
|
||||
Delete "$INSTDIR\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
!macroend
|
||||
|
||||
!macro wails.associateCustomProtocols
|
||||
; Create custom protocols associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateCustomProtocols
|
||||
; Delete app custom protocol associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
|
||||
{{end}}
|
||||
!macroend
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,15 @@
|
||||
package gui
|
||||
|
||||
import "embed"
|
||||
|
||||
// Assets embeds the built React frontend (frontend/dist/) into the
|
||||
// Go binary so a single drover.exe ships with no external files.
|
||||
// Build the frontend before `go build`:
|
||||
//
|
||||
// cd internal/gui/frontend && npm install && npm run build
|
||||
//
|
||||
// Then `go build ./cmd/drover` will pick up the fresh dist/ via this
|
||||
// directive.
|
||||
//
|
||||
//go:embed all:frontend/dist
|
||||
var Assets embed.FS
|
||||
@@ -0,0 +1,54 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEmbed verifies that frontend/dist actually got embedded — easy to
|
||||
// silently miss this and end up with a Wails window that 404s on every
|
||||
// asset.
|
||||
func TestEmbed(t *testing.T) {
|
||||
sub, err := fs.Sub(Assets, "frontend/dist")
|
||||
if err != nil {
|
||||
t.Fatalf("fs.Sub: %v", err)
|
||||
}
|
||||
var files []string
|
||||
fs.WalkDir(sub, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
files = append(files, p)
|
||||
return nil
|
||||
})
|
||||
if len(files) == 0 {
|
||||
t.Fatal("frontend/dist embed is empty — did you forget `npm run build`?")
|
||||
}
|
||||
if !sliceContains(files, "index.html") {
|
||||
t.Fatalf("no index.html in embed; got %v", files)
|
||||
}
|
||||
hasJS := false
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, ".js") {
|
||||
hasJS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasJS {
|
||||
t.Fatalf("no .js bundle in embed; got %v", files)
|
||||
}
|
||||
t.Logf("embed contains %d files (looks healthy):", len(files))
|
||||
for _, f := range files {
|
||||
t.Logf(" %s", f)
|
||||
}
|
||||
}
|
||||
|
||||
func sliceContains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>drover-gui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./src/main.jsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1426
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^2.0.1",
|
||||
"vite": "^3.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react'
|
||||
import ClassicWindow from './components/Classic.jsx'
|
||||
|
||||
// Wails sizes the host window itself (internal/gui/run.go). Classic renders
|
||||
// 100% of that surface; we own the mode state here so the title-bar toggle
|
||||
// in Classic can flip between dark and light without re-mounting.
|
||||
//
|
||||
// onToggleMode receives the click event so we can plant a circle-reveal
|
||||
// origin at the cursor position. The View Transitions API (Chromium 111+,
|
||||
// Edge / WebView2 included) snapshots the old DOM, swaps to the new one
|
||||
// after setMode commits, and animates between them. Fallback path just
|
||||
// flips the mode synchronously when the API is missing.
|
||||
export default function App() {
|
||||
const [mode, setMode] = React.useState('dark')
|
||||
|
||||
function onToggleMode(e) {
|
||||
const x = e?.clientX ?? window.innerWidth - 24
|
||||
const y = e?.clientY ?? 16
|
||||
document.documentElement.style.setProperty('--reveal-x', x + 'px')
|
||||
document.documentElement.style.setProperty('--reveal-y', y + 'px')
|
||||
|
||||
const flip = () => setMode(m => (m === 'dark' ? 'light' : 'dark'))
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(flip)
|
||||
} else {
|
||||
flip()
|
||||
}
|
||||
}
|
||||
|
||||
return <ClassicWindow mode={mode} onToggleMode={onToggleMode} />
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,510 @@
|
||||
// Classic.jsx — Variant 1: Classic devtool.
|
||||
// Information-dense. Mono metrics. Plain rectangles, hairline borders.
|
||||
// Sober palette: neutral grays + one teal accent for primary action / success.
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
useDrover,
|
||||
BrandMark, IconGear, IconMin, IconClose, IconChevron, IconCopy,
|
||||
IconArrowUp, IconArrowDown, IconSun, IconMoon, StatusDot,
|
||||
fmtBytes, fmtUptime, fmtTime,
|
||||
} from './shared.jsx'
|
||||
import { WindowMinimise, Quit } from '../../wailsjs/runtime/runtime'
|
||||
import { Version as GoVersion } from '../../wailsjs/go/gui/App'
|
||||
|
||||
const ClassicTheme = {
|
||||
// dark
|
||||
d: {
|
||||
bg: '#1c1d20',
|
||||
chrome: '#15161a',
|
||||
panel: '#22242a',
|
||||
panelAlt: '#1a1c20',
|
||||
border: '#34373d',
|
||||
borderSoft:'#2a2c32',
|
||||
text: '#dde0e6',
|
||||
dim: '#8a8f99',
|
||||
dimmer: '#5b6068',
|
||||
accent: '#3ea99f', // teal
|
||||
accentDim: '#2a7d76',
|
||||
danger: '#d96565',
|
||||
warn: '#d9a155',
|
||||
pass: '#5cba8b',
|
||||
skip: '#7c8088',
|
||||
inputBg: '#15161a',
|
||||
btnBg: '#2c2f36',
|
||||
btnBgH: '#373b43',
|
||||
primaryBg: '#3ea99f',
|
||||
primaryFg: '#0c1a18',
|
||||
},
|
||||
l: {
|
||||
bg: '#f3f4f6',
|
||||
chrome: '#e8eaef',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f8f9fb',
|
||||
border: '#d8dbe1',
|
||||
borderSoft:'#e6e8ec',
|
||||
text: '#1c1f24',
|
||||
dim: '#5c6168',
|
||||
dimmer: '#8a8f97',
|
||||
accent: '#2a7d76',
|
||||
accentDim: '#bdded9',
|
||||
danger: '#c0463f',
|
||||
warn: '#a8731e',
|
||||
pass: '#2f8c5a',
|
||||
skip: '#7c8088',
|
||||
inputBg: '#ffffff',
|
||||
btnBg: '#ffffff',
|
||||
btnBgH: '#f1f2f5',
|
||||
primaryBg: '#2a7d76',
|
||||
primaryFg: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
export function ClassicWindow({ mode = 'dark', initial, onToggleMode }) {
|
||||
const t = ClassicTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = useDrover(initial);
|
||||
const [version, setVersion] = React.useState('');
|
||||
React.useEffect(() => { GoVersion().then(setVersion).catch(() => {}); }, []);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip, warn: t.warn };
|
||||
const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace';
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
||||
const isActive = D.phase === 'active';
|
||||
const allChecked = D.phase === 'checked' || D.phase === 'active';
|
||||
const failed = D.lastSummary?.failed ?? 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100vw', height: '100vh', background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontUI, fontSize: 13, lineHeight: 1.4, overflow: 'hidden',
|
||||
border: mode === 'dark' ? '1px solid #000' : '1px solid #c0c3c9',
|
||||
}}>
|
||||
{/* ─── title bar ─── */}
|
||||
<ClassicTitleBar t={t} version={version} mode={mode} onToggleMode={onToggleMode} />
|
||||
|
||||
{/* ─── content ─── */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '14px 16px 0' }}>
|
||||
|
||||
{/* Form */}
|
||||
<SectionLabel t={t}>SOCKS5 Proxy</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Field t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono)} />
|
||||
</Field>
|
||||
<Field t={t} label="Port" style={{ width: 92 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Checkbox t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('cls-login')?.focus(), 30); }}>
|
||||
Authentication
|
||||
</Checkbox>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: 12, opacity: D.form.auth ? 1 : 0.45 }}>
|
||||
<Field t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="cls-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono, !D.form.auth)} />
|
||||
</Field>
|
||||
<Field t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono, !D.form.auth)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{D.phase === 'checking' ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<PrimaryBtn t={t} onClick={D.runCheck} disabled style={{ flex: 1 }}>
|
||||
Checking…
|
||||
</PrimaryBtn>
|
||||
<ClassicCancelBtn t={t} onClick={D.cancelCheck} />
|
||||
</div>
|
||||
) : (
|
||||
<PrimaryBtn t={t} onClick={D.runCheck} disabled={isActive}>
|
||||
Check connection
|
||||
</PrimaryBtn>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div style={{ height: 18 }} />
|
||||
<SectionLabel t={t}>Status</SectionLabel>
|
||||
<ClassicStatus t={t} D={D} palette={palette} fontMono={fontMono} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ height: 14 }} />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<ClassicStartBtn t={t} D={D} fontMono={fontMono} />
|
||||
<ClassicStopBtn t={t} D={D} />
|
||||
</div>
|
||||
|
||||
{isActive && <ClassicLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
|
||||
|
||||
<div style={{ height: 14 }} />
|
||||
</div>
|
||||
|
||||
{/* Logs collapsible */}
|
||||
<ClassicLogs t={t} D={D} fontMono={fontMono} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── pieces ─────────────────────────────────────────────────────────────────
|
||||
function ClassicTitleBar({ t, version, mode, onToggleMode }) {
|
||||
// Cell height = full title-bar height so the hover background fills
|
||||
// edge-to-edge (no thin sliver of chrome above the red close highlight).
|
||||
// alignSelf:'stretch' on the wrapper keeps it pinned to the top/bottom
|
||||
// of the flex row even though parent uses alignItems:'center' for text.
|
||||
const cellStyle = {
|
||||
width: 38, height: '100%', display:'flex', alignItems:'center', justifyContent:'center',
|
||||
color: t.dim, cursor:'pointer',
|
||||
};
|
||||
return (
|
||||
<div style={{
|
||||
height: 32, background: t.chrome, borderBottom: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', alignItems:'stretch',
|
||||
// CSS Wails recognises for the OS title-bar drag region. The
|
||||
// close/min cells below override it with --wails-draggable: no-drag
|
||||
// so clicks land on the buttons, not the drag handler.
|
||||
['--wails-draggable']: 'drag',
|
||||
userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'0 12px', flex:1 }}>
|
||||
<BrandMark size={14} color={t.accent}/>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.1 }}>Drover-Go</span>
|
||||
{version && <span style={{ fontSize: 11, color: t.dimmer, fontFamily:'ui-monospace,monospace' }}>v{version}</span>}
|
||||
</div>
|
||||
<div style={{ display:'flex', alignItems:'stretch', ['--wails-draggable']: 'no-drag' }}>
|
||||
<div style={cellStyle}
|
||||
title={mode === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
onClick={(e) => onToggleMode && onToggleMode(e)}>
|
||||
{mode === 'dark' ? <IconSun color={t.dim}/> : <IconMoon color={t.dim}/>}
|
||||
</div>
|
||||
<div style={cellStyle} title="Minimize" onClick={() => WindowMinimise()}>
|
||||
<IconMin color={t.dim}/>
|
||||
</div>
|
||||
<div style={cellStyle} title="Close"
|
||||
onClick={() => Quit()}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#c0463f'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<IconClose color={t.dim}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children, t }) {
|
||||
return <div style={{
|
||||
fontSize: 10.5, fontWeight: 600, letterSpacing: 1.2, textTransform: 'uppercase',
|
||||
color: t.dim, marginBottom: 8,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
|
||||
function Field({ children, label, t, style }) {
|
||||
return (
|
||||
<label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
|
||||
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500 }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function inputStyle(t, fontMono, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.border}`, borderRadius: 3, padding: '7px 9px',
|
||||
fontFamily: fontMono, fontSize: 12, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s, box-shadow .12s',
|
||||
};
|
||||
}
|
||||
|
||||
function Checkbox({ checked, onChange, children, t }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 7, cursor:'pointer', userSelect:'none', fontSize: 12 }}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 2, border: `1px solid ${checked ? t.accent : t.border}`,
|
||||
background: checked ? t.accent : 'transparent', display:'flex', alignItems:'center', justifyContent:'center',
|
||||
transition: 'background .12s, border-color .12s',
|
||||
}}>
|
||||
{checked && <svg width="9" height="9" viewBox="0 0 9 9"><path d="M1.5 4.5l2 2 4-4" stroke={t.primaryFg} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryBtn({ t, onClick, disabled, children, style }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
style={{
|
||||
width:'100%', padding:'9px 12px', border:'none', borderRadius: 3,
|
||||
background: disabled ? t.btnBg : t.primaryBg, color: disabled ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 12.5, letterSpacing: 0.1, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
boxShadow: disabled ? 'none' : `inset 0 -1px 0 rgba(0,0,0,.18)`,
|
||||
transition: 'background .12s', ...style,
|
||||
}}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── status panel ──────────────────────────────────────────────────────────
|
||||
function ClassicStatus({ t, D, palette, fontMono }) {
|
||||
const idle = D.phase === 'idle';
|
||||
if (idle) {
|
||||
return (
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4,
|
||||
padding: '14px 14px', display:'flex', alignItems:'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
||||
<span style={{ color: t.dim, fontSize: 12.5 }}>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4, overflow:'hidden' }}>
|
||||
{/* header */}
|
||||
<div style={{
|
||||
padding: '8px 12px', display:'flex', alignItems:'center', gap: 8,
|
||||
borderBottom: `1px solid ${t.borderSoft}`, background: t.panelAlt, fontSize: 12,
|
||||
}}>
|
||||
{D.phase === 'checking'
|
||||
? <>
|
||||
<StatusDot state="running" palette={palette} size={12}/>
|
||||
<span>Running diagnostics…</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
{Object.keys(D.results).length}/{D.tests.length}
|
||||
</span>
|
||||
</>
|
||||
: D.lastSummary?.failed === 0
|
||||
? (D.lastSummary?.warnings > 0
|
||||
? <span style={{ color: t.warn, fontWeight: 600 }}>All checks passed (with warnings).</span>
|
||||
: <span style={{ color: t.pass, fontWeight: 600 }}>All checks passed. Ready to start.</span>)
|
||||
: <span style={{ color: t.warn, fontWeight: 600 }}>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
|
||||
</div>
|
||||
{/* tests */}
|
||||
<div>
|
||||
{D.tests.map((test, i) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
const isLast = i === D.tests.length - 1;
|
||||
return (
|
||||
<div key={test.id} style={{
|
||||
borderBottom: !isLast ? `1px solid ${t.borderSoft}` : 'none',
|
||||
padding: '6px 12px',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 22 }}>
|
||||
<StatusDot state={state} palette={palette} size={12}/>
|
||||
<span style={{ fontSize: 12, color: state === 'pending' ? t.dim : t.text }} title={test.desc}>
|
||||
{test.label}
|
||||
</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'warn' ? t.warn : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? '...' : '')}
|
||||
</span>
|
||||
{(r?.result === 'failed' || r?.result === 'warn') && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={iconBtnStyle(t)} title="Подробнее">
|
||||
<IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(r?.result === 'failed' || r?.result === 'warn') && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 21px', padding: '8px 10px', borderRadius: 3,
|
||||
background: mode_mix(r.result === 'warn' ? t.warn : t.danger, t.panel, 0.9),
|
||||
border: `1px solid ${mode_mix(r.result === 'warn' ? t.warn : t.danger, t.panel, 0.78)}`,
|
||||
fontSize: 11.5, color: t.text,
|
||||
}}>
|
||||
{r.error
|
||||
? <div style={{ color: r.result === 'warn' ? t.warn : t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
: (r.hint && <div style={{ color: r.result === 'warn' ? t.warn : t.danger, fontWeight: 600, marginBottom: 2 }}>{r.hint}</div>)}
|
||||
{r.error && <div style={{ color: t.dim }}>{r.hint}</div>}
|
||||
{r.rawHex && (
|
||||
<div style={{
|
||||
fontFamily: fontMono, fontSize: 10.5, color: t.dimmer,
|
||||
marginTop: 4, padding: '4px 6px',
|
||||
background: t.panelAlt, borderRadius: 2,
|
||||
overflowX: 'auto', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{r.rawHex.length > 64 ? r.rawHex.slice(0, 64) + '…' : r.rawHex}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
||||
<button onClick={() => navigator.clipboard?.writeText(
|
||||
`[${test.label}] ${r.error} — ${r.metric}` + (r.rawHex ? ` — raw=${r.rawHex}` : ''))}
|
||||
style={smallBtn(t, fontMono)}>
|
||||
<IconCopy color={t.dim}/> copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconBtnStyle(t) {
|
||||
return {
|
||||
width: 20, height: 20, padding: 0, border:'none', background:'transparent',
|
||||
cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center',
|
||||
borderRadius: 2,
|
||||
};
|
||||
}
|
||||
function smallBtn(t, fontMono) {
|
||||
return {
|
||||
display:'inline-flex', alignItems:'center', gap: 4, padding: '3px 7px',
|
||||
background: t.btnBg, border: `1px solid ${t.border}`, color: t.dim,
|
||||
borderRadius: 3, fontFamily: fontMono, fontSize: 10.5, cursor:'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
// crude color mix for dark/light. expects hex (#rrggbb), bg can be hex too. amount=share-of-bg.
|
||||
function mode_mix(fg, bg, amt) {
|
||||
const a = hexToRgb(fg), b = hexToRgb(bg);
|
||||
return `rgb(${Math.round(a.r*(1-amt)+b.r*amt)},${Math.round(a.g*(1-amt)+b.g*amt)},${Math.round(a.b*(1-amt)+b.b*amt)})`;
|
||||
}
|
||||
function hexToRgb(h) {
|
||||
const v = h.replace('#','');
|
||||
return { r: parseInt(v.slice(0,2),16), g: parseInt(v.slice(2,4),16), b: parseInt(v.slice(4,6),16) };
|
||||
}
|
||||
|
||||
// ─── start/stop ────────────────────────────────────────────────────────────
|
||||
function ClassicStartBtn({ t, D, fontMono }) {
|
||||
const phase = D.phase;
|
||||
const summary = D.lastSummary;
|
||||
const allFailed = summary && summary.failed === D.tests.length;
|
||||
const checkedOk = phase === 'checked' && !allFailed;
|
||||
const active = phase === 'active';
|
||||
const warning = active && (summary?.failed || 0) > 0;
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<div style={{
|
||||
flex:1, padding:'9px 12px', borderRadius: 3, display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
background: warning ? mode_mix(t.warn, t.panel, 0.85) : mode_mix(t.pass, t.panel, 0.85),
|
||||
border: `1px solid ${warning ? t.warn : t.pass}`,
|
||||
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5, fontFamily: fontMono,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{
|
||||
width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass,
|
||||
}}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1 }}>
|
||||
Start proxying
|
||||
</PrimaryBtn>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicCancelBtn({ t, onClick }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: 92, padding: '9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
||||
background: t.btnBg, color: hover ? t.danger : t.text,
|
||||
border: `1px solid ${hover ? t.danger : t.border}`, cursor: 'pointer',
|
||||
transition: 'color .12s, border-color .12s',
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled}
|
||||
style={{
|
||||
flex:1, padding:'9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
||||
background: t.btnBg, color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${t.border}`, cursor: enabled ? 'pointer':'not-allowed',
|
||||
}}>
|
||||
Stop
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicLiveStats({ t, stats, fontMono }) {
|
||||
const cell = (icon, val) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4, color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
{icon}<span>{val}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 8, padding: '6px 10px', borderRadius: 3,
|
||||
background: t.panel, border: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
}}>
|
||||
{cell(<IconArrowUp color={t.dim}/>, fmtBytes(stats.up))}
|
||||
{cell(<IconArrowDown color={t.dim}/>, fmtBytes(stats.down))}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>TCP</span>, stats.tcp)}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>UDP</span>, stats.udp)}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>↑t</span>, fmtUptime(stats.uptimeS))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── logs ──────────────────────────────────────────────────────────────────
|
||||
function ClassicLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${t.borderSoft}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '8px 14px', display:'flex', alignItems:'center', gap:8,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
||||
fontSize: 11, fontFamily: fontMono, letterSpacing: 0.3,
|
||||
}}>
|
||||
<IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span style={{ textTransform:'uppercase' }}>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<div style={{ borderTop: `1px solid ${t.borderSoft}` }}>
|
||||
<div style={{ display:'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.borderSoft}` }}>
|
||||
<button style={smallBtn(t, fontMono)}
|
||||
onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}>copy all</button>
|
||||
<button style={smallBtn(t, fontMono)} onClick={D.clearLogs}>clear</button>
|
||||
<button style={smallBtn(t, fontMono)}>open log file</button>
|
||||
</div>
|
||||
<div className="drv-log" style={{
|
||||
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
|
||||
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
|
||||
background: t.panelAlt,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{fmtTime(l.t)}</span>
|
||||
{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
||||
{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export so App.jsx can `import ClassicWindow from './components/Classic'`.
|
||||
export default ClassicWindow;
|
||||
@@ -0,0 +1,394 @@
|
||||
// shared.jsx — state machine + shared icons/utilities for all Drover-Go variants.
|
||||
//
|
||||
// Original prototype loaded everything via window globals (babel script-tag
|
||||
// build). For Wails + Vite we use real ESM imports/exports — additions:
|
||||
// - `import * as React from 'react'` so `React.useState/useMemo/useEffect`
|
||||
// keep working unchanged.
|
||||
// - `export` on everything the variant components need.
|
||||
// - `useDrover` no longer simulates with `SCENARIOS`; it calls the Wails
|
||||
// bindings on `window.go.main.App` and listens for the events the Go
|
||||
// side emits (`check:result`, `check:done`, `stats:update`, ...).
|
||||
//
|
||||
// The state surface (form/phase/results/stats/logs) is unchanged, so the
|
||||
// UI components don't need to be rewritten — only their imports.
|
||||
|
||||
import * as React from 'react'
|
||||
import { RunCheck, CancelCheck, StartEngine, StopEngine, GetStatus } from '../../wailsjs/go/gui/App'
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
|
||||
|
||||
// ─── Test catalog ──────────────────────────────────────────────────────────
|
||||
export const ALL_TESTS = [
|
||||
{ id: 'tcp', label: 'TCP reachability', desc: 'TCP-соединение до прокси установлено' },
|
||||
{ id: 'greet', label: 'SOCKS5 greeting', desc: 'Прокси отвечает по протоколу SOCKS5' },
|
||||
{ id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true },
|
||||
{ id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' },
|
||||
{ id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' },
|
||||
{ id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' },
|
||||
];
|
||||
|
||||
// Pre-baked scenarios so the prototype feels alive. Each entry per test:
|
||||
// { result: 'passed'|'failed'|'skipped', metric: '12 ms' | 'ok' | …, error?: 'short msg', hint?: 'what to try' }
|
||||
const SCENARIOS = {
|
||||
// Default happy path (no auth)
|
||||
happy: {
|
||||
tcp: { result: 'passed', metric: '14 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
|
||||
stun: { result: 'passed', metric: '38 ms RTT' },
|
||||
api: { result: 'passed', metric: '204 OK · 89 ms' },
|
||||
},
|
||||
// With auth
|
||||
happyAuth: {
|
||||
tcp: { result: 'passed', metric: '14 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
auth: { result: 'passed', metric: 'user/pass · ok' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
|
||||
stun: { result: 'passed', metric: '38 ms RTT' },
|
||||
api: { result: 'passed', metric: '204 OK · 89 ms' },
|
||||
},
|
||||
// UDP fails — common Discord scenario
|
||||
udpFail: {
|
||||
tcp: { result: 'passed', metric: '17 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'failed', metric: 'X\'07 cmd not supported',
|
||||
error: 'Прокси не поддерживает UDP ASSOCIATE.',
|
||||
hint: 'Голос и демонстрация экрана работать не будут. Текст и API — будут. Попробуйте другой SOCKS5-сервер с поддержкой UDP.' },
|
||||
stun: { result: 'skipped', metric: 'требует UDP ASSOCIATE' },
|
||||
api: { result: 'passed', metric: '204 OK · 92 ms' },
|
||||
},
|
||||
};
|
||||
|
||||
export function getTests(authEnabled) {
|
||||
return ALL_TESTS.filter(t => !t.authOnly || authEnabled);
|
||||
}
|
||||
|
||||
// ─── Drover state hook ─────────────────────────────────────────────────────
|
||||
// Owns: form values, diagnostic phase, per-test results, drover-active state,
|
||||
// live stats counter, log buffer.
|
||||
// phase: 'idle' | 'checking' | 'checked' | 'active'
|
||||
export function useDrover(initial = {}) {
|
||||
const [form, setForm] = React.useState({
|
||||
host: '95.165.72.59',
|
||||
port: '12334',
|
||||
auth: false,
|
||||
login: '',
|
||||
password: '',
|
||||
...initial,
|
||||
});
|
||||
const [phase, setPhase] = React.useState('idle');
|
||||
const [results, setResults] = React.useState({}); // testId -> {result, metric, error, hint, expanded}
|
||||
const [running, setRunning] = React.useState(null); // currently-running test id
|
||||
const [scenario, setScenario] = React.useState('happy'); // kept for compat with prototype, unused with real backend
|
||||
const [stats, setStats] = React.useState({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
|
||||
const [logs, setLogs] = React.useState(() => seedLogs());
|
||||
const [logsOpen, setLogsOpen] = React.useState(false);
|
||||
const tests = getTests(form.auth);
|
||||
const lastSummary = React.useMemo(() => {
|
||||
if (phase !== 'checked' && phase !== 'active') return null;
|
||||
const ids = tests.map(t => t.id);
|
||||
const failed = ids.filter(id => results[id]?.result === 'failed').length;
|
||||
const warnings = ids.filter(id => results[id]?.result === 'warn').length;
|
||||
return { total: ids.length, failed, warnings };
|
||||
}, [phase, results, tests]);
|
||||
|
||||
// ── actions ────────────────────────────────────────────────────────────
|
||||
function update(patch) { setForm(f => ({ ...f, ...patch })); }
|
||||
|
||||
function pushLog(level, msg) {
|
||||
setLogs(l => [...l.slice(-499), { t: Date.now(), level, msg }]);
|
||||
}
|
||||
|
||||
// Subscribe to backend events once. The Go side emits:
|
||||
// check:result → one test result (id, status, metric, error, hint)
|
||||
// check:done → diagnostic finished, summary {total, passed, failed}
|
||||
// engine:status → {running: bool}
|
||||
// stats:update → {up, down, tcp, udp, uptimeS}
|
||||
React.useEffect(() => {
|
||||
const offResult = EventsOn('check:result', (r) => {
|
||||
if (r.status === 'running') {
|
||||
setRunning(r.id);
|
||||
return;
|
||||
}
|
||||
// Convert backend "status" field to the frontend's "result" field used
|
||||
// by the Classic/Fluent/etc components.
|
||||
setResults(prev => ({
|
||||
...prev,
|
||||
[r.id]: {
|
||||
result: r.status,
|
||||
metric: r.metric,
|
||||
error: r.error,
|
||||
hint: r.hint,
|
||||
rawHex: r.rawHex,
|
||||
attempt: r.attempt,
|
||||
expanded: r.status === 'failed' || r.status === 'warn',
|
||||
},
|
||||
}));
|
||||
pushLog(r.status === 'failed' ? 'ERROR' : (r.status === 'skipped' || r.status === 'warn') ? 'WARN' : 'INFO',
|
||||
`${r.id}: ${r.status}${r.metric ? ' · ' + r.metric : ''}`);
|
||||
});
|
||||
const offDone = EventsOn('check:done', (s) => {
|
||||
setRunning(null);
|
||||
setPhase('checked');
|
||||
pushLog('INFO', `check finished — ${s.passed}/${s.total} passed`);
|
||||
});
|
||||
const offStatus = EventsOn('engine:status', (s) => {
|
||||
setPhase(s.running ? 'active' : 'checked');
|
||||
pushLog('INFO', s.running ? 'engine: started' : 'engine: stopped');
|
||||
});
|
||||
const offStats = EventsOn('stats:update', (s) => setStats(s));
|
||||
|
||||
return () => {
|
||||
offResult();
|
||||
offDone();
|
||||
offStatus();
|
||||
offStats();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function runCheck() {
|
||||
if (phase === 'checking') return;
|
||||
setPhase('checking');
|
||||
setResults({});
|
||||
setRunning(null);
|
||||
pushLog('INFO', `connect ${form.host}:${form.port}${form.auth ? ' (auth)' : ''}`);
|
||||
await RunCheck({
|
||||
host: form.host,
|
||||
port: parseInt(form.port, 10) || 0,
|
||||
auth: form.auth,
|
||||
login: form.login,
|
||||
password: form.password,
|
||||
});
|
||||
// The rest is event-driven (check:result, check:done) — see useEffect above.
|
||||
}
|
||||
|
||||
function cancelCheck() {
|
||||
CancelCheck();
|
||||
pushLog('WARN', 'check cancelled by user');
|
||||
}
|
||||
|
||||
async function startProxy() {
|
||||
if (phase !== 'checked') return;
|
||||
if (lastSummary?.failed === tests.length) return;
|
||||
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'.
|
||||
}
|
||||
|
||||
async function stopProxy() {
|
||||
if (phase !== 'active') return;
|
||||
await StopEngine();
|
||||
setStats({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
|
||||
}
|
||||
|
||||
// Reflect initial backend state (in case the engine was already running
|
||||
// when the GUI was opened — e.g. via service mode).
|
||||
React.useEffect(() => {
|
||||
GetStatus().then((s) => {
|
||||
if (s?.running) setPhase('active');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Toggle a test's expanded explanation
|
||||
function toggleExpand(id) {
|
||||
setResults(r => ({ ...r, [id]: { ...r[id], expanded: !r[id]?.expanded } }));
|
||||
}
|
||||
|
||||
return {
|
||||
form, update,
|
||||
phase, setPhase,
|
||||
tests, results, running,
|
||||
scenario, setScenario,
|
||||
stats,
|
||||
logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]),
|
||||
lastSummary,
|
||||
runCheck, cancelCheck, startProxy, stopProxy,
|
||||
toggleExpand,
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
// Sun + moon icons for the theme-toggle button in the title bar. Style
|
||||
// matches the rest (1.2 stroke, 14px square viewBox).
|
||||
export function IconSun({ size=14, color='currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="3" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M8 1.5v1.5M8 13v1.5M14.5 8H13M3 8H1.5M12.6 3.4l-1 1M4.4 11.6l-1 1M12.6 12.6l-1-1M4.4 4.4l-1-1"
|
||||
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconMoon({ size=14, color='currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M13.5 9.5A5.5 5.5 0 1 1 6.5 2.5a4 4 0 0 0 7 7z"
|
||||
stroke={color} strokeWidth="1.2" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function seedLogs() {
|
||||
const t = Date.now();
|
||||
return [
|
||||
{ t: t-9200, level: 'INFO', msg: 'drover-go v0.4.2 starting' },
|
||||
{ t: t-9100, level: 'INFO', msg: 'config: ~/.drover/config.toml' },
|
||||
{ t: t-9000, level: 'INFO', msg: 'no active session' },
|
||||
];
|
||||
}
|
||||
|
||||
export function fmtBytes(n) {
|
||||
if (n < 1024) return n.toFixed(0) + ' B/s';
|
||||
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB/s';
|
||||
return (n/1024/1024).toFixed(2) + ' MB/s';
|
||||
}
|
||||
export function fmtUptime(s) {
|
||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${ss}s`;
|
||||
return `${ss}s`;
|
||||
}
|
||||
export function fmtTime(t) {
|
||||
const d = new Date(t);
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3,'0');
|
||||
}
|
||||
|
||||
// ─── Shared icons (small, original) ────────────────────────────────────────
|
||||
// Drover-Go mark: a downward chevron through a ring — "tunneled traffic".
|
||||
export function BrandMark({ size = 16, color = 'currentColor', strokeWidth = 1.6 }) {
|
||||
const s = size;
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={strokeWidth}/>
|
||||
<path d="M7 9 L12 14 L17 9" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 14 L12 19" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGear({ size=14, color='currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="2.2" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8L3.4 3.4"
|
||||
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconMin({ size=14, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M3 8h10" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
|
||||
}
|
||||
export function IconClose({ size=14, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
|
||||
}
|
||||
export function IconChevron({ size=12, color='currentColor', dir='down' }) {
|
||||
const r = { down: 0, up: 180, left: 90, right: -90 }[dir];
|
||||
return <svg width={size} height={size} viewBox="0 0 12 12" style={{ transform: `rotate(${r}deg)` }}>
|
||||
<path d="M3 4.5 L6 7.5 L9 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>;
|
||||
}
|
||||
export function IconCopy({ size=12, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M2 8.5V2.5C2 1.95 2.45 1.5 3 1.5h6" stroke={color} strokeWidth="1.2"/>
|
||||
</svg>;
|
||||
}
|
||||
export function IconArrowUp({ size=10, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
|
||||
<path d="M5 8.5V1.5M5 1.5L2 4.5M5 1.5L8 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>;
|
||||
}
|
||||
export function IconArrowDown({ size=10, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
|
||||
<path d="M5 1.5V8.5M5 8.5L2 5.5M5 8.5L8 5.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// ─── Test row state icons (per visual variant supplies its own colors) ─────
|
||||
export function StatusDot({ state, palette, size = 12 }) {
|
||||
// state: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
const c = palette[state] || palette.pending;
|
||||
if (state === 'running') {
|
||||
return (
|
||||
<span style={{ display:'inline-block', width:size, height:size, position:'relative' }}>
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" style={{ animation: 'drv-spin 0.8s linear infinite' }}>
|
||||
<circle cx="8" cy="8" r="6" stroke={c} strokeOpacity="0.25" strokeWidth="2" fill="none"/>
|
||||
<path d="M8 2 a6 6 0 0 1 6 6" stroke={c} strokeWidth="2" strokeLinecap="round" fill="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === 'passed') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M5 8.2l2 2 4-4.4" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'warn') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M8 4v5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
|
||||
<circle cx="8" cy="11.5" r="0.9" fill="white"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" stroke={c} strokeWidth="1.4" strokeDasharray="2 2"/>
|
||||
<path d="M5 8h6" stroke={c} strokeWidth="1.4" strokeLinecap="round"/>
|
||||
</svg>;
|
||||
}
|
||||
// pending
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="3" fill="none" stroke={c} strokeWidth="1.4"/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// CSS for the spinner — injected once.
|
||||
if (typeof document !== 'undefined' && !document.getElementById('drv-shared-css')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'drv-shared-css';
|
||||
s.textContent = `
|
||||
@keyframes drv-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes drv-pulse { 0%,100% { opacity:1; transform:scale(1);} 50% { opacity:.55; transform:scale(0.7);} }
|
||||
@keyframes drv-blink { 0%,100% { opacity:1;} 50% { opacity:.35;} }
|
||||
@keyframes drv-fadein { from { opacity:0; transform:translateY(-2px);} to { opacity:1; transform:none;} }
|
||||
.drv-fadein { animation: drv-fadein .18s ease-out; }
|
||||
.drv-pulsedot { animation: drv-pulse 1.4s ease-in-out infinite; }
|
||||
.drv-shimmer::after {
|
||||
content:''; position:absolute; inset:0; background: linear-gradient(90deg,transparent,rgba(255,255,255,.25),transparent);
|
||||
transform:translateX(-100%); animation: drv-shim 1.6s linear infinite;
|
||||
}
|
||||
@keyframes drv-shim { to { transform: translateX(100%); } }
|
||||
/* Hide scrollbars for log panes inside artboards */
|
||||
.drv-log::-webkit-scrollbar { width:6px; }
|
||||
.drv-log::-webkit-scrollbar-thumb { background: rgba(127,127,127,.35); border-radius: 3px; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// Expose globals
|
||||
Object.assign(window, {
|
||||
useDrover, getTests, ALL_TESTS, SCENARIOS,
|
||||
fmtBytes, fmtUptime, fmtTime,
|
||||
BrandMark, StatusDot,
|
||||
IconGear, IconMin, IconClose, IconChevron, IconCopy, IconArrowUp, IconArrowDown,
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './style.css'
|
||||
import App from './App'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
const root = createRoot(container)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
/* Reset everything Wails react-template ships by default — Classic component
|
||||
* draws the entire surface, including the title bar. */
|
||||
|
||||
html, body, #app, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1c1d20;
|
||||
color: #dde0e6;
|
||||
font-family:
|
||||
"Inter", "Segoe UI Variable", "Segoe UI", system-ui, -apple-system,
|
||||
BlinkMacSystemFont, sans-serif;
|
||||
font-size: 13.5px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Theme switch: circle reveal from the cursor ──────────────────────── */
|
||||
/* The title-bar sun/moon button calls document.startViewTransition() before
|
||||
* flipping the mode state; the API snapshots the old DOM, runs the React
|
||||
* update, and gives us pseudo-elements `::view-transition-old(root)` and
|
||||
* `::view-transition-new(root)` to animate between. We override the default
|
||||
* cross-fade with a circular clip-path expanding from --reveal-x/y, which
|
||||
* is set to the click coordinates by App.jsx right before the transition. */
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* The new state expands from a tiny circle at the cursor into the whole
|
||||
* window. The old state stays put underneath. 0.45s feels lively without
|
||||
* dragging — long-form circle reveals (>700ms) start to feel laggy. */
|
||||
::view-transition-new(root) {
|
||||
animation: theme-reveal 0.45s ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes theme-reveal {
|
||||
from { clip-path: circle(0% at var(--reveal-x, 50%) var(--reveal-y, 50%)); }
|
||||
to { clip-path: circle(150% at var(--reveal-x, 50%) var(--reveal-y, 50%)); }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
// Manually-written JS bindings for the App struct in package
|
||||
// git.okcu.io/root/drover-go/internal/gui.
|
||||
//
|
||||
// Wails Bind() exposes the app's methods at runtime under
|
||||
// window.go.<package>.App.<Method>, where <package> is the Go package
|
||||
// where App is defined (here: "gui"). These wrappers give us a stable
|
||||
// import path from the React side and are the equivalent of what
|
||||
// `wails generate module` would have produced if we used the standard
|
||||
// flat layout.
|
||||
//
|
||||
// Whenever a new App method is added in internal/gui/app.go, mirror it here.
|
||||
|
||||
export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) }
|
||||
export function CancelCheck() { return window['go']['gui']['App']['CancelCheck']() }
|
||||
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']() }
|
||||
export function Greet(name) { return window['go']['gui']['App']['Greet'](name) }
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@wailsapp/runtime",
|
||||
"version": "2.0.0",
|
||||
"description": "Wails Javascript runtime library",
|
||||
"main": "runtime.js",
|
||||
"types": "runtime.d.ts",
|
||||
"scripts": {
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wailsapp/wails.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Wails",
|
||||
"Javascript",
|
||||
"Go"
|
||||
],
|
||||
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
isCurrent: boolean;
|
||||
isPrimary: boolean;
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// Environment information such as platform, buildtype, ...
|
||||
export interface EnvironmentInfo {
|
||||
buildType: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||
// emits the given event. Optional data may be passed with the event.
|
||||
// This will trigger any event listeners.
|
||||
export function EventsEmit(eventName: string, ...data: any): void;
|
||||
|
||||
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||
export function EventsOn(eventName: string, callback: (...data: any) => void): void;
|
||||
|
||||
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): void;
|
||||
|
||||
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||
// sets up a listener for the given event name, but will only trigger once.
|
||||
export function EventsOnce(eventName: string, callback: (...data: any) => void): void;
|
||||
|
||||
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsff)
|
||||
// unregisters the listener for the given event name.
|
||||
export function EventsOff(eventName: string): void;
|
||||
|
||||
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||
// unregisters all event listeners.
|
||||
export function EventsOffAll(): void;
|
||||
|
||||
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||
// logs the given message as a raw message
|
||||
export function LogPrint(message: string): void;
|
||||
|
||||
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||
// logs the given message at the `trace` log level.
|
||||
export function LogTrace(message: string): void;
|
||||
|
||||
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||
// logs the given message at the `debug` log level.
|
||||
export function LogDebug(message: string): void;
|
||||
|
||||
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||
// logs the given message at the `error` log level.
|
||||
export function LogError(message: string): void;
|
||||
|
||||
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||
// logs the given message at the `fatal` log level.
|
||||
// The application will quit after calling this method.
|
||||
export function LogFatal(message: string): void;
|
||||
|
||||
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||
// logs the given message at the `info` log level.
|
||||
export function LogInfo(message: string): void;
|
||||
|
||||
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||
// logs the given message at the `warning` log level.
|
||||
export function LogWarning(message: string): void;
|
||||
|
||||
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||
// Forces a reload by the main application as well as connected browsers.
|
||||
export function WindowReload(): void;
|
||||
|
||||
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||
// Reloads the application frontend.
|
||||
export function WindowReloadApp(): void;
|
||||
|
||||
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||
// Sets the window AlwaysOnTop or not on top.
|
||||
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||
|
||||
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||
// *Windows only*
|
||||
// Sets window theme to system default (dark/light).
|
||||
export function WindowSetSystemDefaultTheme(): void;
|
||||
|
||||
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||
// *Windows only*
|
||||
// Sets window to light theme.
|
||||
export function WindowSetLightTheme(): void;
|
||||
|
||||
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||
// *Windows only*
|
||||
// Sets window to dark theme.
|
||||
export function WindowSetDarkTheme(): void;
|
||||
|
||||
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||
// Centers the window on the monitor the window is currently on.
|
||||
export function WindowCenter(): void;
|
||||
|
||||
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||
// Sets the text in the window title bar.
|
||||
export function WindowSetTitle(title: string): void;
|
||||
|
||||
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||
// Makes the window full screen.
|
||||
export function WindowFullscreen(): void;
|
||||
|
||||
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||
// Restores the previous window dimensions and position prior to full screen.
|
||||
export function WindowUnfullscreen(): void;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): void;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
export function WindowGetSize(): Promise<Size>;
|
||||
|
||||
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMaxSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMinSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||
// Sets the window position relative to the monitor the window is currently on.
|
||||
export function WindowSetPosition(x: number, y: number): void;
|
||||
|
||||
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||
// Gets the window position relative to the monitor the window is currently on.
|
||||
export function WindowGetPosition(): Promise<Position>;
|
||||
|
||||
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||
// Hides the window.
|
||||
export function WindowHide(): void;
|
||||
|
||||
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||
// Shows the window, if it is currently hidden.
|
||||
export function WindowShow(): void;
|
||||
|
||||
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||
// Maximises the window to fill the screen.
|
||||
export function WindowMaximise(): void;
|
||||
|
||||
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||
// Toggles between Maximised and UnMaximised.
|
||||
export function WindowToggleMaximise(): void;
|
||||
|
||||
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||
// Restores the window to the dimensions and position prior to maximising.
|
||||
export function WindowUnmaximise(): void;
|
||||
|
||||
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||
// Minimises the window.
|
||||
export function WindowMinimise(): void;
|
||||
|
||||
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||
// Restores the window to the dimensions and position prior to minimising.
|
||||
export function WindowUnminimise(): void;
|
||||
|
||||
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||
|
||||
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||
export function ScreenGetAll(): Promise<Screen[]>;
|
||||
|
||||
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||
// Opens the given URL in the system browser.
|
||||
export function BrowserOpenURL(url: string): void;
|
||||
|
||||
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||
// Returns information about the environment
|
||||
export function Environment(): Promise<EnvironmentInfo>;
|
||||
|
||||
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||
// Quits the application.
|
||||
export function Quit(): void;
|
||||
|
||||
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||
// Hides the application.
|
||||
export function Hide(): void;
|
||||
|
||||
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||
// Shows the application.
|
||||
export function Show(): void;
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export function LogPrint(message) {
|
||||
window.runtime.LogPrint(message);
|
||||
}
|
||||
|
||||
export function LogTrace(message) {
|
||||
window.runtime.LogTrace(message);
|
||||
}
|
||||
|
||||
export function LogDebug(message) {
|
||||
window.runtime.LogDebug(message);
|
||||
}
|
||||
|
||||
export function LogInfo(message) {
|
||||
window.runtime.LogInfo(message);
|
||||
}
|
||||
|
||||
export function LogWarning(message) {
|
||||
window.runtime.LogWarning(message);
|
||||
}
|
||||
|
||||
export function LogError(message) {
|
||||
window.runtime.LogError(message);
|
||||
}
|
||||
|
||||
export function LogFatal(message) {
|
||||
window.runtime.LogFatal(message);
|
||||
}
|
||||
|
||||
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||
window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||
}
|
||||
|
||||
export function EventsOn(eventName, callback) {
|
||||
EventsOnMultiple(eventName, callback, -1);
|
||||
}
|
||||
|
||||
export function EventsOff(eventName) {
|
||||
return window.runtime.EventsOff(eventName);
|
||||
}
|
||||
|
||||
export function EventsOffAll() {
|
||||
return window.runtime.EventsOffAll();
|
||||
}
|
||||
|
||||
export function EventsOnce(eventName, callback) {
|
||||
EventsOnMultiple(eventName, callback, 1);
|
||||
}
|
||||
|
||||
export function EventsEmit(eventName) {
|
||||
let args = [eventName].slice.call(arguments);
|
||||
return window.runtime.EventsEmit.apply(null, args);
|
||||
}
|
||||
|
||||
export function WindowReload() {
|
||||
window.runtime.WindowReload();
|
||||
}
|
||||
|
||||
export function WindowReloadApp() {
|
||||
window.runtime.WindowReloadApp();
|
||||
}
|
||||
|
||||
export function WindowSetAlwaysOnTop(b) {
|
||||
window.runtime.WindowSetAlwaysOnTop(b);
|
||||
}
|
||||
|
||||
export function WindowSetSystemDefaultTheme() {
|
||||
window.runtime.WindowSetSystemDefaultTheme();
|
||||
}
|
||||
|
||||
export function WindowSetLightTheme() {
|
||||
window.runtime.WindowSetLightTheme();
|
||||
}
|
||||
|
||||
export function WindowSetDarkTheme() {
|
||||
window.runtime.WindowSetDarkTheme();
|
||||
}
|
||||
|
||||
export function WindowCenter() {
|
||||
window.runtime.WindowCenter();
|
||||
}
|
||||
|
||||
export function WindowSetTitle(title) {
|
||||
window.runtime.WindowSetTitle(title);
|
||||
}
|
||||
|
||||
export function WindowFullscreen() {
|
||||
window.runtime.WindowFullscreen();
|
||||
}
|
||||
|
||||
export function WindowUnfullscreen() {
|
||||
window.runtime.WindowUnfullscreen();
|
||||
}
|
||||
|
||||
export function WindowGetSize() {
|
||||
return window.runtime.WindowGetSize();
|
||||
}
|
||||
|
||||
export function WindowSetSize(width, height) {
|
||||
window.runtime.WindowSetSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMaxSize(width, height) {
|
||||
window.runtime.WindowSetMaxSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMinSize(width, height) {
|
||||
window.runtime.WindowSetMinSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetPosition(x, y) {
|
||||
window.runtime.WindowSetPosition(x, y);
|
||||
}
|
||||
|
||||
export function WindowGetPosition() {
|
||||
return window.runtime.WindowGetPosition();
|
||||
}
|
||||
|
||||
export function WindowHide() {
|
||||
window.runtime.WindowHide();
|
||||
}
|
||||
|
||||
export function WindowShow() {
|
||||
window.runtime.WindowShow();
|
||||
}
|
||||
|
||||
export function WindowMaximise() {
|
||||
window.runtime.WindowMaximise();
|
||||
}
|
||||
|
||||
export function WindowToggleMaximise() {
|
||||
window.runtime.WindowToggleMaximise();
|
||||
}
|
||||
|
||||
export function WindowUnmaximise() {
|
||||
window.runtime.WindowUnmaximise();
|
||||
}
|
||||
|
||||
export function WindowMinimise() {
|
||||
window.runtime.WindowMinimise();
|
||||
}
|
||||
|
||||
export function WindowUnminimise() {
|
||||
window.runtime.WindowUnminimise();
|
||||
}
|
||||
|
||||
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||
}
|
||||
|
||||
export function ScreenGetAll() {
|
||||
return window.runtime.ScreenGetAll();
|
||||
}
|
||||
|
||||
export function BrowserOpenURL(url) {
|
||||
window.runtime.BrowserOpenURL(url);
|
||||
}
|
||||
|
||||
export function Environment() {
|
||||
return window.runtime.Environment();
|
||||
}
|
||||
|
||||
export function Quit() {
|
||||
window.runtime.Quit();
|
||||
}
|
||||
|
||||
export function Hide() {
|
||||
window.runtime.Hide();
|
||||
}
|
||||
|
||||
export function Show() {
|
||||
window.runtime.Show();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
)
|
||||
|
||||
// Run launches the Wails GUI. It blocks until the window is closed.
|
||||
//
|
||||
// Window size matches the React design (480×640) but is resizable so
|
||||
// users on smaller displays can shrink it. Title shows the version.
|
||||
func Run(version string) error {
|
||||
app := NewApp(version)
|
||||
|
||||
// Frameless = no native Windows chrome; the React Classic component
|
||||
// renders its own title bar (brand mark, version, theme toggle,
|
||||
// min/close icons) so we deliberately suppress the OS chrome to
|
||||
// avoid stacking two title bars.
|
||||
// The Classic React component renders a fixed 480×640 surface, so we
|
||||
// pin the host window to exactly the same. Allowing resize would
|
||||
// expose the bare Wails background colour around the React canvas
|
||||
// (the "blue strip on the side" issue from early testing).
|
||||
const w, h = 480, 640
|
||||
return wails.Run(&options.App{
|
||||
Title: "Drover-Go " + version,
|
||||
Width: w,
|
||||
Height: h,
|
||||
MinWidth: w,
|
||||
MinHeight: h,
|
||||
MaxWidth: w,
|
||||
MaxHeight: h,
|
||||
DisableResize: true,
|
||||
Frameless: true,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: Assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 28, G: 29, B: 32, A: 1}, // matches Classic dark bg
|
||||
OnStartup: app.Startup,
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
DisableFramelessWindowDecorations: false,
|
||||
},
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package procscan resolves process IDs via Toolhelp32.
|
||||
package procscan
|
||||
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
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{
|
||||
// 1. Domain rule for sniffed SNI (works when sniffing
|
||||
// catches the ClientHello before route decision).
|
||||
map[string]any{
|
||||
"domain_suffix": []string{
|
||||
"discord.com",
|
||||
"discord.gg",
|
||||
"discord.media",
|
||||
"discordapp.com",
|
||||
"discordapp.net",
|
||||
"discord.dev",
|
||||
},
|
||||
"outbound": "upstream",
|
||||
},
|
||||
// 2. IP-CIDR fallback — sing-box on Windows TUN
|
||||
// sometimes misattributes the source process for
|
||||
// Discord's in-process Rust updater (gets attributed
|
||||
// to steam.exe or similar), so even with the right
|
||||
// process_name list the updater's TLS connection to
|
||||
// updates.discord.com (Fastly: 199.232.x.x) goes
|
||||
// direct and gets RKN-blocked. Force the major
|
||||
// Discord/Cloudflare/Fastly ranges through upstream
|
||||
// regardless of which process the kernel claims sent
|
||||
// them.
|
||||
map[string]any{
|
||||
"ip_cidr": []string{
|
||||
// Fastly (updates.discord.com)
|
||||
"151.101.0.0/16",
|
||||
"199.232.0.0/16",
|
||||
"185.199.108.0/22",
|
||||
// Cloudflare (Discord gateway, CDN, media)
|
||||
"162.158.0.0/15",
|
||||
"162.159.0.0/16",
|
||||
"104.16.0.0/13",
|
||||
"104.24.0.0/14",
|
||||
"172.64.0.0/13",
|
||||
"131.0.72.0/22",
|
||||
},
|
||||
"outbound": "upstream",
|
||||
},
|
||||
// 3. Process-name rule — covers Discord traffic to
|
||||
// non-Cloudflare destinations (RTC voice, etc).
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package sboxrun manages an embedded sing-box subprocess that
|
||||
// implements the actual proxy engine (TUN inbound + per-process
|
||||
// routing rule + SOCKS5 outbound).
|
||||
//
|
||||
// On first Start, the package extracts sing-box.exe + wintun.dll from
|
||||
// embedded bytes into %PROGRAMDATA%\Drover\sboxrun\ (SHA256-verified),
|
||||
// generates a JSON config from the GUI's proxy form, and launches
|
||||
// sing-box as a child process. Stop kills the child cleanly.
|
||||
package sboxrun
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed assets/sing-box.exe
|
||||
var singBoxExe []byte
|
||||
|
||||
//go:embed assets/wintun.dll
|
||||
var wintunDLL []byte
|
||||
|
||||
// SHA256 sentinels for the embedded binaries — verified after extract.
|
||||
// Update both when bumping versions:
|
||||
//
|
||||
// sing-box: https://github.com/SagerNet/sing-box/releases
|
||||
// wintun: https://www.wintun.net/
|
||||
const (
|
||||
// Pinned to 1.12.25 — last release on the 1.12 line that still
|
||||
// accepts the legacy TUN inbound config layout. 1.13.0 removed
|
||||
// `address` from inbound and requires migration to rule-based
|
||||
// `endpoints` — when our config generator gets updated to that
|
||||
// shape, we can move to 1.13.x.
|
||||
SingBoxVersion = "1.12.25"
|
||||
SingBoxSHA256 = "fc7b65219abe8a0166d0b4891a2f7cabcbcc13b3adcf89e6d5913743a67aba10"
|
||||
WintunVersion = "0.14.1"
|
||||
WintunSHA256 = "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce"
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build windows
|
||||
|
||||
package sboxrun
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AssetPaths records where the binaries landed after install.
|
||||
type AssetPaths struct {
|
||||
SingBoxExe string
|
||||
WintunDLL string
|
||||
WorkDir string // %PROGRAMDATA%\Drover\sboxrun
|
||||
ConfigPath string // <workdir>\config.json
|
||||
LogPath string // <workdir>\sing-box.log
|
||||
}
|
||||
|
||||
// InstallAssets extracts sing-box.exe + wintun.dll into
|
||||
// %PROGRAMDATA%\Drover\sboxrun\ (creating the directory if needed)
|
||||
// and verifies SHA256. Idempotent — second runs skip if existing
|
||||
// files match the embedded SHAs.
|
||||
func InstallAssets() (*AssetPaths, error) {
|
||||
pd := os.Getenv("ProgramData")
|
||||
if pd == "" {
|
||||
return nil, fmt.Errorf("ProgramData environment variable is not set")
|
||||
}
|
||||
dir := filepath.Join(pd, "Drover", "sboxrun")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", dir, err)
|
||||
}
|
||||
|
||||
exePath := filepath.Join(dir, "sing-box.exe")
|
||||
dllPath := filepath.Join(dir, "wintun.dll")
|
||||
|
||||
if err := writeIfDifferent(exePath, singBoxExe, SingBoxSHA256); err != nil {
|
||||
return nil, fmt.Errorf("install sing-box.exe: %w", err)
|
||||
}
|
||||
if err := writeIfDifferent(dllPath, wintunDLL, WintunSHA256); err != nil {
|
||||
return nil, fmt.Errorf("install wintun.dll: %w", err)
|
||||
}
|
||||
|
||||
return &AssetPaths{
|
||||
SingBoxExe: exePath,
|
||||
WintunDLL: dllPath,
|
||||
WorkDir: dir,
|
||||
ConfigPath: filepath.Join(dir, "config.json"),
|
||||
LogPath: filepath.Join(dir, "sing-box.log"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeIfDifferent(path string, content []byte, expectedSHA string) error {
|
||||
if existing, err := os.ReadFile(path); err == nil {
|
||||
if strings.EqualFold(sha256Hex(existing), expectedSHA) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, content, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(sha256Hex(got), expectedSHA) {
|
||||
return fmt.Errorf("SHA256 mismatch after write at %s; antivirus may have tampered with the file. Add %%PROGRAMDATA%%\\Drover\\ to AV exclusions", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build !windows
|
||||
|
||||
package sboxrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Status — duplicate of the Windows-side enum so call sites compile.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusIdle Status = "idle"
|
||||
StatusStarting Status = "starting"
|
||||
StatusActive Status = "active"
|
||||
StatusFailed Status = "failed"
|
||||
)
|
||||
|
||||
// Engine stub for non-Windows builds.
|
||||
type Engine struct{}
|
||||
|
||||
// New returns an error on non-Windows: sing-box + wintun + WFP-based
|
||||
// per-process routing only make sense on Windows.
|
||||
func New(_ Config) (*Engine, error) {
|
||||
return nil, errors.New("sboxrun is Windows-only")
|
||||
}
|
||||
|
||||
func (e *Engine) Start(_ context.Context) error { return errors.New("sboxrun is Windows-only") }
|
||||
func (e *Engine) Stop() error { return nil }
|
||||
func (e *Engine) Status() Status { return StatusIdle }
|
||||
func (e *Engine) LastError() error { return nil }
|
||||
func (e *Engine) LogPath() string { return "" }
|
||||
func (e *Engine) ConfigPath() string { return "" }
|
||||
|
||||
// AssetPaths stub.
|
||||
type AssetPaths struct {
|
||||
SingBoxExe string
|
||||
WintunDLL string
|
||||
WorkDir string
|
||||
ConfigPath string
|
||||
LogPath string
|
||||
}
|
||||
|
||||
// InstallAssets stub.
|
||||
func InstallAssets() (*AssetPaths, error) {
|
||||
return nil, errors.New("sboxrun is Windows-only")
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package service installs the Windows service and exposes the IPC named pipe.
|
||||
package service
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package socks5 implements a SOCKS5 client (CONNECT + UDP ASSOCIATE, RFC 1928 + 1929).
|
||||
package socks5
|
||||
@@ -1,2 +0,0 @@
|
||||
// Package tray manages the system tray icon.
|
||||
package tray
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
echo "[$(date +%H:%M:%S)] frontend..."
|
||||
(cd internal/gui/frontend && npm run build 2>&1 | tail -3)
|
||||
echo "[$(date +%H:%M:%S)] go build..."
|
||||
CGO_ENABLED=0 go build -trimpath -tags "desktop,production" \
|
||||
-ldflags "-s -w -H=windowsgui -X main.Version=99.99.99-test" \
|
||||
-o drover-test.exe ./cmd/drover
|
||||
echo "[$(date +%H:%M:%S)] OK $(stat -c %s drover-test.exe) bytes"
|
||||
Reference in New Issue
Block a user