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:
samosvalishe
2026-05-23 14:11:05 +03:00
parent 8427887902
commit 12c1ad6283
16 changed files with 624 additions and 109 deletions
+45 -33
View File
@@ -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
View File
@@ -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
View File
@@ -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 — побочный эффект, не цель.
+71
View File
@@ -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`.
+8
View File
@@ -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) }
+8 -2
View File
@@ -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
View File
@@ -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
}
+39
View File
@@ -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)
+70
View File
@@ -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")
)
+72
View File
@@ -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" }
+67
View File
@@ -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
}
+121
View File
@@ -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")
}
+6 -5
View File
@@ -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)
}
+1 -2
View File
@@ -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
}
+22 -24
View File
@@ -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)
+12 -12
View File
@@ -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 {