fix: устранить замечания комплексной проверки

Критичное:
- 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
This commit is contained in:
samosvalishe
2026-05-21 20:14:45 +03:00
parent 6fb3b69b1c
commit 8b2e028f99
21 changed files with 133 additions and 33 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.github
.idea
.claude
notes
scripts
docs
*.md
dist
coverage.out
*.test
+2 -3
View File
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -2,4 +2,10 @@
.claude
*.exe
*.so
*.tmp
*.tmp.*
/dist/
/build/
*.test
coverage.out
.env
+2
View File
@@ -13,6 +13,8 @@ linters:
- unparam
- gocritic
- prealloc
- gosec
- staticcheck
settings:
errcheck:
check-type-assertions: true
+4 -2
View File
@@ -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,
+1 -1
View File
@@ -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)
}
})
+6 -1
View File
@@ -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"
+11 -9
View File
@@ -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 "$@"
+31 -1
View File
@@ -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
```
+1 -1
View File
@@ -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 |
+9 -1
View File
@@ -41,7 +41,7 @@ Invoke-WebRequest -Uri https://github.com/samosvalishe/btp/releases/latest/downl
.\client.exe -listen 127.0.0.1:9000 -peer <vps>:56000 -link "<vk-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 <vps>:56000 -link "<vk-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 <vps>:56000 -link "<vk-link>" -debug 2>&1 | ./scripts/routes-macos.sh
```
Когда скрипт напечатает `Ensuring route to ...` — включайте WireGuard.
> Скачали только бинарник без репо? Возьмите скрипты отдельно:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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) {
+18
View File
@@ -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) {
+7 -2
View File
@@ -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
}
}
+12 -4
View File
@@ -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)
+3 -2
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+3
View File
@@ -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
}
}
+1 -1
View File
@@ -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}')"