Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
# flashes a console window. main.go calls AttachConsole on
|
||||||
# startup so CLI invocations from cmd/PowerShell still print
|
# startup so CLI invocations from cmd/PowerShell still print
|
||||||
# to the parent terminal.
|
# 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.Version=dev-${SHORT_SHA} \
|
||||||
-X main.Commit=${SHORT_SHA} \
|
-X main.Commit=${SHORT_SHA} \
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ jobs:
|
|||||||
# double-click experience doesn't flash a console window. main.go
|
# double-click experience doesn't flash a console window. main.go
|
||||||
# calls AttachConsole on startup so CLI runs still print to the
|
# calls AttachConsole on startup so CLI runs still print to the
|
||||||
# parent terminal when launched from cmd/PowerShell.
|
# 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.Version=${{ steps.version.outputs.version }} \
|
||||||
-X main.Commit=${SHORT_SHA} \
|
-X main.Commit=${SHORT_SHA} \
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
|
|||||||
+4
-3
@@ -8,10 +8,11 @@
|
|||||||
*.out
|
*.out
|
||||||
|
|
||||||
# Wails
|
# Wails
|
||||||
/internal/frontend/node_modules/
|
/internal/gui/frontend/node_modules/
|
||||||
/internal/frontend/dist/
|
/internal/gui/frontend/dist/
|
||||||
/internal/frontend/wailsjs/
|
|
||||||
.wails/
|
.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
|
# IDE
|
||||||
/.idea/
|
/.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+7
-8
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"git.okcu.io/root/drover-go/internal/gui"
|
||||||
"git.okcu.io/root/drover-go/internal/updater"
|
"git.okcu.io/root/drover-go/internal/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,13 +47,12 @@ func newRootCmd() *cobra.Command {
|
|||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
// No subcommand and no flags = end-user double-clicked the exe.
|
// No subcommand and no flags = end-user double-clicked the exe.
|
||||||
// First do a quick update check (silent if no network or already
|
// First do a quick silent update check (no-op if offline or
|
||||||
// current); if an update is available we prompt, apply, and
|
// already current); if an update is available we apply it and
|
||||||
// re-launch ourselves. Then show the smoke-test window.
|
// re-launch ourselves. Then we open the Wails-backed GUI.
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
autoUpdateOnStartup()
|
autoUpdateOnStartup()
|
||||||
showTestWindow()
|
return gui.Run(Version)
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,10 +72,9 @@ func newRootCmd() *cobra.Command {
|
|||||||
func newGUICmd() *cobra.Command {
|
func newGUICmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "gui",
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
showTestWindow()
|
return gui.Run(Version)
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -5,13 +5,44 @@ go 1.23
|
|||||||
require (
|
require (
|
||||||
github.com/minio/selfupdate v0.6.0
|
github.com/minio/selfupdate v0.6.0
|
||||||
github.com/spf13/cobra v1.10.2
|
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 (
|
require (
|
||||||
aead.dev/minisign v0.2.0 // indirect
|
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/inconshreveable/mousetrap v1.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
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // 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,116 @@
|
|||||||
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
||||||
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
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/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
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/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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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=
|
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-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-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/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/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
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-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.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-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-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-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-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-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-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-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
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=
|
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 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=
|
||||||
|
|||||||
@@ -0,0 +1,824 @@
|
|||||||
|
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.runVoiceQuality()
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
e.udpRelay = relay
|
||||||
|
return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil
|
||||||
|
})
|
||||||
|
e.udpOK = ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVoiceQuality — Test 6: 30-packet STUN burst through the SOCKS5 UDP
|
||||||
|
// relay. Computes loss, jitter, p50/p95 RTT and gates on thresholds:
|
||||||
|
//
|
||||||
|
// - StatusPassed: loss ≤ 5%, jitter ≤ 30ms, p50 ≤ 250ms.
|
||||||
|
// - StatusWarn: loss ≤ 15%, jitter ≤ 60ms, p50 ≤ 400ms — voice will
|
||||||
|
// work but with audible glitches.
|
||||||
|
// - StatusFailed: anything worse, OR no replies at all.
|
||||||
|
//
|
||||||
|
// On warn/pass, voiceQualityOK is true (downstream tests proceed). On
|
||||||
|
// failure it stays false.
|
||||||
|
func (e *executor) runVoiceQuality() {
|
||||||
|
if e.shouldSkip("voice-quality", e.udpOK) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host, portStr, splitErr := net.SplitHostPort(e.cfg.StunServer)
|
||||||
|
if splitErr != nil {
|
||||||
|
e.emit(Result{
|
||||||
|
ID: "voice-quality",
|
||||||
|
Status: StatusFailed,
|
||||||
|
Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()),
|
||||||
|
Hint: hintFor("voice-quality", splitErr),
|
||||||
|
Attempt: 1,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port64, perr := strconv.ParseUint(portStr, 10, 16)
|
||||||
|
if perr != nil {
|
||||||
|
e.emit(Result{
|
||||||
|
ID: "voice-quality",
|
||||||
|
Status: StatusFailed,
|
||||||
|
Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()),
|
||||||
|
Hint: hintFor("voice-quality", perr),
|
||||||
|
Attempt: 1,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stunPort := uint16(port64)
|
||||||
|
|
||||||
|
maxAttempts := 1 + e.cfg.MaxRetries
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
if err := e.ctx.Err(); err != nil {
|
||||||
|
e.emitCancelled("voice-quality", attempt, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.emit(Result{ID: "voice-quality", Status: StatusRunning, Attempt: attempt})
|
||||||
|
|
||||||
|
// Per-test budget: cap burst+listen at PerTestTimeout.
|
||||||
|
attemptCtx, cancel := context.WithTimeout(e.ctx, e.cfg.PerTestTimeout)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Open a fresh local UDP socket per attempt.
|
||||||
|
if e.udpClient != nil {
|
||||||
|
_ = e.udpClient.Close()
|
||||||
|
e.udpClient = nil
|
||||||
|
}
|
||||||
|
pc, perr := net.ListenPacket("udp", ":0")
|
||||||
|
if perr != nil {
|
||||||
|
cancel()
|
||||||
|
dur := time.Since(start)
|
||||||
|
class := classifyError(perr)
|
||||||
|
canRetry := class == ClassificationTransient && attempt < maxAttempts
|
||||||
|
e.emit(Result{
|
||||||
|
ID: "voice-quality",
|
||||||
|
Status: StatusFailed,
|
||||||
|
Error: fmt.Sprintf("voice-quality: listen udp: %s", perr.Error()),
|
||||||
|
Hint: hintFor("voice-quality", perr),
|
||||||
|
Attempt: attempt,
|
||||||
|
Duration: dur,
|
||||||
|
})
|
||||||
|
if canRetry {
|
||||||
|
select {
|
||||||
|
case <-time.After(e.cfg.RetryBackoff):
|
||||||
|
continue
|
||||||
|
case <-e.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.udpClient = pc
|
||||||
|
|
||||||
|
res, berr := runVoiceQualityBurst(
|
||||||
|
attemptCtx, pc, e.udpRelay,
|
||||||
|
host, stunPort,
|
||||||
|
e.cfg.VoiceBurstCount, e.cfg.VoiceBurstInterval,
|
||||||
|
)
|
||||||
|
dur := time.Since(start)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if berr != nil {
|
||||||
|
// Resolution / cancellation. Treat ctx-cancel separately.
|
||||||
|
if e.ctx.Err() != nil {
|
||||||
|
e.emitCancelled("voice-quality", attempt, dur)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
class := classifyError(berr)
|
||||||
|
canRetry := class == ClassificationTransient && attempt < maxAttempts
|
||||||
|
e.emit(Result{
|
||||||
|
ID: "voice-quality",
|
||||||
|
Status: StatusFailed,
|
||||||
|
Error: berr.Error(),
|
||||||
|
Hint: hintFor("voice-quality", berr),
|
||||||
|
Attempt: attempt,
|
||||||
|
Duration: dur,
|
||||||
|
})
|
||||||
|
if canRetry {
|
||||||
|
select {
|
||||||
|
case <-time.After(e.cfg.RetryBackoff):
|
||||||
|
continue
|
||||||
|
case <-e.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 100% loss with no underlying error → the relay accepted UDP
|
||||||
|
// (per test 5) but nothing came back. Treat as transient on
|
||||||
|
// the first attempt; permanent on the second.
|
||||||
|
if res.Received == 0 {
|
||||||
|
canRetry := attempt < maxAttempts
|
||||||
|
e.emit(Result{
|
||||||
|
ID: "voice-quality",
|
||||||
|
Status: StatusFailed,
|
||||||
|
Error: "no replies received",
|
||||||
|
Hint: voiceQualityFailHint(100.0, 0, 0, 0),
|
||||||
|
Metric: "loss=100%",
|
||||||
|
Attempt: attempt,
|
||||||
|
Duration: dur,
|
||||||
|
})
|
||||||
|
if canRetry {
|
||||||
|
select {
|
||||||
|
case <-time.After(e.cfg.RetryBackoff):
|
||||||
|
continue
|
||||||
|
case <-e.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metric := fmt.Sprintf("loss=%.0f%% jitter=%.1fms p50=%.1fms",
|
||||||
|
res.LossPct, res.JitterMS, res.P50RTTMS)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case res.LossPct <= 5.0 && res.JitterMS <= 30.0 && res.P50RTTMS <= 250.0:
|
||||||
|
e.emit(Result{
|
||||||
|
ID: "voice-quality",
|
||||||
|
Status: StatusPassed,
|
||||||
|
Metric: metric,
|
||||||
|
Attempt: attempt,
|
||||||
|
Duration: dur,
|
||||||
|
})
|
||||||
|
e.voiceQualityOK = true
|
||||||
|
return
|
||||||
|
case res.LossPct <= 15.0 && res.JitterMS <= 60.0 && res.P50RTTMS <= 400.0:
|
||||||
|
e.emit(Result{
|
||||||
|
ID: "voice-quality",
|
||||||
|
Status: StatusWarn,
|
||||||
|
Metric: metric,
|
||||||
|
Hint: voiceQualityWarnHint(res.LossPct, res.JitterMS, res.P50RTTMS),
|
||||||
|
Attempt: attempt,
|
||||||
|
Duration: dur,
|
||||||
|
})
|
||||||
|
e.voiceQualityOK = true
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
canRetry := attempt < maxAttempts
|
||||||
|
e.emit(Result{
|
||||||
|
ID: "voice-quality",
|
||||||
|
Status: StatusFailed,
|
||||||
|
Error: metric,
|
||||||
|
Metric: metric,
|
||||||
|
Hint: voiceQualityFailHint(res.LossPct, res.JitterMS, res.P50RTTMS, res.P95RTTMS),
|
||||||
|
Attempt: attempt,
|
||||||
|
Duration: dur,
|
||||||
|
})
|
||||||
|
if canRetry {
|
||||||
|
select {
|
||||||
|
case <-time.After(e.cfg.RetryBackoff):
|
||||||
|
continue
|
||||||
|
case <-e.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAPI — Test 7: 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,955 @@
|
|||||||
|
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", "voice-quality", "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.Contains(t, finals["voice-quality"].Metric, "loss=")
|
||||||
|
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", "voice-quality", "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", "voice-quality", "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", "voice-quality", "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")
|
||||||
|
|
||||||
|
// voice-quality depends on udp → passes too.
|
||||||
|
rVQ, _ := finalByID(results, "voice-quality")
|
||||||
|
assert.Equal(t, StatusPassed, rVQ.Status)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// voice-quality depends on udp → skipped.
|
||||||
|
rVQ, _ := finalByID(results, "voice-quality")
|
||||||
|
assert.Equal(t, StatusSkipped, rVQ.Status)
|
||||||
|
|
||||||
|
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", "voice-quality", "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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_VoiceQualityWarn drives the relay to drop ~1 in 10 packets,
|
||||||
|
// which puts the burst into the warn band (loss in (5, 15]%, jitter and
|
||||||
|
// p50 typically tiny on localhost). Asserts StatusWarn and that the
|
||||||
|
// metric reports a non-zero loss.
|
||||||
|
func TestRun_VoiceQualityWarn(t *testing.T) {
|
||||||
|
fp := newFakeProxy(t, "voice_quality_warn")
|
||||||
|
cfg := proxyConfig(fp, false)
|
||||||
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||||
|
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||||
|
cfg.StunServer = "127.0.0.1:65000"
|
||||||
|
// Burst of 30 with 1-in-10 drop → ~3 lost ≈ 10%.
|
||||||
|
cfg.VoiceBurstCount = 30
|
||||||
|
cfg.VoiceBurstInterval = 5 * time.Millisecond
|
||||||
|
cfg.PerTestTimeout = 1 * time.Second
|
||||||
|
fp.udpDropEveryN.Store(10)
|
||||||
|
|
||||||
|
ch := Run(context.Background(), cfg)
|
||||||
|
results := drainResults(t, ch, 15*time.Second)
|
||||||
|
|
||||||
|
rVQ, ok := finalByID(results, "voice-quality")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, StatusWarn, rVQ.Status, "got %+v", rVQ)
|
||||||
|
assert.Contains(t, rVQ.Metric, "loss=")
|
||||||
|
assert.NotEmpty(t, rVQ.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_VoiceQualityFail drives the relay to drop 4 of every 5 packets
|
||||||
|
// (~80% loss) — well past the fail threshold.
|
||||||
|
func TestRun_VoiceQualityFail(t *testing.T) {
|
||||||
|
fp := newFakeProxy(t, "voice_quality_fail")
|
||||||
|
cfg := proxyConfig(fp, false)
|
||||||
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||||
|
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||||
|
cfg.StunServer = "127.0.0.1:65000"
|
||||||
|
cfg.VoiceBurstCount = 30
|
||||||
|
cfg.VoiceBurstInterval = 3 * time.Millisecond
|
||||||
|
cfg.PerTestTimeout = 1 * time.Second
|
||||||
|
cfg.MaxRetries = 0
|
||||||
|
// Drop everything: dropEveryN=1 means EVERY packet dropped → 100%.
|
||||||
|
// Use 2 for ~50%, 1 for 100. We want fail-band — pick 1 to guarantee
|
||||||
|
// "no replies received".
|
||||||
|
fp.udpDropEveryN.Store(1)
|
||||||
|
|
||||||
|
ch := Run(context.Background(), cfg)
|
||||||
|
results := drainResults(t, ch, 15*time.Second)
|
||||||
|
|
||||||
|
rVQ, ok := finalByID(results, "voice-quality")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, StatusFailed, rVQ.Status, "got %+v", rVQ)
|
||||||
|
assert.NotEmpty(t, rVQ.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// 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"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.okcu.io/root/drover-go/internal/checker"
|
||||||
|
"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
|
||||||
|
running bool
|
||||||
|
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 flips the proxy on. In the stub we just toggle the flag and
|
||||||
|
// note the start time so GetStats can produce a believable uptime.
|
||||||
|
func (a *App) StartEngine() error {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.running = true
|
||||||
|
a.startedAt = time.Now()
|
||||||
|
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopEngine turns the proxy off.
|
||||||
|
func (a *App) StopEngine() error {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.running = false
|
||||||
|
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus is read by the frontend on first paint to know whether to
|
||||||
|
// show "Idle" or "Active".
|
||||||
|
func (a *App) GetStatus() map[string]any {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return map[string]any{
|
||||||
|
"running": a.running,
|
||||||
|
"uptimeS": int(time.Since(a.startedAt).Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsLoop emits a stats event every second when the engine is running.
|
||||||
|
// Numbers are random but stable enough to look real.
|
||||||
|
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.running || 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": r.Intn(5) + 1,
|
||||||
|
"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,384 @@
|
|||||||
|
// 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: 'voice-quality', label: 'UDP voice quality', desc: 'Бёрст 30 STUN-пакетов через релей: потери, джиттер, латентность' },
|
||||||
|
{ 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;
|
||||||
|
await StartEngine();
|
||||||
|
// 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() { return window['go']['gui']['App']['StartEngine']() }
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+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