From 4bedd00662bb75a5f2fdd8fc3cee9c8eb6d94e7c Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Sat, 23 May 2026 15:24:00 +0300 Subject: [PATCH] =?UTF-8?q?refactor(wire):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20srtpmi?= =?UTF-8?q?micry=20=E2=86=92=20rtpopus,=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20bool=20-obf=20=D0=BD=D0=B0=20-obf-profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Готовим зонтик `internal/wire/` под будущие профили мимикрии (rtph264, vp8 и т.д.). Сейчас интерфейс/registry не вводим — родится при появлении второго профиля; пока только перепаковываем единственный профиль в собственный namespace. Пакет: - `internal/wire/srtpmimicry` → `internal/wire/rtpopus` - error-префикс `"wrap: ..."` → `"rtpopus: ..."` - wire-формат не тронут (заморожен — совместимость с задеплоенными пирами) CLI (breaking): - `-obf` (bool) удалён - добавлен `-obf-profile none|rtpopus` (default `none`) - `ObfOpts.Mode bool` → `Profile ObfProfile` + `Enabled()` helper - консты `ObfProfileNone`/`ObfProfileRTPOpus` - validation invalid-value error для `-obf-profile` Docker (breaking): - env `OBF_MODE=true` → `OBF_PROFILE=rtpopus` Callsites: - `common.NewClientWrap` → `NewClientObf` - vars `wc`/`relayWC` → `obfConn`, `wrapListener` → `obfListener` - log `obf=%t` → `obf-profile=%s` Доки + тесты подтянуты под новые имена. --- cmd/client/main.go | 8 +-- cmd/server/main.go | 24 +++---- docker-compose.yml | 2 +- docker/entrypoint.sh | 6 +- docs/deploy.md | 6 +- docs/flags.md | 8 +-- docs/glossary.md | 11 ++-- docs/modes.md | 14 +++-- docs/providers.md | 2 +- docs/troubleshooting.md | 2 +- internal/config/config.go | 63 ++++++++++++++----- internal/config/config_test.go | 46 +++++++++++--- internal/proxy/common/common.go | 16 ++--- internal/proxy/tcpfwd/tcpfwd.go | 10 +-- internal/proxy/udprelay/loop.go | 32 +++++----- .../wire/{srtpmimicry => rtpopus}/listen.go | 6 +- .../wire/{srtpmimicry => rtpopus}/relay.go | 4 +- .../srtpmimicry.go => rtpopus/rtpopus.go} | 27 ++++---- .../rtpopus_test.go} | 2 +- 19 files changed, 179 insertions(+), 110 deletions(-) rename internal/wire/{srtpmimicry => rtpopus}/listen.go (95%) rename internal/wire/{srtpmimicry => rtpopus}/relay.go (96%) rename internal/wire/{srtpmimicry/srtpmimicry.go => rtpopus/rtpopus.go} (87%) rename internal/wire/{srtpmimicry/srtpmimicry_test.go => rtpopus/rtpopus_test.go} (99%) diff --git a/cmd/client/main.go b/cmd/client/main.go index 03889e6..fa77999 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -21,7 +21,7 @@ import ( "github.com/samosvalishe/btp/internal/proxy/tcpfwd" "github.com/samosvalishe/btp/internal/proxy/udprelay" "github.com/samosvalishe/btp/internal/transport/dtlsdial" - "github.com/samosvalishe/btp/internal/wire/srtpmimicry" + "github.com/samosvalishe/btp/internal/wire/rtpopus" ) // version is populated at build time via -ldflags "-X main.version=...". @@ -64,7 +64,7 @@ func main() { appDialer := dnsdial.AppDialer(cfg.DNS.Mode) dnsdial.InstallGlobalResolver(cfg.DNS.Mode) if cfg.Obf.GenKey { - key, gerr := srtpmimicry.GenKeyHex() + key, gerr := rtpopus.GenKeyHex() if gerr != nil { logger.Errorf("gen-obf-key: %v", gerr) os.Exit(1) @@ -77,8 +77,8 @@ func main() { logger.Errorf("resolve peer addr: %v", err) os.Exit(1) } - if cfg.Obf.Mode { - logger.Infof("OBF mode enabled: peer server must use matching -obf-key") + if cfg.Obf.Enabled() { + logger.Infof("OBF profile=%s: peer server must use matching -obf-profile and -obf-key", cfg.Obf.Profile) } var connectedStreams atomic.Int32 diff --git a/cmd/server/main.go b/cmd/server/main.go index 9b81e93..c3684e2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,7 +18,7 @@ import ( "github.com/samosvalishe/btp/internal/proxy/tcpfwdserver" "github.com/samosvalishe/btp/internal/proxy/udpserver" "github.com/samosvalishe/btp/internal/transport/dtlsdial" - "github.com/samosvalishe/btp/internal/wire/srtpmimicry" + "github.com/samosvalishe/btp/internal/wire/rtpopus" ) // version is populated at build time via -ldflags "-X main.version=...". @@ -34,7 +34,7 @@ func main() { logger.Infof("btp server version=%s", version) if cfg.Obf.GenKey { - key, gerr := srtpmimicry.GenKeyHex() + key, gerr := rtpopus.GenKeyHex() if gerr != nil { logger.Errorf("gen-obf-key: %v", gerr) os.Exit(1) @@ -65,10 +65,10 @@ func main() { logger.Errorf("resolve listen addr: %v", err) os.Exit(1) } - logger.Infof("Starting server listen=%s connect=%s mode=%s obf=%t bond-autodetect=true", - cfg.Proxy.Listen, cfg.Proxy.Connect, cfg.Proxy.Mode, cfg.Obf.Mode) - if !cfg.Obf.Mode { - logger.Warnf("running without -obf: any client reaching %s can relay to %s (no shared-key auth)", cfg.Proxy.Listen, cfg.Proxy.Connect) + logger.Infof("Starting server listen=%s connect=%s mode=%s obf-profile=%s bond-autodetect=true", + cfg.Proxy.Listen, cfg.Proxy.Connect, cfg.Proxy.Mode, cfg.Obf.Profile) + if !cfg.Obf.Enabled() { + logger.Warnf("running with -obf-profile=none: any client reaching %s can relay to %s (no shared-key auth)", cfg.Proxy.Listen, cfg.Proxy.Connect) } certificate, genErr := dtlsdial.GenerateSelfSignedCert() @@ -84,14 +84,14 @@ func main() { dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)), } var listener net.Listener - if cfg.Obf.Mode { - logger.Infof("OBF mode enabled: listener only accepts clients with matching -obf-key") - wrapListener, werr := srtpmimicry.Listen(addr, cfg.Obf.Key) - if werr != nil { - logger.Errorf("wrap listen: %v", werr) + if cfg.Obf.Enabled() { + logger.Infof("OBF profile=%s: listener only accepts clients with matching -obf-profile and -obf-key", cfg.Obf.Profile) + obfListener, oerr := rtpopus.Listen(addr, cfg.Obf.Key) + if oerr != nil { + logger.Errorf("obf listen: %v", oerr) os.Exit(1) } - listener, err = dtls.NewListenerWithOptions(wrapListener, dtlsOpts...) + listener, err = dtls.NewListenerWithOptions(obfListener, dtlsOpts...) } else { listener, err = dtls.ListenWithOptions("udp", addr, dtlsOpts...) } diff --git a/docker-compose.yml b/docker-compose.yml index ef1bc34..a940f9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,6 @@ services: CONNECT_ADDR: "127.0.0.1:51820" # LISTEN_ADDR: "0.0.0.0:56000" # MODE: "tcp" # tcp = TCP-форвардер для Xray/sing-box; default udp = WireGuard - # OBF_MODE: "true" + # OBF_PROFILE: "rtpopus" # none (default) | rtpopus # OBF_KEY задаётся в .env (не коммитьте ключ в git) # DEBUG: "false" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0d2a9a9..424a6d1 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -15,9 +15,9 @@ if [ "${MODE}" = "tcp" ]; then set -- "$@" -mode tcp fi -if [ "${OBF_MODE}" = "true" ]; then - OBF="${OBF_KEY:?OBF_KEY is required when OBF_MODE=true}" - set -- "$@" -obf -obf-key "$OBF" +if [ -n "${OBF_PROFILE}" ] && [ "${OBF_PROFILE}" != "none" ]; then + OBF="${OBF_KEY:?OBF_KEY is required when OBF_PROFILE != none}" + set -- "$@" -obf-profile "$OBF_PROFILE" -obf-key "$OBF" fi if [ "${DEBUG}" = "true" ]; then diff --git a/docs/deploy.md b/docs/deploy.md index 99b3b73..8c14bcb 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -10,7 +10,7 @@ docker compose logs -f По умолчанию: WireGuard backend `127.0.0.1:51820`, host network, порт `56000/udp`. -Для Xray/sing-box: `MODE=tcp` и смените `CONNECT_ADDR` на `127.0.0.1:443`. Для SRTP-мимикрии: `OBF_MODE=true` и `OBF_KEY=<64-hex>`. +Для Xray/sing-box: `MODE=tcp` и смените `CONNECT_ADDR` на `127.0.0.1:443`. Для обфускации payload: `OBF_PROFILE=rtpopus` и `OBF_KEY=<64-hex>`. ## Docker Run @@ -39,8 +39,8 @@ docker run -d --name btp -p 56000:56000/udp --restart unless-stopped \ | `CONNECT_ADDR` | **обязательна** | backend сервера | | `LISTEN_ADDR` | `0.0.0.0:56000` | адрес прослушивания | | `MODE` | `udp` | режим туннеля: `udp` \| `tcp` | -| `OBF_MODE` | `false` | включает `-obf` | -| `OBF_KEY` | пусто | значение `-obf-key` | +| `OBF_PROFILE` | `none` | значение `-obf-profile`: `none` \| `rtpopus` | +| `OBF_KEY` | пусто | значение `-obf-key` (обязателен при `OBF_PROFILE != none`) | | `DEBUG` | `false` | включает `-debug` | Сборка образа: diff --git a/docs/flags.md b/docs/flags.md index 727e87a..db59ef2 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -14,8 +14,8 @@ | `-bond` | `false` | распределять одно TCP-соединение по всем активным smux-сессиям (только с `-mode tcp`) | | `-turn` | из creds | переопределить IP TURN-сервера | | `-port` | из creds | переопределить порт TURN-сервера | -| `-obf` | `false` | SRTP-мимикрия payload | -| `-obf-key` | пусто | общий ключ, 32 байта hex (64 символа) | +| `-obf-profile` | `none` | wire-профиль обфускации payload: `none` \| `rtpopus` (RTP/opus + ChaCha20-Poly1305 AEAD) | +| `-obf-key` | пусто | общий ключ для `-obf-profile != none`, 32 байта hex (64 символа) | | `-gen-obf-key` | `false` | напечатать новый ключ и выйти | | `-manual-captcha` | `false` | сразу ручной режим captcha (только `-provider vk`) | | `-streams-per-cred` | `10` | потоков на один кеш VK-учёток (только `-provider vk`) | @@ -30,8 +30,8 @@ | `-listen` | `0.0.0.0:56000` | адрес прослушивания `ip:port` | | `-connect` | **обязательный** | локальный backend `host:port` (WG `127.0.0.1:51820` / Xray `127.0.0.1:443`) | | `-mode` | `udp` | режим туннеля: `udp` \| `tcp` (bond автоопределяется) | -| `-obf` | `false` | SRTP-мимикрия payload | -| `-obf-key` | пусто | общий ключ, 32 байта hex | +| `-obf-profile` | `none` | wire-профиль обфускации payload: `none` \| `rtpopus` (RTP/opus + ChaCha20-Poly1305 AEAD) | +| `-obf-key` | пусто | общий ключ для `-obf-profile != none`, 32 байта hex | | `-gen-obf-key` | `false` | напечатать новый ключ и выйти | | `-debug` | `false` | debug-логи | diff --git a/docs/glossary.md b/docs/glossary.md index 3720735..c4b1fb8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -11,7 +11,7 @@ backend (WireGuard UDP | TCP service) ↑ [app proxy mode] ← UDP-relay (-mode udp) | TCP-forward (-mode tcp) | TCP-forward+bond (-mode tcp -bond) ↑ -[srtpmimicry] (optional, AEAD обфускация поверх payload) +[obf wire profile] (optional, AEAD обфускация поверх payload; сейчас только rtpopus) ↑ [DTLS] ← обфускация (не security): VK content-filter ожидает DTLS handshake ↑ @@ -38,8 +38,11 @@ Pipeline (proxy/udprelay, proxy/tcpfwd) работает только через ### DTLS — это **обфускация, не security** VK content-filter дропает payload, который не похож на legit DTLS-handshake. Мы используем `pion/dtls` чтобы пройти фильтр. Шифрование DTLS — побочный эффект, не цель. -### srtpmimicry -Пакет `internal/wire/srtpmimicry`. CLI флаг `-obf`. Это **ChaCha20-Poly1305 AEAD** с RTP-like wire-header (mimicry). Включается опционально поверх DTLS — даёт дополнительный слой обфускации с RTP-видимостью на проводе. Не путать с Go-термином wrap (`errors.Wrap`). +### obf wire profile +Зонтик `internal/wire/` объединяет wire-форматы. Профили обфускации выбираются флагом `-obf-profile` (default `none`): +- **`rtpopus`** (`internal/wire/rtpopus`): **ChaCha20-Poly1305 AEAD** с RTP/opus wire-header (mimicry). RTP-видимость на проводе обходит VK TURN content-filter. + +Профили задуманы расширяемыми (rtph264, vp8 и т.д.) — общий интерфейс введём при появлении второго профиля. Wire-формат `rtpopus` заморожен (требуется побитовая совместимость с задеплоенными пирами). Не путать с Go-термином wrap (`errors.Wrap`) — методы `WrapInto`/`Unwrap` означают энкод/декод wire-фрейма. ### app proxy mode (3 значения) Что копируется поверх DTLS: @@ -87,7 +90,7 @@ Linux user-mode environment для iOS (через usermode x86 emulation). Па - `-transport tcp|udp` — транспорт до TURN (TCP/TLS или UDP). - `-mode udp|tcp` — app proxy mode (UDP-relay|TCP-forward). - `-bond` — bonding внутри TCP-forward (только client). -- `-obf` — srtpmimicry-обфускация. +- `-obf-profile` — wire-профиль обфускации (`none` | `rtpopus`). - `-dns-mode` — транспорт резолвера клиента (plain|doh|auto). ### captcha (auto / manual) diff --git a/docs/modes.md b/docs/modes.md index 76dbbea..20ec382 100644 --- a/docs/modes.md +++ b/docs/modes.md @@ -23,9 +23,15 @@ Серверного `-bond` нет — сервер автоопределяет bond по magic-префиксу в стриме. -## OBF (SRTP-Мимикрия) +## OBF (wire-профили обфускации) -`-obf` маскирует TURN-payload под SRTP: RTP/opus-заголовок + ChaCha20-Poly1305 AEAD на теле. Не защита (DTLS уже шифрует), а обфускация под голос — иначе VK content-filter дропает. Ключ должен совпадать. +`-obf-profile` выбирает wire-профиль маскировки TURN-payload. Не защита (DTLS уже шифрует), а обфускация — иначе VK content-filter дропает payload, не похожий на голосовой трафик. Профиль и ключ должны совпадать на клиенте и сервере. + +Доступные профили: +- **`none`** (default) — обфускация выключена. +- **`rtpopus`** — RTP/opus-заголовок + ChaCha20-Poly1305 AEAD на теле. + +Будущие профили (rtph264, vp8 и т.д.) добавятся по мере необходимости. Сгенерировать ключ: @@ -36,8 +42,8 @@ Запуск: ```bash -./server ... -obf -obf-key <64-hex> -./client ... -obf -obf-key <64-hex> +./server ... -obf-profile rtpopus -obf-key <64-hex> +./client ... -obf-profile rtpopus -obf-key <64-hex> ``` ## TURN Транспорт diff --git a/docs/providers.md b/docs/providers.md index 7903e28..6bf4d4b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -49,7 +49,7 @@ VK Calls API. Перебирает встроенные `app_id/app_secret`, п - 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. +- Application-mode (`-mode udp|tcp`, `-bond`, `-obf-profile`) — поверх TURN, не зависит от источника creds. ## Известные ограничения текущей абстракции diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7b4cd76..00bc278 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,5 +8,5 @@ - VK часто требует captcha → `-manual-captcha`, затем обычный запуск. - Клиент висит на получении TURN-данных → ссылка VK Calls умерла / звонок завершён для всех. - Docker bridge mode → `CONNECT_ADDR=127.0.0.1:...` указывает внутрь контейнера. Используйте `--network host` или IP хоста. -- `-obf` включён → ключ должен совпадать на клиенте и сервере. +- `-obf-profile` != `none` → профиль и ключ должны совпадать на клиенте и сервере. - Bond на сервере не нужен — сервер определяет bond автоматически. Используйте только `-bond` на клиенте с `-mode tcp`. diff --git a/internal/config/config.go b/internal/config/config.go index 51452a1..60f6db0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,7 +16,7 @@ import ( "strings" "github.com/samosvalishe/btp/internal/transport/kcptun" - "github.com/samosvalishe/btp/internal/wire/srtpmimicry" + "github.com/samosvalishe/btp/internal/wire/rtpopus" ) const ( @@ -45,13 +45,26 @@ type TURNOpts struct { N int // -n: число TURN-потоков (только клиент) } -// ObfOpts — опции обфускации TURN-payload (SRTP-mimicry). +// ObfProfile выбирает wire-профиль обфускации TURN-payload. +// Профили живут в internal/wire// — сейчас только rtpopus, +// под добавление новых (rtph264, vp8 и т.д.). +type ObfProfile string + +const ( + ObfProfileNone ObfProfile = "none" // обфускация отключена + ObfProfileRTPOpus ObfProfile = "rtpopus" // RTP/opus + ChaCha20-Poly1305 AEAD +) + +// ObfOpts — опции обфускации TURN-payload. type ObfOpts struct { - Mode bool // -obf: включить SRTP-mimicry AEAD-обёртку - Key []byte // -obf-key (декодированный): 32-байтовый общий ключ; nil если Mode=false - GenKey bool // -gen-obf-key: напечатать новый ключ и выйти + Profile ObfProfile // -obf-profile: none (default) | rtpopus + Key []byte // -obf-key (декодированный): 32-байтовый общий ключ; nil если Profile=none + GenKey bool // -gen-obf-key: напечатать новый ключ и выйти } +// Enabled возвращает true когда выбран реальный профиль обфускации. +func (o ObfOpts) Enabled() bool { return o.Profile != ObfProfileNone } + // ProxyOpts — опции прокси прикладного уровня. type ProxyOpts struct { Mode ProxyMode // udp | tcpfwd | tcpfwd-bond (сервер: udp | tcpfwd) @@ -132,9 +145,9 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { transportFlag := fs.String("transport", "tcp", "транспорт до TURN-реле: tcp (TCP/TLS, default) | udp") modeFlag := fs.String("mode", "udp", "режим туннеля: udp (UDP-релей для WireGuard, default) | tcp (TCP-форвардер для Xray/sing-box)") bondFlag := fs.Bool("bond", false, "распределять одно TCP-соединение по всем активным smux-сессиям (только с -mode tcp)") - 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 и выйти") + obfProfileRaw := fs.String("obf-profile", string(ObfProfileNone), "wire-профиль обфускации TURN-payload: none (default) | rtpopus (RTP/opus + ChaCha20-Poly1305 AEAD для обхода content-filter VK); должен совпадать с сервером") + obfKeyHex := fs.String("obf-key", "", "общий ключ для -obf-profile != none, 32 байта в hex (64 символа)") + genObfKey := fs.Bool("gen-obf-key", false, "напечатать новый ключ для -obf-key и выйти") 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 и сразу открыть ручной режим в локальном браузере (только -provider vk)") @@ -153,8 +166,8 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { N: *n, }, Obf: ObfOpts{ - Mode: *wrapMode, - GenKey: *genWrapKey, + Profile: ObfProfile(*obfProfileRaw), + GenKey: *genObfKey, }, Proxy: ProxyOpts{ Mode: clientProxyMode(*modeFlag, *bondFlag), @@ -226,7 +239,10 @@ func ParseClient(args []string, errOut io.Writer) (*Client, error) { default: return nil, fmt.Errorf("invalid -provider value %q: must be %s", c.Provider.Name, ProviderVK) } - key, err := srtpmimicry.DecodeKey(c.Obf.Mode, *wrapKeyHex) + if err := validateObfProfile(c.Obf.Profile); err != nil { + return nil, err + } + key, err := rtpopus.DecodeKey(c.Obf.Enabled(), *obfKeyHex) if err != nil { return nil, err } @@ -248,9 +264,9 @@ func ParseServer(args []string, errOut io.Writer) (*Server, error) { listen := fs.String("listen", "0.0.0.0:56000", "локальный адрес прослушивания ip:port") connect := fs.String("connect", "", "адрес локального бэкенда, host:port (обязательно: WireGuard 127.0.0.1:51820 или Xray 127.0.0.1:443)") modeFlag := fs.String("mode", "udp", "режим туннеля: udp (UDP-релей для WireGuard, default) | tcp (TCP-форвардер для Xray/sing-box; bond определяется автоматически)") - 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 и выйти") + obfProfileRaw := fs.String("obf-profile", string(ObfProfileNone), "wire-профиль обфускации TURN-payload: none (default) | rtpopus (RTP/opus + ChaCha20-Poly1305 AEAD для обхода content-filter VK); должен совпадать с клиентом") + obfKeyHex := fs.String("obf-key", "", "общий ключ для -obf-profile != none, 32 байта в hex (64 символа)") + genObfKey := fs.Bool("gen-obf-key", false, "напечатать новый ключ для -obf-key и выйти") debugFlag := fs.Bool("debug", false, "включить подробные debug-логи") if err := fs.Parse(args); err != nil { @@ -259,8 +275,8 @@ func ParseServer(args []string, errOut io.Writer) (*Server, error) { s := &Server{ Obf: ObfOpts{ - Mode: *wrapMode, - GenKey: *genWrapKey, + Profile: ObfProfile(*obfProfileRaw), + GenKey: *genObfKey, }, Proxy: ProxyOpts{ Mode: serverProxyMode(*modeFlag), @@ -289,7 +305,10 @@ func ParseServer(args []string, errOut io.Writer) (*Server, error) { if s.Proxy.Connect == "" { return nil, fmt.Errorf("server address is required") } - key, err := srtpmimicry.DecodeKey(s.Obf.Mode, *wrapKeyHex) + if err := validateObfProfile(s.Obf.Profile); err != nil { + return nil, err + } + key, err := rtpopus.DecodeKey(s.Obf.Enabled(), *obfKeyHex) if err != nil { return nil, err } @@ -298,6 +317,16 @@ func ParseServer(args []string, errOut io.Writer) (*Server, error) { return s, nil } +// validateObfProfile проверяет что -obf-profile содержит известное значение. +func validateObfProfile(p ObfProfile) error { + switch p { + case ObfProfileNone, ObfProfileRTPOpus: + return nil + default: + return fmt.Errorf("invalid -obf-profile value %q: must be %s | %s", p, ObfProfileNone, ObfProfileRTPOpus) + } +} + func clientProxyMode(mode string, bond bool) ProxyMode { switch { case mode == "tcp" && bond: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ffa9a6f..093cf06 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -40,7 +40,13 @@ func TestParseClient_Defaults(t *testing.T) { t.Errorf("VK.Link: %q (expected abcdef)", c.VK.Link) } if c.Obf.Key != nil { - t.Errorf("Obf.Key should be nil when -obf absent") + t.Errorf("Obf.Key should be nil when -obf-profile absent") + } + if c.Obf.Profile != ObfProfileNone { + t.Errorf("Obf.Profile default: %q (expected %q)", c.Obf.Profile, ObfProfileNone) + } + if c.Obf.Enabled() { + t.Errorf("Obf.Enabled() should be false when profile=none") } if c.Proxy.Mode != ProxyModeUDP { t.Errorf("Proxy.Mode default: %q (expected udp)", c.Proxy.Mode) @@ -92,15 +98,15 @@ func TestParseClient_BondWithoutTCPMode(t *testing.T) { } func TestParseClient_ObfMissingKey(t *testing.T) { - args := append(validClientArgs(), "-obf") + args := append(validClientArgs(), "-obf-profile", "rtpopus") _, err := ParseClient(args, io.Discard) - if err == nil || !strings.Contains(err.Error(), "-obf requires -obf-key") { + if err == nil || !strings.Contains(err.Error(), "-obf-key") { t.Errorf("expected obf-key error, got %v", err) } } func TestParseClient_ObfKeyOK(t *testing.T) { - args := append(validClientArgs(), "-obf", "-obf-key", strings.Repeat("ab", 32)) + args := append(validClientArgs(), "-obf-profile", "rtpopus", "-obf-key", strings.Repeat("ab", 32)) c, err := ParseClient(args, io.Discard) if err != nil { t.Fatal(err) @@ -108,6 +114,20 @@ func TestParseClient_ObfKeyOK(t *testing.T) { if len(c.Obf.Key) != 32 { t.Errorf("Obf.Key len: %d", len(c.Obf.Key)) } + if c.Obf.Profile != ObfProfileRTPOpus { + t.Errorf("Obf.Profile: %q (expected %q)", c.Obf.Profile, ObfProfileRTPOpus) + } + if !c.Obf.Enabled() { + t.Errorf("Obf.Enabled() should be true when profile=rtpopus") + } +} + +func TestParseClient_ObfProfileInvalid(t *testing.T) { + args := append(validClientArgs(), "-obf-profile", "bogus") + _, err := ParseClient(args, io.Discard) + if err == nil || !strings.Contains(err.Error(), "invalid -obf-profile") { + t.Errorf("expected invalid obf-profile error, got %v", err) + } } func TestParseClient_StreamsPerCredNonPositive(t *testing.T) { @@ -215,27 +235,37 @@ func TestParseServer_MissingConnect(t *testing.T) { } func TestParseServer_ObfMissingKey(t *testing.T) { - _, err := ParseServer([]string{"-connect", "x:1", "-obf"}, io.Discard) - if err == nil || !strings.Contains(err.Error(), "-obf requires -obf-key") { + _, err := ParseServer([]string{"-connect", "x:1", "-obf-profile", "rtpopus"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "-obf-key") { t.Errorf("expected obf-key error, got %v", err) } } func TestParseServer_ObfKeyBadHex(t *testing.T) { - _, err := ParseServer([]string{"-connect", "x:1", "-obf", "-obf-key", "zz"}, io.Discard) + _, err := ParseServer([]string{"-connect", "x:1", "-obf-profile", "rtpopus", "-obf-key", "zz"}, io.Discard) if err == nil || !strings.Contains(err.Error(), "invalid hex") { t.Errorf("expected hex error, got %v", err) } } func TestParseServer_ObfKeyOK(t *testing.T) { - s, err := ParseServer([]string{"-connect", "x:1", "-obf", "-obf-key", strings.Repeat("cd", 32)}, io.Discard) + s, err := ParseServer([]string{"-connect", "x:1", "-obf-profile", "rtpopus", "-obf-key", strings.Repeat("cd", 32)}, io.Discard) if err != nil { t.Fatal(err) } if len(s.Obf.Key) != 32 { t.Errorf("Obf.Key len: %d", len(s.Obf.Key)) } + if s.Obf.Profile != ObfProfileRTPOpus { + t.Errorf("Obf.Profile: %q (expected %q)", s.Obf.Profile, ObfProfileRTPOpus) + } +} + +func TestParseServer_ObfProfileInvalid(t *testing.T) { + _, err := ParseServer([]string{"-connect", "x:1", "-obf-profile", "bogus"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "invalid -obf-profile") { + t.Errorf("expected invalid obf-profile error, got %v", err) + } } func TestParseServer_GenObfKeySkipsConnectCheck(t *testing.T) { diff --git a/internal/proxy/common/common.go b/internal/proxy/common/common.go index 5a9de8c..c8ad108 100644 --- a/internal/proxy/common/common.go +++ b/internal/proxy/common/common.go @@ -1,6 +1,6 @@ // Package common содержит хелперы, общие для udprelay и tcpfwd -// (TURN-dial + создание wrap-кодека). Два режима прокси по-разному компонуют DTLS и -// srtpmimicry, поэтому полная абстракция Engine/Handler намеренно не вводится — +// (TURN-dial + создание obf-кодека). Два режима прокси по-разному компонуют DTLS +// и rtpopus, поэтому полная абстракция Engine/Handler намеренно не вводится — // пакет собирает только действительно идентичный код. package common @@ -10,7 +10,7 @@ import ( "net" "github.com/samosvalishe/btp/internal/transport/turndial" - "github.com/samosvalishe/btp/internal/wire/srtpmimicry" + "github.com/samosvalishe/btp/internal/wire/rtpopus" ) // GetCredsFunc разрешает TURN-реквизиты для streamID. Реализуется provider'ом @@ -33,11 +33,11 @@ func DialTURN(ctx context.Context, host, port string, udp bool, peer *net.UDPAdd }, peer, user, pass, rawURL) } -// NewClientWrap возвращает клиентский srtpmimicry.Conn если key нужной длины, -// иначе (nil, nil) — wrap отключён. Ошибки NewConn пробрасываются вызывающему. -func NewClientWrap(key []byte) (*srtpmimicry.Conn, error) { - if len(key) != srtpmimicry.KeyLen { +// NewClientObf возвращает клиентский rtpopus.Conn если key нужной длины, +// иначе (nil, nil) — обфускация отключена. Ошибки NewConn пробрасываются вызывающему. +func NewClientObf(key []byte) (*rtpopus.Conn, error) { + if len(key) != rtpopus.KeyLen { return nil, nil } - return srtpmimicry.NewConn(key, false) + return rtpopus.NewConn(key, false) } diff --git a/internal/proxy/tcpfwd/tcpfwd.go b/internal/proxy/tcpfwd/tcpfwd.go index 928a553..19c46fe 100644 --- a/internal/proxy/tcpfwd/tcpfwd.go +++ b/internal/proxy/tcpfwd/tcpfwd.go @@ -14,14 +14,14 @@ import ( "github.com/samosvalishe/btp/internal/stats" "github.com/samosvalishe/btp/internal/transport/dtlsdial" "github.com/samosvalishe/btp/internal/transport/kcptun" - "github.com/samosvalishe/btp/internal/wire/srtpmimicry" + "github.com/samosvalishe/btp/internal/wire/rtpopus" "github.com/xtaci/smux" ) // GetCredsFunc реэкспортирован из common, чтобы вызывающие не выходили за пределы импортов пакета. type GetCredsFunc = common.GetCredsFunc -// Params — конфигурация TURN/wrap для пула. +// Params — конфигурация TURN/obf для пула. type Params struct { Host string Port string @@ -241,12 +241,12 @@ func createSmuxSession(ctx context.Context, deps *Deps, params *Params, peer *ne deps.log().Debugf("[session %d] TURN server IP: %s", id, stream.ServerUDPAddr.IP) deps.log().Debugf("relayed-address=%s", relayConn.LocalAddr().String()) - relayWC, err := common.NewClientWrap(params.ObfKey) + obfConn, err := common.NewClientObf(params.ObfKey) if err != nil { cleanup() - return nil, nil, fmt.Errorf("wrap init: %w", err) + return nil, nil, fmt.Errorf("obf init: %w", err) } - dtlsPC := &srtpmimicry.RelayPacketConn{Relay: relayConn, Peer: peer, Conn: relayWC} + dtlsPC := &rtpopus.RelayPacketConn{Relay: relayConn, Peer: peer, Conn: obfConn} dtlsConn, err := deps.DTLSDialer.Dial(ctx, dtlsPC, peer) if err != nil { cleanup() diff --git a/internal/proxy/udprelay/loop.go b/internal/proxy/udprelay/loop.go index 12a0e47..8367322 100644 --- a/internal/proxy/udprelay/loop.go +++ b/internal/proxy/udprelay/loop.go @@ -14,7 +14,7 @@ import ( "github.com/samosvalishe/btp/internal/proxy/common" "github.com/samosvalishe/btp/internal/randx" "github.com/samosvalishe/btp/internal/stats" - "github.com/samosvalishe/btp/internal/wire/srtpmimicry" + "github.com/samosvalishe/btp/internal/wire/rtpopus" ) // DTLSLoop поддерживает единственное DTLS-подключение для streamID, перезапуская @@ -255,9 +255,9 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, } }) var internalPipeAddr atomic.Value - wc, wcErr := common.NewClientWrap(params.ObfKey) - if wcErr != nil { - deps.log().Errorf("[STREAM %d] WRAP init failed: %v", streamID, wcErr) + obfConn, obfErr := common.NewClientObf(params.ObfKey) + if obfErr != nil { + deps.log().Errorf("[STREAM %d] OBF init failed: %v", streamID, obfErr) turncancel() return } @@ -266,8 +266,8 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, defer turncancel() buf := make([]byte, 1600) var wireBuf []byte - if wc != nil { - wireBuf = make([]byte, srtpmimicry.MaxWire(len(buf))) + if obfConn != nil { + wireBuf = make([]byte, rtpopus.MaxWire(len(buf))) } for { if turnctx.Err() != nil { @@ -284,10 +284,10 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, internalPipeAddr.Store(addr1) out := buf[:n] - if wc != nil { - written, wrapErr := wc.WrapInto(wireBuf, out) - if wrapErr != nil { - deps.log().Errorf("[STREAM %d] WRAP failed: %v", streamID, wrapErr) + if obfConn != nil { + written, wErr := obfConn.WrapInto(wireBuf, out) + if wErr != nil { + deps.log().Errorf("[STREAM %d] OBF wrap failed: %v", streamID, wErr) return } out = wireBuf[:written] @@ -304,8 +304,8 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, wg.Go(func() { defer turncancel() readBufLen := 1600 - if wc != nil { - readBufLen = srtpmimicry.MaxWire(1600) + if obfConn != nil { + readBufLen = rtpopus.MaxWire(1600) } buf := make([]byte, readBufLen) plain := make([]byte, 1600) @@ -321,10 +321,10 @@ func oneTURN(ctx context.Context, deps *Deps, params *Params, peer *net.UDPAddr, if addr, ok := addr1.(net.Addr); ok { payload := buf[:n] - if wc != nil { - m, wrapErr := wc.Unwrap(payload, plain) - if wrapErr != nil { - deps.log().Errorf("[STREAM %d] UNWRAP failed: %v (n=%d)", streamID, wrapErr, n) + if obfConn != nil { + m, uErr := obfConn.Unwrap(payload, plain) + if uErr != nil { + deps.log().Errorf("[STREAM %d] OBF unwrap failed: %v (n=%d)", streamID, uErr, n) continue } payload = plain[:m] diff --git a/internal/wire/srtpmimicry/listen.go b/internal/wire/rtpopus/listen.go similarity index 95% rename from internal/wire/srtpmimicry/listen.go rename to internal/wire/rtpopus/listen.go index 5aa0dfe..d57f762 100644 --- a/internal/wire/srtpmimicry/listen.go +++ b/internal/wire/rtpopus/listen.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -package srtpmimicry +package rtpopus import ( "errors" @@ -30,7 +30,7 @@ func Listen(addr *net.UDPAddr, key []byte) (dtlsnet.PacketListener, error) { } inner, err := pionudp.Listen("udp", addr) if err != nil { - return nil, fmt.Errorf("wrap: udp listen: %w", err) + return nil, fmt.Errorf("rtpopus:udp listen: %w", err) } return &packetListener{ inner: dtlsnet.PacketListenerFromListener(inner), @@ -81,7 +81,7 @@ func (c *packetConn) ReadFrom(p []byte) (int, net.Addr, error) { } wire := buf[:n] if len(wire) < Overhead { - return 0, addr, errors.New("wrap: packet too short") + return 0, addr, errors.New("rtpopus:packet too short") } m, err := c.conn.Unwrap(wire, p) if err != nil { diff --git a/internal/wire/srtpmimicry/relay.go b/internal/wire/rtpopus/relay.go similarity index 96% rename from internal/wire/srtpmimicry/relay.go rename to internal/wire/rtpopus/relay.go index cd67ed7..5e509c5 100644 --- a/internal/wire/srtpmimicry/relay.go +++ b/internal/wire/rtpopus/relay.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -package srtpmimicry +package rtpopus import ( "net" @@ -9,7 +9,7 @@ import ( // RelayPacketConn оборачивает TURN relay PacketConn, направляя все записи // фиксированному пиру. Если Conn non-nil, пакеты оборачиваются/разворачиваются -// SRTP-mimicry AEAD. +// rtpopus AEAD (мимикрия под RTP/opus). type RelayPacketConn struct { Relay net.PacketConn Peer net.Addr diff --git a/internal/wire/srtpmimicry/srtpmimicry.go b/internal/wire/rtpopus/rtpopus.go similarity index 87% rename from internal/wire/srtpmimicry/srtpmimicry.go rename to internal/wire/rtpopus/rtpopus.go index 7e22fc2..c1c77e0 100644 --- a/internal/wire/srtpmimicry/srtpmimicry.go +++ b/internal/wire/rtpopus/rtpopus.go @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT -// Package srtpmimicry реализует AEAD-фрейминг с мимикрией SRTP для обхода +// Package rtpopus реализует AEAD-фрейминг с мимикрией под RTP/opus (один +// из планируемых wire-профилей обфускации в internal/wire/). Цель — обход // VK TURN content-filter. // // Назначение: обфускация, а не безопасность. DTLS уже обеспечивает @@ -25,7 +26,7 @@ // AAD = первые 24 байта (RTP header || nonce). // // Wire-формат заморожен — требуется побитовая совместимость с задеплоенными пирами. -package srtpmimicry +package rtpopus import ( "crypto/cipher" @@ -61,11 +62,11 @@ type State struct { func NewState(key []byte) (*State, error) { if len(key) != KeyLen { - return nil, fmt.Errorf("wrap: key must be %d bytes (got %d)", KeyLen, len(key)) + return nil, fmt.Errorf("rtpopus:key must be %d bytes (got %d)", KeyLen, len(key)) } aead, err := chacha20poly1305.New(key) if err != nil { - return nil, fmt.Errorf("wrap: aead init: %w", err) + return nil, fmt.Errorf("rtpopus:aead init: %w", err) } return &State{aead: aead}, nil } @@ -93,13 +94,13 @@ func NewConn(key []byte, isServer bool) (*Conn, error) { // переиспользуя переданный State. func NewConnFromState(state *State, isServer bool) (*Conn, error) { if state == nil { - return nil, errors.New("wrap: nil state") + return nil, errors.New("rtpopus:nil state") } c := &Conn{state: state} var rnd [16]byte if _, err := rand.Read(rnd[:]); err != nil { - return nil, fmt.Errorf("wrap: rand init: %w", err) + return nil, fmt.Errorf("rtpopus:rand init: %w", err) } copy(c.sessionID[:], rnd[0:4]) copy(c.ssrc[:], rnd[4:8]) @@ -115,7 +116,7 @@ func NewConnFromState(state *State, isServer bool) (*Conn, error) { var cb [8]byte if _, err := rand.Read(cb[:]); err != nil { - return nil, fmt.Errorf("wrap: counter rand: %w", err) + return nil, fmt.Errorf("rtpopus:counter rand: %w", err) } c.counter.Store(binary.BigEndian.Uint64(cb[:])) return c, nil @@ -126,7 +127,7 @@ func NewConnFromState(state *State, isServer bool) (*Conn, error) { func (c *Conn) WrapInto(dst, payload []byte) (int, error) { wireLen := Overhead + len(payload) if len(dst) < wireLen { - return 0, errors.New("wrap: dst buffer too small") + return 0, errors.New("rtpopus:dst buffer too small") } // RTP-заголовок. @@ -158,7 +159,7 @@ func (c *Conn) WrapInto(dst, payload []byte) (int, error) { // потреблённым после вызова (содержимое нельзя переиспользовать). func (c *Conn) Unwrap(wire, dst []byte) (int, error) { if len(wire) < Overhead { - return 0, errors.New("wrap: packet too short") + return 0, errors.New("rtpopus:packet too short") } nonce := wire[rtpHdrLen : rtpHdrLen+nonceLen] aad := wire[:headerLen] @@ -166,10 +167,10 @@ func (c *Conn) Unwrap(wire, dst []byte) (int, error) { plain, err := c.state.aead.Open(ct[:0], nonce, ct, aad) if err != nil { - return 0, fmt.Errorf("wrap: AEAD open: %w", err) + return 0, fmt.Errorf("rtpopus:AEAD open: %w", err) } if len(plain) > len(dst) { - return 0, errors.New("wrap: dst buffer too small") + return 0, errors.New("rtpopus:dst buffer too small") } copy(dst[:len(plain)], plain) return len(plain), nil @@ -178,7 +179,7 @@ func (c *Conn) Unwrap(wire, dst []byte) (int, error) { func GenKeyHex() (string, error) { key := make([]byte, KeyLen) if _, err := rand.Read(key); err != nil { - return "", fmt.Errorf("wrap: key gen: %w", err) + return "", fmt.Errorf("rtpopus:key gen: %w", err) } return hex.EncodeToString(key), nil } @@ -190,7 +191,7 @@ func DecodeKey(enabled bool, raw string) ([]byte, error) { return nil, nil } if raw == "" { - return nil, errors.New("-obf requires -obf-key") + return nil, errors.New("-obf-profile != none requires -obf-key") } key, err := hex.DecodeString(raw) if err != nil { diff --git a/internal/wire/srtpmimicry/srtpmimicry_test.go b/internal/wire/rtpopus/rtpopus_test.go similarity index 99% rename from internal/wire/srtpmimicry/srtpmimicry_test.go rename to internal/wire/rtpopus/rtpopus_test.go index 5abac21..e7697a9 100644 --- a/internal/wire/srtpmimicry/srtpmimicry_test.go +++ b/internal/wire/rtpopus/rtpopus_test.go @@ -1,4 +1,4 @@ -package srtpmimicry +package rtpopus import ( "bytes"