diff --git a/cmd/client/main.go b/cmd/client/main.go index b381371..f23ed5a 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -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) + } +} diff --git a/docs/flags.md b/docs/flags.md index e5bce53..fcc3ead 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -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-логи | diff --git a/docs/glossary.md b/docs/glossary.md index 65bfadf..5f9b0cf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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 — побочный эффект, не цель. diff --git a/docs/providers.md b/docs/providers.md new file mode 100644 index 0000000..63eb7a1 --- /dev/null +++ b/docs/providers.md @@ -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/` (нормализуется до 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//`. +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` (`Opts`, регистрация в `ParseClient`, валидация в `switch c.Provider.Name`). +5. Добавить ветку в `buildProvider` в `cmd/client/main.go`. +6. Тесты в `internal/provider//_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:]` или `[ 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`. diff --git a/internal/client/vkauth/client.go b/internal/client/vkauth/client.go index 4496ad2..c7e9276 100644 --- a/internal/client/vkauth/client.go +++ b/internal/client/vkauth/client.go @@ -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) } diff --git a/internal/client/vkauth/types.go b/internal/client/vkauth/types.go index 06664f2..e8a94b1 100644 --- a/internal/client/vkauth/types.go +++ b/internal/client/vkauth/types.go @@ -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") ) diff --git a/internal/config/config.go b/internal/config/config.go index 431c639..c0ce54e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b9dbf92..8d18705 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..c2d926a --- /dev/null +++ b/internal/provider/provider.go @@ -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") +) diff --git a/internal/provider/static/static.go b/internal/provider/static/static.go new file mode 100644 index 0000000..326a806 --- /dev/null +++ b/internal/provider/static/static.go @@ -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" } diff --git a/internal/provider/static/static_test.go b/internal/provider/static/static_test.go new file mode 100644 index 0000000..53f0e10 --- /dev/null +++ b/internal/provider/static/static_test.go @@ -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 +} diff --git a/internal/provider/vk/vk.go b/internal/provider/vk/vk.go new file mode 100644 index 0000000..16d09ce --- /dev/null +++ b/internal/provider/vk/vk.go @@ -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") +} diff --git a/internal/proxy/common/common.go b/internal/proxy/common/common.go index dc77f29..5a9de8c 100644 --- a/internal/proxy/common/common.go +++ b/internal/proxy/common/common.go @@ -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) } diff --git a/internal/proxy/tcpfwd/tcpfwd.go b/internal/proxy/tcpfwd/tcpfwd.go index b3b258e..928a553 100644 --- a/internal/proxy/tcpfwd/tcpfwd.go +++ b/internal/proxy/tcpfwd/tcpfwd.go @@ -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 } diff --git a/internal/proxy/udprelay/loop.go b/internal/proxy/udprelay/loop.go index 4fa1ca9..12a0e47 100644 --- a/internal/proxy/udprelay/loop.go +++ b/internal/proxy/udprelay/loop.go @@ -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) diff --git a/internal/proxy/udprelay/run.go b/internal/proxy/udprelay/run.go index 47b9235..1816803 100644 --- a/internal/proxy/udprelay/run.go +++ b/internal/proxy/udprelay/run.go @@ -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 {