From 8b2e028f997c306984fa16aa86ceb4d34858ccce Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Thu, 21 May 2026 20:14:45 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=83=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Критичное: - go.mod: версия 1.25.5→1.25.0 (go mod tidy); Dockerfile: тег 1.25 - release.yml: go-version 1.25.5 → stable - удалён temp-файл types.go.tmp.18500.1c9f4278a15a - udprelay/loop.go: defer conn1/conn2.Close() при сбое Dial; лог write-error Безопасность/код: - browserprofile: пермиссии 0644→0600 - bondserver: once.Do перед go readLane (ordering fix) - cmd/server: err= → err:= (shadow fix в AfterFunc) - vkauth/client: vkDelayRandom ctx-aware (возвращает error) - captcha/manual: whitelist хостов в /generic_proxy (SSRF mitigation) Инфра: - .dockerignore: исключить .git/.github/notes/dist из build context - docker-compose.yml: env_file .env для OBF_KEY - entrypoint.sh: позиционные аргументы вместо unquoted vars; валидация host:port CI: - ci.yml: исправлен путь go module cache (~/go/pkg/mod); добавлен -race - .gitignore: добавлены /dist/, *.test, coverage.out, .env, *.tmp - .golangci.yml: добавлены gosec и staticcheck - routes-macos.sh: set -eu вместо set -u Код: - bondclient/bondserver: errors.Is(err, io.EOF) вместо err != io.EOF - cmd/client: константа dtlsHandshakeConcurrency для HandshakeSem Доки: - quickstart.md: добавлен Intel Mac (darwin-amd64) - build.md: версия Go, Windows/macOS cross-compile инструкции - flags.md: уточнено описание -bond --- .dockerignore | 11 ++++++++ .github/workflows/ci.yml | 5 ++-- .github/workflows/release.yml | 2 +- .gitignore | 6 +++++ .golangci.yml | 2 ++ cmd/client/main.go | 6 +++-- cmd/server/main.go | 2 +- docker-compose.yml | 7 ++++- docker/entrypoint.sh | 20 +++++++------- docs/build.md | 32 ++++++++++++++++++++++- docs/flags.md | 2 +- docs/quickstart.md | 10 ++++++- go.mod | 2 +- internal/client/browserprofile/profile.go | 2 +- internal/client/captcha/manual/manual.go | 18 +++++++++++++ internal/client/vkauth/client.go | 9 +++++-- internal/client/vkauth/token.go | 16 +++++++++--- internal/proxy/bondclient/bondclient.go | 5 ++-- internal/proxy/bondserver/bondserver.go | 4 +-- internal/proxy/udprelay/loop.go | 3 +++ scripts/routes-macos.sh | 2 +- 21 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..68fd9f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +.idea +.claude +notes +scripts +docs +*.md +dist +coverage.out +*.test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04568dd..ca94840 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,7 @@ jobs: with: path: | ~/.cache/go-build - # Module cache - ${{ github.workspace }}/pkg/mod + ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- @@ -54,7 +53,7 @@ jobs: - name: Run tests run: | - go test ./... -v + go test -race ./... -v build: name: Build binaries diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fdd728c..9d4c0e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.5" + go-version: "stable" # Install Android NDK only for android rows - name: Setup Android NDK diff --git a/.gitignore b/.gitignore index 549a3e6..f761e67 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ .claude *.exe *.so +*.tmp *.tmp.* +/dist/ +/build/ +*.test +coverage.out +.env diff --git a/.golangci.yml b/.golangci.yml index 5be2c47..11aedf0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -13,6 +13,8 @@ linters: - unparam - gocritic - prealloc + - gosec + - staticcheck settings: errcheck: check-type-assertions: true diff --git a/cmd/client/main.go b/cmd/client/main.go index eb36c06..49f95df 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -25,6 +25,8 @@ import ( "github.com/samosvalishe/btp/internal/wire/srtpmimicry" ) +const dtlsHandshakeConcurrency = 3 + // manualCaptchaSolver связывает контракт vkauth.ManualSolveFunc // с локальным captcha-обработчиком (internal/client/captcha/manual). func manualCaptchaSolver(ctx context.Context, e *captcha.Error, d net.Dialer) (string, string, error) { @@ -107,7 +109,7 @@ func main() { if cfg.Proxy.Mode != config.ProxyModeUDP { tcpDtlsDialer := &dtlsdial.Dialer{ HandshakeTimeout: 30 * time.Second, - HandshakeSem: make(chan struct{}, 3), + HandshakeSem: make(chan struct{}, dtlsHandshakeConcurrency), } bondH := &bondclient.Handler{Deps: bondclient.Deps{Log: logger}} tcpDeps := &tcpfwd.Deps{ @@ -134,7 +136,7 @@ func main() { udpDtlsDialer := &dtlsdial.Dialer{ HandshakeTimeout: 20 * time.Second, - HandshakeSem: make(chan struct{}, 3), + HandshakeSem: make(chan struct{}, dtlsHandshakeConcurrency), } udpParams := &udprelay.Params{ Host: cfg.TURN.Host, diff --git a/cmd/server/main.go b/cmd/server/main.go index ad657d9..ff02b9a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -96,7 +96,7 @@ func main() { os.Exit(1) } context.AfterFunc(ctx, func() { - if err = listener.Close(); err != nil { + if err := listener.Close(); err != nil { logger.Errorf("listener close: %v", err) } }) diff --git a/docker-compose.yml b/docker-compose.yml index 777bfeb..ef1bc34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,16 @@ services: # build: . network_mode: host restart: unless-stopped + # Секретные переменные (OBF_KEY) храните в .env рядом с этим файлом, + # а не здесь. Пример: echo "OBF_KEY=<64hex>" >> .env + env_file: + - path: .env + required: false environment: # Backend address: WireGuard → 127.0.0.1:51820 | Xray/sing-box → 127.0.0.1:443 CONNECT_ADDR: "127.0.0.1:51820" # LISTEN_ADDR: "0.0.0.0:56000" # MODE: "tcp" # tcp = TCP-форвардер для Xray/sing-box; default udp = WireGuard # OBF_MODE: "true" - # OBF_KEY: "" # 64 hex chars; required when OBF_MODE=true + # OBF_KEY задаётся в .env (не коммитьте ключ в git) # DEBUG: "false" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 5d581ed..0d2a9a9 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -4,22 +4,24 @@ set -e CONNECT="${CONNECT_ADDR:?CONNECT_ADDR is required}" LISTEN="${LISTEN_ADDR:-0.0.0.0:56000}" -MODE_FLAG="" +case "$CONNECT" in + *:*) ;; + *) echo "CONNECT_ADDR must be in host:port format" >&2; exit 1 ;; +esac + +set -- -listen "$LISTEN" -connect "$CONNECT" + if [ "${MODE}" = "tcp" ]; then - MODE_FLAG="-mode tcp" + set -- "$@" -mode tcp fi -OBF_FLAG="" -OBF_KEY_FLAG="" if [ "${OBF_MODE}" = "true" ]; then OBF="${OBF_KEY:?OBF_KEY is required when OBF_MODE=true}" - OBF_FLAG="-obf" - OBF_KEY_FLAG="-obf-key $OBF" + set -- "$@" -obf -obf-key "$OBF" fi -DEBUG_FLAG="" if [ "${DEBUG}" = "true" ]; then - DEBUG_FLAG="-debug" + set -- "$@" -debug fi -exec ./server -listen "$LISTEN" -connect "$CONNECT" $MODE_FLAG $OBF_FLAG $OBF_KEY_FLAG $DEBUG_FLAG +exec ./server "$@" diff --git a/docs/build.md b/docs/build.md index 7ca9fb8..47387cd 100644 --- a/docs/build.md +++ b/docs/build.md @@ -1,6 +1,6 @@ # Сборка -Нужен Go 1.25.x. +Нужен Go 1.24.x. ```bash go build -o client ./cmd/client @@ -44,3 +44,33 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -checklinkname=0" ``` > Все `.so` выше — обычные ELF под `.so`-именем (`CGO_ENABLED=0`, не настоящий shared library). Настоящий `c-shared` — `CGO_ENABLED=1` + NDK toolchain (см. `.github/workflows/release.yml`). + +**Windows amd64:** + +```powershell +$env:GOOS="windows"; $env:GOARCH="amd64"; $env:CGO_ENABLED="0" +go build -ldflags="-s -w -checklinkname=0" -trimpath -o client-windows-amd64.exe ./cmd/client +go build -ldflags="-s -w -checklinkname=0" -trimpath -o server-windows-amd64.exe ./cmd/server +``` + +**Windows arm64:** + +```powershell +$env:GOOS="windows"; $env:GOARCH="arm64"; $env:CGO_ENABLED="0" +go build -ldflags="-s -w -checklinkname=0" -trimpath -o client-windows-arm64.exe ./cmd/client +go build -ldflags="-s -w -checklinkname=0" -trimpath -o server-windows-arm64.exe ./cmd/server +``` + +**macOS amd64 (Intel):** + +```bash +GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o client-darwin-amd64 ./cmd/client +GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o server-darwin-amd64 ./cmd/server +``` + +**macOS arm64 (Apple Silicon):** + +```bash +GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o client-darwin-arm64 ./cmd/client +GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o server-darwin-arm64 ./cmd/server +``` diff --git a/docs/flags.md b/docs/flags.md index 7cc167a..e5bce53 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -10,7 +10,7 @@ | `-n` | `10` | параллельных TURN-потоков | | `-transport` | `tcp` | транспорт до TURN-реле: `tcp` (TCP/TLS) \| `udp` | | `-mode` | `udp` | режим туннеля: `udp` (UDP-релей для WireGuard) \| `tcp` (TCP-форвардер для Xray/sing-box) | -| `-bond` | `false` | распределять TCP-соединение по smux-сессиям (только с `-mode tcp`) | +| `-bond` | `false` | распределять одно TCP-соединение по всем активным smux-сессиям (только с `-mode tcp`) | | `-turn` | из ссылки | переопределить IP TURN-сервера | | `-port` | из ссылки | переопределить порт TURN-сервера | | `-obf` | `false` | SRTP-мимикрия payload | diff --git a/docs/quickstart.md b/docs/quickstart.md index 2548487..974fde1 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -41,7 +41,7 @@ Invoke-WebRequest -Uri https://github.com/samosvalishe/btp/releases/latest/downl .\client.exe -listen 127.0.0.1:9000 -peer :56000 -link "" -debug 2>&1 | .\scripts\routes.ps1 ``` -**macOS:** +**macOS (Apple Silicon / ARM):** ```bash curl -L -o client https://github.com/samosvalishe/btp/releases/latest/download/client-darwin-arm64 @@ -49,6 +49,14 @@ chmod +x client ./client -listen 127.0.0.1:9000 -peer :56000 -link "" -debug 2>&1 | ./scripts/routes-macos.sh ``` +**macOS (Intel / amd64):** + +```bash +curl -L -o client https://github.com/samosvalishe/btp/releases/latest/download/client-darwin-amd64 +chmod +x client +./client -listen 127.0.0.1:9000 -peer :56000 -link "" -debug 2>&1 | ./scripts/routes-macos.sh +``` + Когда скрипт напечатает `Ensuring route to ...` — включайте WireGuard. > Скачали только бинарник без репо? Возьмите скрипты отдельно: diff --git a/go.mod b/go.mod index 9936aee..bd2e55a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/samosvalishe/btp -go 1.25.5 +go 1.25.0 require ( github.com/bogdanfinn/fhttp v0.6.8 diff --git a/internal/client/browserprofile/profile.go b/internal/client/browserprofile/profile.go index 1bd5591..7942d22 100644 --- a/internal/client/browserprofile/profile.go +++ b/internal/client/browserprofile/profile.go @@ -39,7 +39,7 @@ func Save(sp Saved) error { if err != nil { return err } - return os.WriteFile(profileFile, data, 0644) + return os.WriteFile(profileFile, data, 0600) } func ApplyFhttp(req *fhttp.Request, profile Profile) { diff --git a/internal/client/captcha/manual/manual.go b/internal/client/captcha/manual/manual.go index c86df7d..d26e63a 100644 --- a/internal/client/captcha/manual/manual.go +++ b/internal/client/captcha/manual/manual.go @@ -65,6 +65,20 @@ func localCaptchaHosts() []string { } } +func isAllowedProxyHost(hostname string) bool { + allowed := []string{ + ".vk.com", ".vk.ru", ".vkontakte.ru", + ".userapi.com", ".okcdn.ru", ".mycdn.me", + ".api.vk.ru", + } + for _, suffix := range allowed { + if strings.HasSuffix(hostname, suffix) || hostname == suffix[1:] { + return true + } + } + return false +} + func isLocalCaptchaHost(host string) bool { for _, localHost := range localCaptchaHosts() { if strings.EqualFold(host, localHost) { @@ -790,6 +804,10 @@ func SolveViaProxy(ctx context.Context, redirectURI string, dialer net.Dialer) ( http.Error(w, "Bad URL", http.StatusBadRequest) return } + if !isAllowedProxyHost(targetParsed.Hostname()) { + http.Error(w, "Forbidden host", http.StatusForbidden) + return + } genericReverse := &httputil.ReverseProxy{ Transport: transport, Rewrite: func(req *httputil.ProxyRequest) { diff --git a/internal/client/vkauth/client.go b/internal/client/vkauth/client.go index 9c52030..9d5a14a 100644 --- a/internal/client/vkauth/client.go +++ b/internal/client/vkauth/client.go @@ -237,7 +237,12 @@ func (c *Client) fetch(ctx context.Context, link string, streamID int) (string, return "", "", nil, fmt.Errorf("all VK credentials failed: %w", lastErr) } -func vkDelayRandom(minMs, maxMs int) { +func vkDelayRandom(ctx context.Context, minMs, maxMs int) error { ms := minMs + rand.Intn(maxMs-minMs+1) - time.Sleep(time.Duration(ms) * time.Millisecond) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(ms) * time.Millisecond): + return nil + } } diff --git a/internal/client/vkauth/token.go b/internal/client/vkauth/token.go index f79edeb..ca0be8f 100644 --- a/internal/client/vkauth/token.go +++ b/internal/client/vkauth/token.go @@ -44,7 +44,9 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c return "", "", nil, err } - vkDelayRandom(100, 150) + if err := vkDelayRandom(ctx, 100, 150); err != nil { + return "", "", nil, err + } // Шаг 1a: прогрев getCallPreview (не критично). previewData := fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1) @@ -53,7 +55,9 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c c.log.Warnf("[STREAM %d] [VK Auth] getCallPreview failed: %v", streamID, prevErr) } - vkDelayRandom(200, 400) + if err := vkDelayRandom(ctx, 200, 400); err != nil { + return "", "", nil, err + } // Шаг 2: анонимный call-токен (здесь может сработать captcha). token2, err := c.fetchCallToken(ctx, httpClient, profile, streamID, link, escapedName, token1, creds) @@ -61,7 +65,9 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c return "", "", nil, err } - vkDelayRandom(100, 150) + if err := vkDelayRandom(ctx, 100, 150); err != nil { + return "", "", nil, err + } // Шаг 3: ok.ru session_key. sessionKey, err := c.fetchOkRuSession(ctx, httpClient, profile) @@ -69,7 +75,9 @@ func (c *Client) getTokenChain(ctx context.Context, link string, streamID int, c return "", "", nil, err } - vkDelayRandom(100, 150) + if err := vkDelayRandom(ctx, 100, 150); err != nil { + return "", "", nil, err + } // Шаг 4: TURN-реквизиты. return c.fetchTurnCreds(ctx, httpClient, profile, streamID, link, token2, sessionKey) diff --git a/internal/proxy/bondclient/bondclient.go b/internal/proxy/bondclient/bondclient.go index e25395a..e6e47e4 100644 --- a/internal/proxy/bondclient/bondclient.go +++ b/internal/proxy/bondclient/bondclient.go @@ -6,6 +6,7 @@ package bondclient import ( "context" + "errors" "fmt" "io" "net" @@ -129,7 +130,7 @@ func (h *Handler) Handle(ctx context.Context, tcpConn net.Conn, connID uint64, c select { case <-ctx.Done(): default: - if err != io.EOF { + if !errors.Is(err, io.EOF) { h.Deps.log().Debugf("[bond %d] session %d read frame error: %v", connID, l.ps.ID, err) } } @@ -178,7 +179,7 @@ func (h *Handler) copyTCPToBond(ctx context.Context, connID uint64, tcpConn net. seq++ } if err != nil { - if err != io.EOF { + if !errors.Is(err, io.EOF) { h.Deps.log().Debugf("[bond %d] local TCP read finished with error: %v", connID, err) } for _, l := range lanes { diff --git a/internal/proxy/bondserver/bondserver.go b/internal/proxy/bondserver/bondserver.go index 783a707..088cea4 100644 --- a/internal/proxy/bondserver/bondserver.go +++ b/internal/proxy/bondserver/bondserver.go @@ -151,10 +151,10 @@ func (c *serverConn) addLane(l *serverLane, laneCount uint16) { default: } - go c.readLane(l) c.once.Do(func() { go c.run() }) + go c.readLane(l) } func (c *serverConn) snapshotLanes() []*serverLane { @@ -207,7 +207,7 @@ func (c *serverConn) readLane(l *serverLane) { select { case <-c.ctx.Done(): default: - if err != io.EOF { + if !errors.Is(err, io.EOF) { c.deps.log().Debugf("[bond %d] lane %d read error: %v (lanes=%d)", c.id, l.index, err, left) } if left == 0 { diff --git a/internal/proxy/udprelay/loop.go b/internal/proxy/udprelay/loop.go index 8e86f02..586658a 100644 --- a/internal/proxy/udprelay/loop.go +++ b/internal/proxy/udprelay/loop.go @@ -127,6 +127,8 @@ func oneDTLS(ctx context.Context, deps *Deps, peer *net.UDPAddr, listenConn net. defer dtlscancel() conn1, conn2 := connutil.AsyncPacketPipe() + defer conn1.Close() + defer conn2.Close() // TURNLoop может перезапускать oneTURN несколько раз в рамках одного DTLS // соединения, каждый раз перечитывая conn2; публикуем до завершения DTLS. go func() { @@ -177,6 +179,7 @@ func oneDTLS(ctx context.Context, deps *Deps, peer *net.UDPAddr, listenConn net. _, werr := dtlsConn.Write(pkt.Data[:pkt.N]) packetPool.Put(pkt) if werr != nil { + deps.log().Debugf("[STREAM %d] DTLS write error: %v", streamID, werr) return } } diff --git a/scripts/routes-macos.sh b/scripts/routes-macos.sh index 31e5301..f1c199c 100644 --- a/scripts/routes-macos.sh +++ b/scripts/routes-macos.sh @@ -1,6 +1,6 @@ #!/bin/zsh # Usage: ./client -debug ... 2>&1 | ./scripts/routes-macos.sh -set -u +set -eu default_info="$(route -n get default 2>/dev/null || true)" default_if="$(printf '%s\n' "$default_info" | awk '/interface:/{print $2; exit}')"