mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
feat: абстракция провайдера (vk + static)
Вытаскивает источник TURN-creds из захардкоженной привязки к VK в интерфейс internal/provider.Provider. Pipeline (proxy/udprelay, proxy/tcpfwd) работает только через интерфейс — без VK-specific импортов и sentinel'ов. Реализации: - internal/provider/vk: фасад над client/vkauth (требует -link) - internal/provider/static: фиксированные creds для self-hosted coturn / metered / любого RFC 5766/8656 TURN (флаги -static-user/-static-pass/ -static-addr) Изменения интерфейса: - common.GetCredsFunc дропнула link (provider держит сам) - udprelay AuthHandler: LockoutUntilUnix → BackoffUntilUnix - vkauth sentinels обёрнуты в provider.ErrBackoffActive / ErrFatalNoStreams через errors.Join (backward-compat сохранён) Документация: docs/providers.md (новый), docs/glossary.md и docs/flags.md обновлены.
This commit is contained in:
+45
-33
@@ -12,12 +12,12 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/btp/internal/client/captcha"
|
||||
manualcaptcha "github.com/samosvalishe/btp/internal/client/captcha/manual"
|
||||
"github.com/samosvalishe/btp/internal/client/dnsdial"
|
||||
"github.com/samosvalishe/btp/internal/client/vkauth"
|
||||
"github.com/samosvalishe/btp/internal/config"
|
||||
"github.com/samosvalishe/btp/internal/logx"
|
||||
"github.com/samosvalishe/btp/internal/provider"
|
||||
"github.com/samosvalishe/btp/internal/provider/static"
|
||||
"github.com/samosvalishe/btp/internal/provider/vk"
|
||||
"github.com/samosvalishe/btp/internal/proxy/bondclient"
|
||||
"github.com/samosvalishe/btp/internal/proxy/tcpfwd"
|
||||
"github.com/samosvalishe/btp/internal/proxy/udprelay"
|
||||
@@ -30,20 +30,6 @@ var version = "dev"
|
||||
|
||||
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) {
|
||||
if e.RedirectURI != "" {
|
||||
t, err := manualcaptcha.SolveViaProxy(ctx, e.RedirectURI, d)
|
||||
return t, "", err
|
||||
}
|
||||
if e.CaptchaImg != "" {
|
||||
k, err := manualcaptcha.SolveViaHTTP(ctx, e.CaptchaImg)
|
||||
return "", k, err
|
||||
}
|
||||
return "", "", fmt.Errorf("no redirect_uri or captcha_img")
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.ParseClient(os.Args[1:], os.Stderr)
|
||||
if err != nil {
|
||||
@@ -53,10 +39,8 @@ func main() {
|
||||
|
||||
logger := logx.New(cfg.Log.Debug)
|
||||
logger.Infof("btp client version=%s", version)
|
||||
captcha.SetLogger(logger)
|
||||
manualcaptcha.SetLogger(logger)
|
||||
vk.SetCaptchaLoggers(logger, cfg.Log.Debug)
|
||||
dnsdial.SetLogger(logger)
|
||||
manualcaptcha.Debug = cfg.Log.Debug
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -101,14 +85,20 @@ func main() {
|
||||
|
||||
var connectedStreams atomic.Int32
|
||||
|
||||
vkAuth := vkauth.New(vkauth.Config{
|
||||
Dialer: appDialer,
|
||||
ManualOnly: cfg.VK.ManualCaptcha,
|
||||
StreamsPerCache: cfg.VK.StreamsPerCred,
|
||||
StreamsAlive: connectedStreams.Load,
|
||||
ManualSolver: manualCaptchaSolver,
|
||||
Log: logger,
|
||||
})
|
||||
prov, err := buildProvider(cfg, appDialer, &connectedStreams, logger)
|
||||
if err != nil {
|
||||
logger.Errorf("provider init: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Infof("provider=%s", prov.Name())
|
||||
|
||||
getCreds := func(ctx context.Context, streamID int) (string, string, string, error) {
|
||||
c, err := prov.GetCredentials(ctx, streamID)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return c.User, c.Pass, c.ServerAddr, nil
|
||||
}
|
||||
|
||||
if cfg.Proxy.Mode != config.ProxyModeUDP {
|
||||
tcpDtlsDialer := &dtlsdial.Dialer{
|
||||
@@ -124,10 +114,9 @@ func main() {
|
||||
tcpParams := &tcpfwd.Params{
|
||||
Host: cfg.TURN.Host,
|
||||
Port: cfg.TURN.Port,
|
||||
Link: cfg.VK.Link,
|
||||
TransportUDP: cfg.TURN.TransportUDP,
|
||||
ObfKey: cfg.Obf.Key,
|
||||
GetCreds: tcpfwd.GetCredsFunc(vkAuth.GetCredentials),
|
||||
GetCreds: tcpfwd.GetCredsFunc(getCreds),
|
||||
KCPProfile: cfg.KCP.Profile,
|
||||
KCPFEC: cfg.KCP.FEC,
|
||||
}
|
||||
@@ -145,12 +134,11 @@ func main() {
|
||||
udpParams := &udprelay.Params{
|
||||
Host: cfg.TURN.Host,
|
||||
Port: cfg.TURN.Port,
|
||||
Link: cfg.VK.Link,
|
||||
TransportUDP: cfg.TURN.TransportUDP,
|
||||
ObfKey: cfg.Obf.Key,
|
||||
GetCreds: udprelay.GetCredsFunc(vkAuth.GetCredentials),
|
||||
GetCreds: udprelay.GetCredsFunc(getCreds),
|
||||
}
|
||||
if err := udprelay.Run(ctx, udpDtlsDialer, vkAuth, logger, &connectedStreams, udpParams, peer, cfg.Proxy.Listen, cfg.TURN.N); err != nil {
|
||||
if err := udprelay.Run(ctx, udpDtlsDialer, prov, logger, &connectedStreams, udpParams, peer, cfg.Proxy.Listen, cfg.TURN.N); err != nil {
|
||||
if errors.Is(err, udprelay.ErrFatal) {
|
||||
logger.Errorf("udprelay: fatal: %v", err)
|
||||
} else {
|
||||
@@ -159,3 +147,27 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// buildProvider выбирает реализацию provider.Provider по cfg.Provider.Name.
|
||||
// Валидация имени уже выполнена в config.ParseClient.
|
||||
func buildProvider(cfg *config.Client, dialer net.Dialer, connected *atomic.Int32, logger logx.Logger) (provider.Provider, error) {
|
||||
switch cfg.Provider.Name {
|
||||
case config.ProviderVK:
|
||||
return vk.New(vk.Config{
|
||||
Link: cfg.VK.Link,
|
||||
Dialer: dialer,
|
||||
ManualOnly: cfg.VK.ManualCaptcha,
|
||||
StreamsPerCache: cfg.VK.StreamsPerCred,
|
||||
StreamsAlive: connected.Load,
|
||||
Log: logger,
|
||||
}, vk.DefaultManualSolver)
|
||||
case config.ProviderStatic:
|
||||
return static.New(static.Config{
|
||||
User: cfg.Static.User,
|
||||
Pass: cfg.Static.Pass,
|
||||
Addr: cfg.Static.Addr,
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown provider %q", cfg.Provider.Name)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-5
@@ -6,18 +6,22 @@
|
||||
| --- | --- | --- |
|
||||
| `-listen` | `127.0.0.1:9000` | локальный адрес `ip:port`, куда подключается WireGuard или Xray клиент |
|
||||
| `-peer` | **обязательный** | адрес сервера на VPS, `host:port` |
|
||||
| `-link` | **обязательный** | ссылка VK Calls `https://vk.com/call/join/...` |
|
||||
| `-provider` | `vk` | источник TURN-creds: `vk` \| `static` (см. `docs/providers.md`) |
|
||||
| `-link` | **обязательный для `-provider vk`** | ссылка VK Calls `https://vk.com/call/join/...` |
|
||||
| `-static-user` | **обязательный для `-provider static`** | TURN username |
|
||||
| `-static-pass` | **обязательный для `-provider static`** | TURN password |
|
||||
| `-static-addr` | **обязательный для `-provider static`** | TURN server `host:port` |
|
||||
| `-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`) |
|
||||
| `-turn` | из ссылки | переопределить IP TURN-сервера |
|
||||
| `-port` | из ссылки | переопределить порт TURN-сервера |
|
||||
| `-turn` | из creds | переопределить IP TURN-сервера |
|
||||
| `-port` | из creds | переопределить порт TURN-сервера |
|
||||
| `-obf` | `false` | SRTP-мимикрия payload |
|
||||
| `-obf-key` | пусто | общий ключ, 32 байта hex (64 символа) |
|
||||
| `-gen-obf-key` | `false` | напечатать новый ключ и выйти |
|
||||
| `-manual-captcha` | `false` | сразу ручной режим captcha |
|
||||
| `-streams-per-cred` | `10` | потоков на один кеш VK-учёток |
|
||||
| `-manual-captcha` | `false` | сразу ручной режим captcha (только `-provider vk`) |
|
||||
| `-streams-per-cred` | `10` | потоков на один кеш VK-учёток (только `-provider vk`) |
|
||||
| `-dns-mode` | `auto` | `plain` (UDP/53) \| `doh` \| `auto` |
|
||||
| `-dns-servers` | пусто | свои UDP/53 резолверы, `ip[:port][,ip[:port]...]` |
|
||||
| `-debug` | `false` | debug-логи |
|
||||
|
||||
+8
-1
@@ -27,7 +27,14 @@ client ←→ VK TURN-relay
|
||||
## Термины
|
||||
|
||||
### TURN
|
||||
Standard relay protocol (RFC 5766/8656). VK выдаёт креды на свои TURN-реле. Мы используем эти реле как транзит к собственному серверу. Транспорт до TURN выбирается флагом `-udp` (UDP/TCP).
|
||||
Standard relay protocol (RFC 5766/8656). Источник реле выбирается флагом `-provider` (см. ниже). Мы используем реле как транзит к собственному серверу. Транспорт до TURN выбирается флагом `-transport` (UDP/TCP).
|
||||
|
||||
### Provider
|
||||
Источник TURN-реквизитов (user/pass/server-addr). Интерфейс `internal/provider.Provider`. Текущие реализации:
|
||||
- **`vk`** (default) — `internal/provider/vk`, обёртка над VK Calls API (требует `-link`).
|
||||
- **`static`** — `internal/provider/static`, фиксированные `-static-user/-static-pass/-static-addr` (для coturn, metered, любого совместимого TURN).
|
||||
|
||||
Pipeline (proxy/udprelay, proxy/tcpfwd) работает только через интерфейс — без VK-specific импортов. См. `docs/providers.md`.
|
||||
|
||||
### DTLS — это **обфускация, не security**
|
||||
VK content-filter дропает payload, который не похож на legit DTLS-handshake. Мы используем `pion/dtls` чтобы пройти фильтр. Шифрование DTLS — побочный эффект, не цель.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Providers
|
||||
|
||||
Источник TURN-реквизитов выбирается флагом `-provider` (default `vk`). Реализации удовлетворяют интерфейс `internal/provider.Provider` и подключаются в `cmd/client/main.go` через `buildProvider`.
|
||||
|
||||
## Доступные провайдеры
|
||||
|
||||
### `vk` (default)
|
||||
|
||||
VK Calls API. Перебирает встроенные `app_id/app_secret`, получает короткоживущие (≈10 мин) TURN-creds через 4-шаговый token chain. Solver captcha auto+manual.
|
||||
|
||||
**Обязательные флаги:**
|
||||
- `-link` — VK callroom URL вида `https://vk.com/call/join/<code>` (нормализуется до join-кода).
|
||||
|
||||
**Опциональные:**
|
||||
- `-streams-per-cred` (default 10) — сколько TURN-стримов делят один кеш креденшалов.
|
||||
- `-manual-captcha` — пропустить auto-solver, сразу открыть браузер.
|
||||
|
||||
Пакет: `internal/provider/vk/`. Внутренние зависимости: `internal/client/{vkauth,captcha,captcha/manual,browserprofile,namegen,ish,dnsdial}`.
|
||||
|
||||
### `static`
|
||||
|
||||
Фиксированные creds для любого RFC 5766/8656-совместимого TURN: self-hosted coturn, metered.ca с long-term creds, корпоративный TURN.
|
||||
|
||||
**Обязательные флаги:**
|
||||
- `-static-user` — TURN username.
|
||||
- `-static-pass` — TURN password.
|
||||
- `-static-addr` — TURN server host:port.
|
||||
|
||||
Без сетевого control-plane: никаких captcha, throttling, lockout. Если TURN вернёт 401, pipeline крутит retry с теми же creds — провайдер не умеет их ротировать.
|
||||
|
||||
Пакет: `internal/provider/static/`.
|
||||
|
||||
## Как добавить новый провайдер
|
||||
|
||||
1. Создать пакет `internal/provider/<name>/`.
|
||||
2. Определить `Config` (опции из CLI или из встроенной БД).
|
||||
3. Реализовать тип, удовлетворяющий `provider.Provider`:
|
||||
|
||||
```go
|
||||
type Provider interface {
|
||||
GetCredentials(ctx context.Context, streamID int) (Credentials, error)
|
||||
IsAuthError(err error) bool
|
||||
HandleAuthError(streamID int) bool
|
||||
ResetErrors(streamID int)
|
||||
BackoffUntilUnix() int64
|
||||
Name() string
|
||||
}
|
||||
```
|
||||
|
||||
4. Добавить флаги в `internal/config/config.go` (`<Name>Opts`, регистрация в `ParseClient`, валидация в `switch c.Provider.Name`).
|
||||
5. Добавить ветку в `buildProvider` в `cmd/client/main.go`.
|
||||
6. Тесты в `internal/provider/<name>/<name>_test.go` (минимум: validation, GetCredentials, контракт интерфейса).
|
||||
|
||||
### Соглашения
|
||||
|
||||
- **Backoff**: если провайдер просит pipeline подождать, оборачивай ошибку в `provider.ErrBackoffActive` (через `errors.Join` или `fmt.Errorf("%w: ...", provider.ErrBackoffActive, inner)`) + возвращай `BackoffUntilUnix() > 0` при наличии точного дедлайна.
|
||||
- **Фатально**: если креды нельзя получить и ни один стрим не поднят, оборачивай в `provider.ErrFatalNoStreams`. Pipeline вернёт `udprelay.ErrFatal`, хост-процесс сделает `os.Exit(1)`.
|
||||
- **Thread-safety**: все методы зовутся из N горутин (по числу стримов).
|
||||
- **Логи**: префикс `[Provider:<name>]` или `[<Name> Auth]` внутри пакета. Generic pipeline не пишет имя провайдера сам.
|
||||
|
||||
## Что НЕ относится к провайдеру
|
||||
|
||||
- DNS-стратегия резолвинга control-plane API (`internal/client/dnsdial`) — ортогональна провайдеру. VK-провайдер использует её для VK API, другой провайдер может использовать для своего HTTP API или не использовать вовсе.
|
||||
- Транспорт до TURN-реле (`-transport tcp|udp`) — определяется на pipeline-уровне, провайдер только выдаёт `host:port`.
|
||||
- Application-mode (`-mode udp|tcp`, `-bond`, `-obf`) — поверх TURN, не зависит от источника creds.
|
||||
|
||||
## Известные ограничения текущей абстракции
|
||||
|
||||
1. **Per-process один провайдер** — нет fallback chain (VK → static). Можно добавить wrapper-провайдер позже.
|
||||
2. **`StreamsAlive` callback** — VK-провайдер использует для решения fatal-vs-throttle. Другие провайдеры могут игнорировать (поле `Config.StreamsAlive` nil-safe).
|
||||
3. **Файл captcha-логгеров** в `vk.SetCaptchaLoggers` — VK-специфичная утечка в `cmd/client`. Будет инкапсулирована при физическом переносе `internal/client/captcha` → `internal/provider/vk/internal/captcha`.
|
||||
@@ -178,6 +178,14 @@ func (c *Client) LockoutUntilUnix() int64 {
|
||||
return c.lockout.Load()
|
||||
}
|
||||
|
||||
// BackoffUntilUnix реализует provider.Provider — алиас LockoutUntilUnix.
|
||||
// vkauth-lockout глобальный (без per-stream), streamID-параметра в самом
|
||||
// методе нет; интерфейс provider.Provider определяет no-arg сигнатуру.
|
||||
func (c *Client) BackoffUntilUnix() int64 { return c.LockoutUntilUnix() }
|
||||
|
||||
// Name реализует provider.Provider.
|
||||
func (*Client) Name() string { return "vk" }
|
||||
|
||||
// IsAuthError оборачивает пакетный IsAuthError как метод для работы через интерфейс.
|
||||
func (*Client) IsAuthError(err error) bool { return IsAuthError(err) }
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package vkauth
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/btp/internal/provider"
|
||||
)
|
||||
|
||||
// VKCredentials — пара app_id/app_secret для получения анонимных токенов.
|
||||
@@ -44,8 +46,12 @@ const (
|
||||
)
|
||||
|
||||
// Sentinel-ошибки auth-потока. Строковые формы стабильны (используются в логах).
|
||||
//
|
||||
// ErrCaptchaWaitRequired и ErrFatalCaptchaNoStreams также матчатся через
|
||||
// provider.ErrBackoffActive / provider.ErrFatalNoStreams — pipeline проверяет
|
||||
// generic-sentinels, vkauth-внутренний код может проверять и старые.
|
||||
var (
|
||||
ErrCaptchaWaitRequired = errors.New("CAPTCHA_WAIT_REQUIRED")
|
||||
ErrFatalCaptchaNoStreams = errors.New("FATAL_CAPTCHA_FAILED_NO_STREAMS")
|
||||
ErrCaptchaWaitRequired = errors.Join(provider.ErrBackoffActive, errors.New("CAPTCHA_WAIT_REQUIRED"))
|
||||
ErrFatalCaptchaNoStreams = errors.Join(provider.ErrFatalNoStreams, errors.New("FATAL_CAPTCHA_FAILED_NO_STREAMS"))
|
||||
ErrLockoutActive = errors.New("global lockout active")
|
||||
)
|
||||
|
||||
+65
-25
@@ -60,13 +60,31 @@ type ProxyOpts struct {
|
||||
Peer string // -peer: адрес серверного прокси, куда дозванивается клиент (только клиент)
|
||||
}
|
||||
|
||||
// VKOpts — опции VK-учёток и captcha (только клиент).
|
||||
// VKOpts — опции VK-учёток и captcha (только клиент, провайдер "vk").
|
||||
type VKOpts struct {
|
||||
Link string // -link (нормализован до join-кода)
|
||||
StreamsPerCred int // -streams-per-cred
|
||||
ManualCaptcha bool // -manual-captcha
|
||||
}
|
||||
|
||||
// StaticOpts — фиксированные TURN-реквизиты (только клиент, провайдер "static").
|
||||
type StaticOpts struct {
|
||||
User string // -static-user
|
||||
Pass string // -static-pass
|
||||
Addr string // -static-addr
|
||||
}
|
||||
|
||||
// ProviderOpts выбирает реализацию provider.Provider.
|
||||
type ProviderOpts struct {
|
||||
Name string // -provider: vk (default) | static
|
||||
}
|
||||
|
||||
// Известные имена провайдеров.
|
||||
const (
|
||||
ProviderVK = "vk"
|
||||
ProviderStatic = "static"
|
||||
)
|
||||
|
||||
// DNSOpts — опции DNS-резолвинга (только клиент).
|
||||
type DNSOpts struct {
|
||||
Mode string // -dns-mode: plain | doh | auto
|
||||
@@ -86,13 +104,15 @@ type KCPOpts struct {
|
||||
|
||||
// Client — разобранные и провалидированные CLI-опции клиента.
|
||||
type Client struct {
|
||||
TURN TURNOpts
|
||||
Obf ObfOpts
|
||||
Proxy ProxyOpts
|
||||
VK VKOpts
|
||||
DNS DNSOpts
|
||||
Log LogOpts
|
||||
KCP KCPOpts
|
||||
TURN TURNOpts
|
||||
Obf ObfOpts
|
||||
Proxy ProxyOpts
|
||||
Provider ProviderOpts
|
||||
VK VKOpts
|
||||
Static StaticOpts
|
||||
DNS DNSOpts
|
||||
Log LogOpts
|
||||
KCP KCPOpts
|
||||
}
|
||||
|
||||
// Server — разобранные и провалидированные CLI-опции сервера.
|
||||
@@ -111,10 +131,14 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) {
|
||||
fs.SetOutput(errOut)
|
||||
}
|
||||
|
||||
host := fs.String("turn", "", "переопределить IP TURN-сервера (по умолчанию берётся из VK Calls ссылки)")
|
||||
port := fs.String("port", "", "переопределить порт TURN-сервера (по умолчанию берётся из VK Calls ссылки)")
|
||||
host := fs.String("turn", "", "переопределить IP TURN-сервера (по умолчанию берётся из credentials провайдера)")
|
||||
port := fs.String("port", "", "переопределить порт TURN-сервера (по умолчанию берётся из credentials провайдера)")
|
||||
listen := fs.String("listen", "127.0.0.1:9000", "локальный адрес ip:port, куда подключается WireGuard или Xray клиент")
|
||||
vklink := fs.String("link", "", "ссылка VK Calls вида https://vk.com/call/join/... (обязательно)")
|
||||
providerFlag := fs.String("provider", ProviderVK, "источник TURN-реквизитов: vk (default, VK Calls API) | static (фиксированные -static-* флаги)")
|
||||
vklink := fs.String("link", "", "ссылка VK Calls вида https://vk.com/call/join/... (обязательно для -provider vk)")
|
||||
staticUser := fs.String("static-user", "", "TURN username (обязательно для -provider static)")
|
||||
staticPass := fs.String("static-pass", "", "TURN password (обязательно для -provider static)")
|
||||
staticAddr := fs.String("static-addr", "", "TURN server host:port (обязательно для -provider static)")
|
||||
peerAddr := fs.String("peer", "", "адрес сервера VK TURN Proxy на VPS, host:port (обязательно)")
|
||||
n := fs.Int("n", 10, "количество параллельных TURN-потоков (соединений к TURN-реле)")
|
||||
transportFlag := fs.String("transport", "tcp", "транспорт до TURN-реле: tcp (TCP/TLS, default) | udp")
|
||||
@@ -123,9 +147,9 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) {
|
||||
wrapMode := fs.Bool("obf", false, "маскировать TURN-payload под SRTP (RTP/opus + ChaCha20-Poly1305 AEAD) для обхода content-filter VK; ключ должен совпадать на клиенте и сервере")
|
||||
wrapKeyHex := fs.String("obf-key", "", "общий ключ для -obf, 32 байта в hex (64 символа)")
|
||||
genWrapKey := fs.Bool("gen-obf-key", false, "напечатать новый ключ для -obf-key и выйти")
|
||||
streamsPerCredFlag := fs.Int("streams-per-cred", defaultStreamsPerCache, "сколько TURN-потоков делят один кеш VK-учёток")
|
||||
streamsPerCredFlag := fs.Int("streams-per-cred", defaultStreamsPerCache, "сколько TURN-потоков делят один кеш VK-учёток (только -provider vk)")
|
||||
debugFlag := fs.Bool("debug", false, "включить подробные debug-логи")
|
||||
manualCaptchaFlag := fs.Bool("manual-captcha", false, "пропустить авто-решение VK captcha и сразу открыть ручной режим в локальном браузере")
|
||||
manualCaptchaFlag := fs.Bool("manual-captcha", false, "пропустить авто-решение VK captcha и сразу открыть ручной режим в локальном браузере (только -provider vk)")
|
||||
dnsFlag := fs.String("dns-mode", dnsModeAuto, "транспорт резолвера клиента: plain (UDP/53) | doh (DNS-over-HTTPS) | auto (UDP/53 → sticky DoH при отказе)")
|
||||
dnsServersFlag := fs.String("dns-servers", "", "список UDP/53 DNS-серверов через запятую вместо встроенных (напр. резолверы оператора из Android LinkProperties). Формат: ip[:port][,ip[:port]...]")
|
||||
|
||||
@@ -149,10 +173,18 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) {
|
||||
Listen: *listen,
|
||||
Peer: *peerAddr,
|
||||
},
|
||||
Provider: ProviderOpts{
|
||||
Name: *providerFlag,
|
||||
},
|
||||
VK: VKOpts{
|
||||
StreamsPerCred: *streamsPerCredFlag,
|
||||
ManualCaptcha: *manualCaptchaFlag,
|
||||
},
|
||||
Static: StaticOpts{
|
||||
User: *staticUser,
|
||||
Pass: *staticPass,
|
||||
Addr: *staticAddr,
|
||||
},
|
||||
DNS: DNSOpts{
|
||||
Mode: *dnsFlag,
|
||||
},
|
||||
@@ -194,28 +226,36 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) {
|
||||
if c.Proxy.Peer == "" {
|
||||
return nil, errors.New("need peer address")
|
||||
}
|
||||
if *vklink == "" {
|
||||
return nil, errors.New("need -link")
|
||||
switch c.Provider.Name {
|
||||
case ProviderVK:
|
||||
if *vklink == "" {
|
||||
return nil, errors.New("need -link (required for -provider vk)")
|
||||
}
|
||||
if c.VK.StreamsPerCred <= 0 {
|
||||
return nil, fmt.Errorf("-streams-per-cred must be positive")
|
||||
}
|
||||
parts := strings.Split(*vklink, "join/")
|
||||
link := parts[len(parts)-1]
|
||||
if idx := strings.IndexAny(link, "/?#"); idx != -1 {
|
||||
link = link[:idx]
|
||||
}
|
||||
c.VK.Link = link
|
||||
case ProviderStatic:
|
||||
if c.Static.User == "" || c.Static.Pass == "" || c.Static.Addr == "" {
|
||||
return nil, errors.New("-provider static requires -static-user, -static-pass, -static-addr")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid -provider value %q: must be %s | %s", c.Provider.Name, ProviderVK, ProviderStatic)
|
||||
}
|
||||
key, err := srtpmimicry.DecodeKey(c.Obf.Mode, *wrapKeyHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Obf.Key = key
|
||||
if c.VK.StreamsPerCred <= 0 {
|
||||
return nil, fmt.Errorf("-streams-per-cred must be positive")
|
||||
}
|
||||
if c.TURN.N <= 0 {
|
||||
c.TURN.N = 10
|
||||
}
|
||||
|
||||
parts := strings.Split(*vklink, "join/")
|
||||
link := parts[len(parts)-1]
|
||||
if idx := strings.IndexAny(link, "/?#"); idx != -1 {
|
||||
link = link[:idx]
|
||||
}
|
||||
c.VK.Link = link
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ func TestParseClient_Defaults(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if c.Provider.Name != ProviderVK {
|
||||
t.Errorf("Provider.Name default: %q (expected %q)", c.Provider.Name, ProviderVK)
|
||||
}
|
||||
if c.Proxy.Listen != "127.0.0.1:9000" {
|
||||
t.Errorf("Proxy.Listen default: %q", c.Proxy.Listen)
|
||||
}
|
||||
@@ -126,6 +129,42 @@ func TestParseClient_NClampedToTen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ProviderStaticOK(t *testing.T) {
|
||||
args := []string{
|
||||
"-peer", "1.2.3.4:5000",
|
||||
"-provider", "static",
|
||||
"-static-user", "u",
|
||||
"-static-pass", "p",
|
||||
"-static-addr", "turn.example.com:3478",
|
||||
}
|
||||
c, err := ParseClient(args, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if c.Provider.Name != ProviderStatic {
|
||||
t.Errorf("Provider.Name: %q (expected %q)", c.Provider.Name, ProviderStatic)
|
||||
}
|
||||
if c.Static.User != "u" || c.Static.Pass != "p" || c.Static.Addr != "turn.example.com:3478" {
|
||||
t.Errorf("Static: %+v", c.Static)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ProviderStaticMissingFlags(t *testing.T) {
|
||||
args := []string{"-peer", "1.2.3.4:5000", "-provider", "static"}
|
||||
_, err := ParseClient(args, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "static-user") {
|
||||
t.Errorf("expected static flags error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_ProviderUnknown(t *testing.T) {
|
||||
args := []string{"-peer", "1.2.3.4:5000", "-provider", "bogus"}
|
||||
_, err := ParseClient(args, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid -provider") {
|
||||
t.Errorf("expected invalid provider error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClient_DNSServersSplit(t *testing.T) {
|
||||
args := append(validClientArgs(), "-dns-servers", "1.1.1.1,8.8.8.8:53")
|
||||
c, err := ParseClient(args, io.Discard)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package provider определяет абстракцию источника TURN-реквизитов.
|
||||
//
|
||||
// Реализации (internal/provider/vk, internal/provider/static, ...) поставляют
|
||||
// короткоживущие user/pass/addr для TURN-allocate. Pipeline (proxy/udprelay,
|
||||
// proxy/tcpfwd) работает только через этот интерфейс — никаких VK-specific
|
||||
// типов в общем коде.
|
||||
//
|
||||
// Sentinel-ошибки описывают provider-нейтральные состояния, которые pipeline
|
||||
// обрабатывает однотипно: ErrBackoffActive — провайдер просит подождать,
|
||||
// ErrFatalNoStreams — фатально, дальше пробовать бессмысленно.
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Credentials — выданные провайдером TURN-реквизиты для одного стрима.
|
||||
// Адрес уже разрешён до конкретного host:port; pipeline не делает round-robin
|
||||
// по нескольким адресам — это ответственность провайдера.
|
||||
type Credentials struct {
|
||||
User string
|
||||
Pass string
|
||||
ServerAddr string // host:port
|
||||
ExpiresAt time.Time // 0 если провайдер не сообщает дедлайн
|
||||
}
|
||||
|
||||
// Provider — источник TURN-реквизитов для клиентского pipeline.
|
||||
// Реализации обязаны быть thread-safe: pipeline вызывает GetCredentials и
|
||||
// HandleAuthError из N разных горутин одновременно (по числу стримов).
|
||||
type Provider interface {
|
||||
// GetCredentials блокирующе возвращает реквизиты для streamID.
|
||||
// Реализация отвечает за throttling, кеширование, ретраи к upstream-API.
|
||||
GetCredentials(ctx context.Context, streamID int) (Credentials, error)
|
||||
|
||||
// IsAuthError — провайдер сам решает, какие ошибки от TURN-allocate
|
||||
// (401/403/stale-nonce/etc.) считать auth-related и приводить к
|
||||
// инвалидации кеша через HandleAuthError.
|
||||
IsAuthError(err error) bool
|
||||
|
||||
// HandleAuthError увеличивает счётчик auth-ошибок для streamID.
|
||||
// Возвращает true, если порог достигнут и кеш для streamID инвалидирован.
|
||||
HandleAuthError(streamID int) bool
|
||||
|
||||
// ResetErrors обнуляет счётчик ошибок (вызывать после успешного allocate).
|
||||
ResetErrors(streamID int)
|
||||
|
||||
// BackoffUntilUnix — unix-секунда, до которой провайдер просит pipeline
|
||||
// не звать GetCredentials. 0 — нет активного бэк-оффа.
|
||||
// Используется только при возврате ошибки, обёрнутой ErrBackoffActive.
|
||||
BackoffUntilUnix() int64
|
||||
|
||||
// Name — короткое имя провайдера для логов ("vk", "static", "metered").
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Sentinel-ошибки, которые провайдер оборачивает (через errors.Join или
|
||||
// fmt.Errorf("%w: ...", ErrXxx, inner)) для сигнализации pipeline.
|
||||
var (
|
||||
// ErrBackoffActive — провайдер просит подождать. Pipeline вызывает
|
||||
// BackoffUntilUnix() для получения точного дедлайна; если 0 — sleep по
|
||||
// дефолтному backoff (60s).
|
||||
ErrBackoffActive = errors.New("provider backoff active")
|
||||
|
||||
// ErrFatalNoStreams — провайдер не может выдать креды и ни один стрим
|
||||
// ещё не поднят. Pipeline возвращает фатальную ошибку наружу
|
||||
// (хост-процесс делает os.Exit без вмешательства pipeline).
|
||||
ErrFatalNoStreams = errors.New("provider fatal: no streams alive")
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package static — провайдер фиксированных TURN-реквизитов из CLI-флагов.
|
||||
//
|
||||
// Используется для подключения к любому совместимому TURN-серверу (coturn,
|
||||
// metered, self-hosted) с долгоживущими credentials. Без сетевого control-plane:
|
||||
// никаких captcha, throttling, lockout. Проверяет, что абстракция provider.Provider
|
||||
// действительно достаточна для не-VK источников.
|
||||
package static
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/samosvalishe/btp/internal/provider"
|
||||
)
|
||||
|
||||
// Config — параметры static-провайдера.
|
||||
type Config struct {
|
||||
User string // TURN username (обязателен)
|
||||
Pass string // TURN password (обязателен)
|
||||
Addr string // TURN server host:port (обязателен)
|
||||
}
|
||||
|
||||
// Provider реализует provider.Provider, всегда возвращает одни и те же реквизиты.
|
||||
type Provider struct {
|
||||
creds provider.Credentials
|
||||
}
|
||||
|
||||
// New создаёт static-провайдер. Валидирует обязательные поля.
|
||||
func New(cfg Config) (*Provider, error) {
|
||||
if cfg.User == "" {
|
||||
return nil, fmt.Errorf("static: empty User")
|
||||
}
|
||||
if cfg.Pass == "" {
|
||||
return nil, fmt.Errorf("static: empty Pass")
|
||||
}
|
||||
if cfg.Addr == "" {
|
||||
return nil, fmt.Errorf("static: empty Addr")
|
||||
}
|
||||
if !strings.Contains(cfg.Addr, ":") {
|
||||
return nil, fmt.Errorf("static: Addr must be host:port, got %q", cfg.Addr)
|
||||
}
|
||||
return &Provider{
|
||||
creds: provider.Credentials{
|
||||
User: cfg.User,
|
||||
Pass: cfg.Pass,
|
||||
ServerAddr: cfg.Addr,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCredentials возвращает фиксированные реквизиты.
|
||||
func (p *Provider) GetCredentials(_ context.Context, _ int) (provider.Credentials, error) {
|
||||
return p.creds, nil
|
||||
}
|
||||
|
||||
// IsAuthError — для static-провайдера auth-ошибки не приводят к ротации
|
||||
// реквизитов (их просто негде взять). Возвращаем false всегда: pipeline сам
|
||||
// сделает retry с тем же кешем.
|
||||
func (*Provider) IsAuthError(error) bool { return false }
|
||||
|
||||
// HandleAuthError ничего не делает (нет кеша для инвалидации).
|
||||
func (*Provider) HandleAuthError(int) bool { return false }
|
||||
|
||||
// ResetErrors ничего не делает.
|
||||
func (*Provider) ResetErrors(int) {}
|
||||
|
||||
// BackoffUntilUnix всегда 0 — backoff не реализован.
|
||||
func (*Provider) BackoffUntilUnix() int64 { return 0 }
|
||||
|
||||
// Name реализует provider.Provider.
|
||||
func (*Provider) Name() string { return "static" }
|
||||
@@ -0,0 +1,67 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/samosvalishe/btp/internal/provider"
|
||||
)
|
||||
|
||||
func TestNew_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
want string
|
||||
}{
|
||||
{"empty user", Config{Pass: "p", Addr: "h:1"}, "User"},
|
||||
{"empty pass", Config{User: "u", Addr: "h:1"}, "Pass"},
|
||||
{"empty addr", Config{User: "u", Pass: "p"}, "Addr"},
|
||||
{"addr without port", Config{User: "u", Pass: "p", Addr: "host"}, "host:port"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := New(tc.cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("got %v, want substring %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GetCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := New(Config{User: "alice", Pass: "secret", Addr: "turn.example.com:3478"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := p.GetCredentials(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := provider.Credentials{User: "alice", Pass: "secret", ServerAddr: "turn.example.com:3478"}
|
||||
if got != want {
|
||||
t.Errorf("got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_InterfaceContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, _ := New(Config{User: "u", Pass: "p", Addr: "h:1"})
|
||||
if p.IsAuthError(errors.New("anything")) {
|
||||
t.Error("IsAuthError should always return false")
|
||||
}
|
||||
if p.HandleAuthError(0) {
|
||||
t.Error("HandleAuthError should always return false")
|
||||
}
|
||||
p.ResetErrors(0)
|
||||
if p.BackoffUntilUnix() != 0 {
|
||||
t.Error("BackoffUntilUnix should always return 0")
|
||||
}
|
||||
if p.Name() != "static" {
|
||||
t.Errorf("Name=%q, want static", p.Name())
|
||||
}
|
||||
var _ provider.Provider = p
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package vk — провайдер TURN-реквизитов через VK Calls API.
|
||||
//
|
||||
// Фасад над internal/client/vkauth: добавляет фиксированный link (адрес
|
||||
// VK callroom) и адаптирует сигнатуру GetCredentials к provider.Provider.
|
||||
//
|
||||
// vk.Provider удовлетворяет provider.Provider и используется через generic
|
||||
// pipeline (proxy/udprelay, proxy/tcpfwd) без VK-specific импортов.
|
||||
package vk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/samosvalishe/btp/internal/client/captcha"
|
||||
manualcaptcha "github.com/samosvalishe/btp/internal/client/captcha/manual"
|
||||
"github.com/samosvalishe/btp/internal/client/vkauth"
|
||||
"github.com/samosvalishe/btp/internal/logx"
|
||||
"github.com/samosvalishe/btp/internal/provider"
|
||||
)
|
||||
|
||||
// Config — параметры VK-провайдера.
|
||||
type Config struct {
|
||||
// Link — VK callroom join-код (нормализованный, без префикса URL).
|
||||
// Обязателен.
|
||||
Link string
|
||||
|
||||
// Dialer для HTTP-транспорта VK API.
|
||||
Dialer net.Dialer
|
||||
|
||||
// ManualOnly форсирует ручной путь captcha с первой попытки.
|
||||
ManualOnly bool
|
||||
|
||||
// StreamsPerCache — делитель streamID → cacheID. <=0 → дефолт (10).
|
||||
StreamsPerCache int
|
||||
|
||||
// StreamsAlive возвращает число подключённых потоков; vkauth использует
|
||||
// для решения, является ли исчерпанная captcha фатальной или throttle.
|
||||
StreamsAlive func() int32
|
||||
|
||||
// Credentials — VK app_id/secret пары; nil → vkauth.DefaultCredentials.
|
||||
Credentials []vkauth.VKCredentials
|
||||
|
||||
// Log — уровневый логгер. nil → no-op.
|
||||
Log logx.Logger
|
||||
}
|
||||
|
||||
// ManualSolverFunc — кастомный решатель captcha. Если nil, vkauth не пытается
|
||||
// решать ручную captcha (поток падает на ErrFatalNoStreams при auto-fail).
|
||||
type ManualSolverFunc = vkauth.ManualSolveFunc
|
||||
|
||||
// Provider реализует provider.Provider через vkauth.Client + сохранённый link.
|
||||
type Provider struct {
|
||||
link string
|
||||
auth *vkauth.Client
|
||||
}
|
||||
|
||||
// New создаёт VK-провайдер. solver — функция ручного решения captcha
|
||||
// (опциональная); если nil — manual captcha путь отключён.
|
||||
func New(cfg Config, solver ManualSolverFunc) (*Provider, error) {
|
||||
if cfg.Link == "" {
|
||||
return nil, fmt.Errorf("vk: empty Link")
|
||||
}
|
||||
auth := vkauth.New(vkauth.Config{
|
||||
Credentials: cfg.Credentials,
|
||||
Dialer: cfg.Dialer,
|
||||
ManualOnly: cfg.ManualOnly,
|
||||
StreamsPerCache: cfg.StreamsPerCache,
|
||||
StreamsAlive: cfg.StreamsAlive,
|
||||
ManualSolver: solver,
|
||||
Log: cfg.Log,
|
||||
})
|
||||
return &Provider{link: cfg.Link, auth: auth}, nil
|
||||
}
|
||||
|
||||
// GetCredentials реализует provider.Provider.
|
||||
func (p *Provider) GetCredentials(ctx context.Context, streamID int) (provider.Credentials, error) {
|
||||
user, pass, addr, err := p.auth.GetCredentials(ctx, p.link, streamID)
|
||||
if err != nil {
|
||||
return provider.Credentials{}, err
|
||||
}
|
||||
return provider.Credentials{User: user, Pass: pass, ServerAddr: addr}, nil
|
||||
}
|
||||
|
||||
// IsAuthError реализует provider.Provider.
|
||||
func (p *Provider) IsAuthError(err error) bool { return p.auth.IsAuthError(err) }
|
||||
|
||||
// HandleAuthError реализует provider.Provider.
|
||||
func (p *Provider) HandleAuthError(streamID int) bool { return p.auth.HandleAuthError(streamID) }
|
||||
|
||||
// ResetErrors реализует provider.Provider.
|
||||
func (p *Provider) ResetErrors(streamID int) { p.auth.ResetErrors(streamID) }
|
||||
|
||||
// BackoffUntilUnix реализует provider.Provider.
|
||||
func (p *Provider) BackoffUntilUnix() int64 { return p.auth.BackoffUntilUnix() }
|
||||
|
||||
// Name реализует provider.Provider.
|
||||
func (*Provider) Name() string { return "vk" }
|
||||
|
||||
// SetCaptchaLoggers подключает logger в общие captcha-пакеты. Удобный шорткат
|
||||
// для cmd/client/main.go, чтобы не импортировать captcha/manualcaptcha напрямую
|
||||
// после полного перехода на provider/vk.
|
||||
func SetCaptchaLoggers(log logx.Logger, debug bool) {
|
||||
captcha.SetLogger(log)
|
||||
manualcaptcha.SetLogger(log)
|
||||
manualcaptcha.Debug = debug
|
||||
}
|
||||
|
||||
// DefaultManualSolver — стандартный manual-captcha solver, использует
|
||||
// internal/client/captcha/manual (HTTP-сервер 127.0.0.1:8765 + браузер).
|
||||
func DefaultManualSolver(ctx context.Context, e *captcha.Error, d net.Dialer) (string, string, error) {
|
||||
if e.RedirectURI != "" {
|
||||
t, err := manualcaptcha.SolveViaProxy(ctx, e.RedirectURI, d)
|
||||
return t, "", err
|
||||
}
|
||||
if e.CaptchaImg != "" {
|
||||
k, err := manualcaptcha.SolveViaHTTP(ctx, e.CaptchaImg)
|
||||
return "", k, err
|
||||
}
|
||||
return "", "", fmt.Errorf("no redirect_uri or captcha_img")
|
||||
}
|
||||
@@ -13,15 +13,16 @@ import (
|
||||
"github.com/samosvalishe/btp/internal/wire/srtpmimicry"
|
||||
)
|
||||
|
||||
// GetCredsFunc разрешает VK TURN-реквизиты для пары (link, streamID).
|
||||
// Соответствует vkauth.Client.GetCredentials.
|
||||
type GetCredsFunc func(ctx context.Context, link string, streamID int) (user, pass, rawURL string, err error)
|
||||
// GetCredsFunc разрешает TURN-реквизиты для streamID. Реализуется provider'ом
|
||||
// (см. internal/provider): provider держит идентификатор сессии (link/room/key)
|
||||
// внутри, pipeline передаёт только streamID.
|
||||
type GetCredsFunc func(ctx context.Context, streamID int) (user, pass, rawURL string, err error)
|
||||
|
||||
// DialTURN получает реквизиты и открывает TURN-поток. Вызывающий отвечает
|
||||
// за закрытие потока и политику retry при auth-ошибке (udprelay)
|
||||
// или перезапуска сессии (tcpfwd).
|
||||
func DialTURN(ctx context.Context, host, port string, udp bool, peer *net.UDPAddr, link string, streamID int, getCreds GetCredsFunc) (*turndial.Stream, error) {
|
||||
user, pass, rawURL, err := getCreds(ctx, link, streamID)
|
||||
func DialTURN(ctx context.Context, host, port string, udp bool, peer *net.UDPAddr, streamID int, getCreds GetCredsFunc) (*turndial.Stream, error) {
|
||||
user, pass, rawURL, err := getCreds(ctx, streamID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get TURN creds: %w", err)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ type GetCredsFunc = common.GetCredsFunc
|
||||
type Params struct {
|
||||
Host string
|
||||
Port string
|
||||
Link string
|
||||
TransportUDP bool
|
||||
ObfKey []byte
|
||||
GetCreds GetCredsFunc
|
||||
@@ -233,7 +232,7 @@ func createSmuxSession(ctx context.Context, deps *Deps, params *Params, peer *ne
|
||||
}
|
||||
}
|
||||
|
||||
stream, err := common.DialTURN(ctx, params.Host, params.Port, params.TransportUDP, peer, params.Link, id, params.GetCreds)
|
||||
stream, err := common.DialTURN(ctx, params.Host, params.Port, params.TransportUDP, peer, id, params.GetCreds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cbeuw/connutil"
|
||||
"github.com/samosvalishe/btp/internal/client/vkauth"
|
||||
"github.com/samosvalishe/btp/internal/provider"
|
||||
"github.com/samosvalishe/btp/internal/proxy/common"
|
||||
"github.com/samosvalishe/btp/internal/randx"
|
||||
"github.com/samosvalishe/btp/internal/stats"
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// DTLSLoop поддерживает единственное DTLS-подключение для streamID, перезапуская
|
||||
// его при сбое с backoff 10-30s (пропускается при активной captcha-блокировке,
|
||||
// его при сбое с backoff 10-30s (пропускается при активном provider-backoff,
|
||||
// если предыдущая ошибка — дедлайн). connchan получает свежую половину
|
||||
// AsyncPacketPipe на каждой попытке; okchan (non-nil только для потока 1)
|
||||
// сигнализирует о первом успешном handshake.
|
||||
@@ -29,10 +29,10 @@ func DTLSLoop(ctx context.Context, deps *Deps, peer *net.UDPAddr, listenConn net
|
||||
return
|
||||
default:
|
||||
err := oneDTLS(ctx, deps, peer, listenConn, inboundChan, connchan, okchan, streamID)
|
||||
// При captcha-блокировке дедлайн handshake срабатывает раньше,
|
||||
// При активном provider-backoff дедлайн handshake срабатывает раньше,
|
||||
// чем auth-retry успевает отработать; делаем краткий backoff,
|
||||
// чтобы не крутиться в tight spin до снятия блокировки.
|
||||
if err != nil && time.Now().Unix() < deps.Auth.LockoutUntilUnix() && errors.Is(err, context.DeadlineExceeded) {
|
||||
if err != nil && time.Now().Unix() < deps.Auth.BackoffUntilUnix() && errors.Is(err, context.DeadlineExceeded) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -53,7 +53,8 @@ func DTLSLoop(ctx context.Context, deps *Deps, peer *net.UDPAddr, listenConn net
|
||||
|
||||
// TURNLoop ведёт половину TURN-аллокации. Ждёт свежий conn2 от DTLS-цикла,
|
||||
// тормозит через t (глобальный тик 200ms), выполняет одну TURN-сессию
|
||||
// и реагирует на FATAL_CAPTCHA / CAPTCHA_WAIT_REQUIRED соответственно.
|
||||
// и реагирует на provider.ErrFatalNoStreams / provider.ErrBackoffActive
|
||||
// соответственно.
|
||||
func TURNLoop(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time, streamID int) {
|
||||
for {
|
||||
select {
|
||||
@@ -75,33 +76,30 @@ func TURNLoop(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, vkauth.ErrFatalCaptchaNoStreams) {
|
||||
deps.log().Errorf("[STREAM %d] Fatal manual captcha error. Shutting down application.", streamID)
|
||||
if errors.Is(err, provider.ErrFatalNoStreams) {
|
||||
deps.log().Errorf("[STREAM %d] Fatal provider error. Shutting down application.", streamID)
|
||||
select {
|
||||
case deps.fatalCh <- fmt.Errorf("%w: %w", ErrFatal, err):
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.Is(err, vkauth.ErrCaptchaWaitRequired) {
|
||||
if !errors.Is(err, vkauth.ErrLockoutActive) {
|
||||
deps.log().Warnf("[STREAM %d] Backing off for 60 seconds to avoid IP ban", streamID)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
} else {
|
||||
lockoutEnd := deps.Auth.LockoutUntilUnix()
|
||||
sleepDuration := time.Until(time.Unix(lockoutEnd, 0))
|
||||
if errors.Is(err, provider.ErrBackoffActive) {
|
||||
lockoutEnd := deps.Auth.BackoffUntilUnix()
|
||||
var sleepDuration time.Duration
|
||||
if lockoutEnd > 0 {
|
||||
sleepDuration = time.Until(time.Unix(lockoutEnd, 0))
|
||||
if sleepDuration < 0 {
|
||||
sleepDuration = 5 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(sleepDuration):
|
||||
}
|
||||
} else {
|
||||
sleepDuration = 60 * time.Second
|
||||
deps.log().Warnf("[STREAM %d] Backing off for 60 seconds (provider requests wait)", streamID)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(sleepDuration):
|
||||
}
|
||||
} else {
|
||||
deps.log().Errorf("[STREAM %d] %s", streamID, err)
|
||||
@@ -221,7 +219,7 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr,
|
||||
err = ctx.Err()
|
||||
return
|
||||
}
|
||||
stream, err1 := common.DialTURN(ctx, params.Host, params.Port, params.TransportUDP, peer, params.Link, streamID, params.GetCreds)
|
||||
stream, err1 := common.DialTURN(ctx, params.Host, params.Port, params.TransportUDP, peer, streamID, params.GetCreds)
|
||||
if err1 != nil {
|
||||
if deps.Auth.IsAuthError(err1) {
|
||||
deps.Auth.HandleAuthError(streamID)
|
||||
|
||||
@@ -21,30 +21,30 @@ import (
|
||||
// GetCredsFunc реэкспортирован из common, чтобы вызывающие не выходили за пределы импортов пакета.
|
||||
type GetCredsFunc = common.GetCredsFunc
|
||||
|
||||
// AuthHandler — подмножество vkauth.Client, необходимое пакету. Определено как
|
||||
// интерфейс, чтобы тесты могли подменять fake; prod-код всё равно импортирует
|
||||
// vkauth ради sentinel-ошибок (ErrFatalCaptchaNoStreams и т.д.).
|
||||
// AuthHandler — подмножество provider.Provider, необходимое пакету.
|
||||
// Определено как локальный интерфейс, чтобы тесты могли подменять fake без
|
||||
// импорта реализации провайдера. Sentinel-ошибки auth-флоу проверяются через
|
||||
// provider.ErrXxx.
|
||||
type AuthHandler interface {
|
||||
IsAuthError(err error) bool
|
||||
HandleAuthError(streamID int) bool
|
||||
ResetErrors(streamID int)
|
||||
LockoutUntilUnix() int64
|
||||
BackoffUntilUnix() int64
|
||||
}
|
||||
|
||||
// Params — per-stream конфигурация TURN/wrap, общая для DTLS и TURN циклов.
|
||||
type Params struct {
|
||||
Host string
|
||||
Port string
|
||||
Link string
|
||||
TransportUDP bool
|
||||
ObfKey []byte
|
||||
GetCreds GetCredsFunc
|
||||
}
|
||||
|
||||
// ErrFatal возвращается из Run, когда поток встречает условие, требующее
|
||||
// завершения всего приложения (напр. ручной решатель captcha провалился без
|
||||
// подключённых потоков). Вызывающий должен проверить через errors.Is и вызвать
|
||||
// os.Exit сам — udprelay не вмешивается в хост-процесс.
|
||||
// завершения всего приложения (см. provider.ErrFatalNoStreams). Вызывающий
|
||||
// должен проверить через errors.Is и вызвать os.Exit сам — udprelay не
|
||||
// вмешивается в хост-процесс.
|
||||
var ErrFatal = errors.New("udprelay: fatal error")
|
||||
|
||||
// Deps объединяет всё, что циклы берут из хост-процесса. Атомики принадлежат
|
||||
@@ -70,11 +70,11 @@ func (d *Deps) log() logx.Logger {
|
||||
|
||||
// Run — точка входа UDP-режима. Биндит listenAddr, распределяет входящие пакеты
|
||||
// в общую очередь и запускает numStreams пар (DTLSLoop, TURNLoop).
|
||||
// connectedStreams принадлежит вызывающему (vkauth читает через StreamsAlive)
|
||||
// и инкрементируется/декрементируется в oneTURN.
|
||||
// connectedStreams принадлежит вызывающему (provider может читать через свой
|
||||
// StreamsAlive-аналог) и инкрементируется/декрементируется в oneTURN.
|
||||
// Возвращается после выхода всех потоков (т.е. при отмене ctx).
|
||||
// При фатальном captcha-условии возвращает ErrFatal — вызывающий делает os.Exit
|
||||
// без вмешательства udprelay в хост-процесс.
|
||||
// При фатальной provider-ошибке возвращает ErrFatal — вызывающий делает
|
||||
// os.Exit без вмешательства udprelay в хост-процесс.
|
||||
func Run(ctx context.Context, dtlsDialer *dtlsdial.Dialer, auth AuthHandler, logger logx.Logger, connectedStreams *atomic.Int32, params *Params, peer *net.UDPAddr, listenAddr string, numStreams int) error {
|
||||
listenConn, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", listenAddr)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user