refactor(wire): переименовать srtpmimicry → rtpopus, заменить bool -obf на -obf-profile

Готовим зонтик `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`

Доки + тесты подтянуты под новые имена.
This commit is contained in:
samosvalishe
2026-05-23 15:24:00 +03:00
parent cf47f374b3
commit 4bedd00662
19 changed files with 179 additions and 110 deletions
+4 -4
View File
@@ -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
+12 -12
View File
@@ -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...)
}
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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` |
Сборка образа:
+4 -4
View File
@@ -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-логи |
+7 -4
View File
@@ -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)
+10 -4
View File
@@ -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 Транспорт
+1 -1
View File
@@ -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.
## Известные ограничения текущей абстракции
+1 -1
View File
@@ -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`.
+46 -17
View File
@@ -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/<profile>/ — сейчас только 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:
+38 -8
View File
@@ -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) {
+8 -8
View File
@@ -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)
}
+5 -5
View File
@@ -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()
+16 -16
View File
@@ -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]
@@ -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 {
@@ -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
@@ -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 {
@@ -1,4 +1,4 @@
package srtpmimicry
package rtpopus
import (
"bytes"