mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
feat(netconn): анти-DPI мульти-сплит ClientHello по SNI
MultiSplitWriteConn парсит TLS ClientHello, находит SNI host_name и режет первый сегмент внутри него на ~трети с джиттером - registrable-домен гарантированно пересекает границу TCP-сегмента, DPI без реассемблинга не собирает hostname. Фоллбэк на одиночный рез offset 6, если SNI не найден. turndial: STUN-split offset рандомизирован в [5,7] (рез внутри magic cookie), убран статический фингерпринт фикс-offset.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package netconn
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
)
|
||||
|
||||
// MultiSplitWriteConn оборачивает TCP net.Conn и разбивает первый Write на
|
||||
// несколько сегментов так, чтобы SNI из TLS ClientHello попадал на границу.
|
||||
// DPI без TCP-реассемблинга не собирает hostname и не инжектит SNI-based RST.
|
||||
//
|
||||
// В отличие от SplitFirstWriteConn (один рез на фиксированном offset - рвёт
|
||||
// только TLS record header, SNI остаётся целым) парсит ClientHello и режет
|
||||
// внутри host_name. Если первый Write не ClientHello или SNI не найден -
|
||||
// фоллбэк на одиночный рез FallbackSplitAt (поведение SplitFirstWriteConn).
|
||||
type MultiSplitWriteConn struct {
|
||||
net.Conn
|
||||
// Delay - базовая пауза между сегментами.
|
||||
Delay time.Duration
|
||||
// Jitter - верхняя граница случайной добавки к Delay (антифингерпринт тайминга).
|
||||
Jitter time.Duration
|
||||
// FallbackSplitAt - offset одиночного реза, когда SNI не найден. 0 - без реза.
|
||||
FallbackSplitAt int
|
||||
done atomic.Bool
|
||||
}
|
||||
|
||||
// Write на первом вызове дробит ClientHello по границам внутри host_name (SNI),
|
||||
// далее проксирует напрямую.
|
||||
func (s *MultiSplitWriteConn) Write(b []byte) (int, error) {
|
||||
if !s.done.CompareAndSwap(false, true) {
|
||||
return s.Conn.Write(b)
|
||||
}
|
||||
offsets := s.splitOffsets(b)
|
||||
if len(offsets) == 0 {
|
||||
return s.Conn.Write(b)
|
||||
}
|
||||
|
||||
bounds := make([]int, len(offsets)+1)
|
||||
copy(bounds, offsets)
|
||||
bounds[len(offsets)] = len(b)
|
||||
|
||||
total, prev := 0, 0
|
||||
for i, bound := range bounds {
|
||||
if i > 0 && s.Delay > 0 {
|
||||
d := s.Delay
|
||||
if s.Jitter > 0 {
|
||||
d += time.Duration(randx.Intn(int(s.Jitter)))
|
||||
}
|
||||
time.Sleep(d)
|
||||
}
|
||||
n, err := s.Conn.Write(b[prev:bound])
|
||||
total += n
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
prev = bound
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// splitOffsets возвращает отсортированные точки реза первого сегмента. Сначала
|
||||
// пробует SNI (рез внутри host_name), иначе одиночный FallbackSplitAt.
|
||||
func (s *MultiSplitWriteConn) splitOffsets(b []byte) []int {
|
||||
if start, end, ok := clientHelloSNIRange(b); ok {
|
||||
if offs := sniSplitOffsets(start, end); len(offs) > 0 {
|
||||
return offs
|
||||
}
|
||||
}
|
||||
if s.FallbackSplitAt > 0 && len(b) > s.FallbackSplitAt {
|
||||
return []int{s.FallbackSplitAt}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sniSplitOffsets делит host_name [start,end) на ~трети с джиттером. Гарантирует:
|
||||
// registrable-домен (vk.ru и т.п.) пересекает хотя бы одну границу независимо от
|
||||
// раскладки лейблов. Короткий hostname (len 2) - один рез посередине; len<2 -
|
||||
// без реза (nil, уходит на фоллбэк).
|
||||
func sniSplitOffsets(start, end int) []int {
|
||||
l := end - start
|
||||
switch {
|
||||
case l < 2:
|
||||
return nil
|
||||
case l < 3:
|
||||
return []int{start + 1}
|
||||
default:
|
||||
third := l / 3
|
||||
c1 := start + 1 + randx.Intn(third) // [start+1, start+third]
|
||||
c2 := end - third + randx.Intn(third) // [end-third, end-1]
|
||||
if c2 <= c1 {
|
||||
c2 = c1 + 1
|
||||
}
|
||||
return []int{c1, c2}
|
||||
}
|
||||
}
|
||||
|
||||
// clientHelloSNIRange парсит TLS ClientHello (b начинается с TLS record header)
|
||||
// и возвращает байтовый диапазон [start,end) первого host_name в SNI-расширении.
|
||||
// ok=false если b не ClientHello-record либо SNI/host_name отсутствует.
|
||||
func clientHelloSNIRange(b []byte) (int, int, bool) {
|
||||
u16 := func(i int) int { return int(b[i])<<8 | int(b[i+1]) }
|
||||
|
||||
// record layer: type(1)=0x16 handshake, version(2), length(2)
|
||||
if len(b) < 5 || b[0] != 0x16 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// handshake: msg_type(1)=0x01 ClientHello, length(3)
|
||||
p := 5
|
||||
if p+4 > len(b) || b[p] != 0x01 {
|
||||
return 0, 0, false
|
||||
}
|
||||
p += 4
|
||||
p += 2 + 32 // client_version + random
|
||||
if p+1 > len(b) {
|
||||
return 0, 0, false
|
||||
}
|
||||
p += 1 + int(b[p]) // session_id
|
||||
if p+2 > len(b) {
|
||||
return 0, 0, false
|
||||
}
|
||||
p += 2 + u16(p) // cipher_suites
|
||||
if p+1 > len(b) {
|
||||
return 0, 0, false
|
||||
}
|
||||
p += 1 + int(b[p]) // compression_methods
|
||||
if p+2 > len(b) {
|
||||
return 0, 0, false
|
||||
}
|
||||
extEnd := p + 2 + u16(p)
|
||||
p += 2
|
||||
if extEnd > len(b) {
|
||||
extEnd = len(b)
|
||||
}
|
||||
for p+4 <= extEnd {
|
||||
etype := u16(p)
|
||||
elen := u16(p + 2)
|
||||
p += 4
|
||||
if p+elen > extEnd {
|
||||
break
|
||||
}
|
||||
if etype == 0x0000 { // server_name
|
||||
return parseSNIExtension(b, p, p+elen)
|
||||
}
|
||||
p += elen
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// parseSNIExtension ищет первый host_name (name_type 0) в server_name_list
|
||||
// внутри [p,end). Возвращает абсолютный диапазон host_name в b.
|
||||
func parseSNIExtension(b []byte, p, end int) (int, int, bool) {
|
||||
u16 := func(i int) int { return int(b[i])<<8 | int(b[i+1]) }
|
||||
if p+2 > end {
|
||||
return 0, 0, false
|
||||
}
|
||||
listEnd := p + 2 + u16(p) // server_name_list length
|
||||
p += 2
|
||||
if listEnd > end {
|
||||
listEnd = end
|
||||
}
|
||||
for p+3 <= listEnd {
|
||||
nameType := b[p]
|
||||
nameLen := u16(p + 1)
|
||||
p += 3
|
||||
if p+nameLen > listEnd {
|
||||
break
|
||||
}
|
||||
if nameType == 0x00 { // host_name
|
||||
return p, p + nameLen, true
|
||||
}
|
||||
p += nameLen
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package netconn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// u8 - узкое преобразование длины в байт для тестового билдера ClientHello.
|
||||
// Длины здесь малы и ограничены, переполнение невозможно.
|
||||
//
|
||||
//nolint:gosec // G115: тестовые длины малы и ограничены
|
||||
func u8(v int) byte { return byte(v) }
|
||||
|
||||
// buildClientHello собирает минимальный валидный TLS ClientHello-record с
|
||||
// заданным SNI host_name. Достаточно для проверки парсера и сегментации.
|
||||
func buildClientHello(host string) []byte {
|
||||
hn := []byte(host)
|
||||
entry := append([]byte{0x00, u8(len(hn) >> 8), u8(len(hn))}, hn...) // name_type=host_name + len + host
|
||||
sni := append([]byte{u8(len(entry) >> 8), u8(len(entry))}, entry...) // server_name_list len + entry
|
||||
ext := append([]byte{0x00, 0x00, u8(len(sni) >> 8), u8(len(sni))}, sni...) // ext type 0 + len + body
|
||||
|
||||
body := make([]byte, 0, 64)
|
||||
body = append(body, 0x03, 0x03) // client_version
|
||||
body = append(body, make([]byte, 32)...) // random
|
||||
body = append(body, 0x00) // session_id len 0
|
||||
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher_suites len 2 + TLS_AES_128_GCM_SHA256
|
||||
body = append(body, 0x01, 0x00) // compression methods len 1 + null
|
||||
body = append(body, u8(len(ext)>>8), u8(len(ext))) // extensions length
|
||||
body = append(body, ext...)
|
||||
|
||||
hs := append([]byte{0x01, u8(len(body) >> 16), u8(len(body) >> 8), u8(len(body))}, body...)
|
||||
rec := append([]byte{0x16, 0x03, 0x01, u8(len(hs) >> 8), u8(len(hs))}, hs...)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestClientHelloSNIRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, host := range []string{"login.vk.ru", "api.vk.ru", "ok.ru", "calls.okcdn.ru"} {
|
||||
ch := buildClientHello(host)
|
||||
start, end, ok := clientHelloSNIRange(ch)
|
||||
if !ok {
|
||||
t.Fatalf("%s: SNI not found", host)
|
||||
}
|
||||
if got := string(ch[start:end]); got != host {
|
||||
t.Fatalf("host range = %q, want %q", got, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHelloSNIRange_NotTLS(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, _, ok := clientHelloSNIRange([]byte("not a tls record at all")); ok {
|
||||
t.Fatal("expected ok=false for non-TLS payload")
|
||||
}
|
||||
if _, _, ok := clientHelloSNIRange([]byte{0x16, 0x03}); ok {
|
||||
t.Fatal("expected ok=false for truncated record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSNISplitOffsets_InsideHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := buildClientHello("api.vk.ru")
|
||||
start, end, ok := clientHelloSNIRange(ch)
|
||||
if !ok {
|
||||
t.Fatal("SNI not found")
|
||||
}
|
||||
for range 64 {
|
||||
offs := sniSplitOffsets(start, end)
|
||||
if len(offs) < 1 || len(offs) > 2 {
|
||||
t.Fatalf("want 1-2 offsets, got %d (%v)", len(offs), offs)
|
||||
}
|
||||
for j, o := range offs {
|
||||
if o <= start || o >= end {
|
||||
t.Fatalf("offset %d = %d outside (%d,%d)", j, o, start, end)
|
||||
}
|
||||
if j > 0 && o <= offs[j-1] {
|
||||
t.Fatalf("offsets not strictly ascending: %v", offs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSplitWriteConn_SplitsSNI(t *testing.T) {
|
||||
t.Parallel()
|
||||
host := "login.vk.ru"
|
||||
ch := buildClientHello(host)
|
||||
|
||||
for range 32 {
|
||||
rec := newRecorder()
|
||||
c := &MultiSplitWriteConn{Conn: rec, FallbackSplitAt: 6}
|
||||
n, err := c.Write(ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Write err: %v", err)
|
||||
}
|
||||
if n != len(ch) {
|
||||
t.Fatalf("Write n=%d, want %d", n, len(ch))
|
||||
}
|
||||
if len(rec.writes) < 2 {
|
||||
t.Fatalf("expected multi-segment, got %d", len(rec.writes))
|
||||
}
|
||||
|
||||
var got []byte
|
||||
for _, w := range rec.writes {
|
||||
got = append(got, w...)
|
||||
}
|
||||
if !bytes.Equal(got, ch) {
|
||||
t.Fatal("reassembled bytes differ from original")
|
||||
}
|
||||
// Анти-DPI инвариант: ни один сегмент не содержит hostname целиком.
|
||||
hb := []byte(host)
|
||||
for _, w := range rec.writes {
|
||||
if bytes.Contains(w, hb) {
|
||||
t.Fatalf("segment contains full SNI %q (writes=%d)", host, len(rec.writes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSplitWriteConn_FallbackNoSNI(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := newRecorder()
|
||||
c := &MultiSplitWriteConn{Conn: rec, FallbackSplitAt: 6}
|
||||
payload := []byte("plain bytes, not a tls clienthello here")
|
||||
if _, err := c.Write(payload); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if len(rec.writes) != 2 {
|
||||
t.Fatalf("expected 2 fallback segments, got %d", len(rec.writes))
|
||||
}
|
||||
if !bytes.Equal(rec.writes[0], payload[:6]) || !bytes.Equal(rec.writes[1], payload[6:]) {
|
||||
t.Fatal("fallback single-split offset wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSplitWriteConn_SubsequentPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := buildClientHello("api.vk.ru")
|
||||
rec := newRecorder()
|
||||
c := &MultiSplitWriteConn{Conn: rec, FallbackSplitAt: 6}
|
||||
if _, err := c.Write(ch); err != nil {
|
||||
t.Fatalf("first Write: %v", err)
|
||||
}
|
||||
before := len(rec.writes)
|
||||
if _, err := c.Write([]byte("XYZ")); err != nil {
|
||||
t.Fatalf("second Write: %v", err)
|
||||
}
|
||||
if len(rec.writes) != before+1 {
|
||||
t.Fatalf("second write should be single segment: before=%d after=%d", before, len(rec.writes))
|
||||
}
|
||||
if !bytes.Equal(rec.writes[before], []byte("XYZ")) {
|
||||
t.Fatalf("subsequent write = %q, want XYZ", rec.writes[before])
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/turn/v5"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/netconn"
|
||||
"github.com/samosvalishe/free-turn-proxy/internal/randx"
|
||||
)
|
||||
|
||||
// Config конфигурирует один вызов Open.
|
||||
@@ -25,17 +26,17 @@ type Config struct {
|
||||
HostOverride string
|
||||
// PortOverride, если непустой, заменяет port из lookup credentials.
|
||||
PortOverride string
|
||||
// TransportUDP=true — dial TURN по UDP; иначе по TCP через STUNConn.
|
||||
// TransportUDP=true - dial TURN по UDP; иначе по TCP через STUNConn.
|
||||
TransportUDP bool
|
||||
// DialTimeout ограничивает TCP dial. Ноль → 5s.
|
||||
// DialTimeout ограничивает TCP dial. Ноль -> 5s.
|
||||
DialTimeout time.Duration
|
||||
}
|
||||
|
||||
// Stream — активная TURN-аллокация с зависимостями. Close разрушает в обратном порядке.
|
||||
// Stream - активная TURN-аллокация с зависимостями. Close разрушает в обратном порядке.
|
||||
type Stream struct {
|
||||
// Relay — выделенный relay PacketConn из turn.Client.Allocate.
|
||||
// Relay - выделенный relay PacketConn из turn.Client.Allocate.
|
||||
Relay net.PacketConn
|
||||
// ServerUDPAddr — резолвнутый UDP-адрес TURN-сервера (host:port).
|
||||
// ServerUDPAddr - резолвнутый UDP-адрес TURN-сервера (host:port).
|
||||
ServerUDPAddr *net.UDPAddr
|
||||
// PermDead закрывается при стойком провале ChannelBind refresh (relay
|
||||
// блэкхолит data-path) - вызывающий рециклит allocation. См. permwatch.go.
|
||||
@@ -52,8 +53,8 @@ func (s *Stream) Close() error {
|
||||
return s.close()
|
||||
}
|
||||
|
||||
// Open подключается к TURN, создаёт turn.Client и выделяет relay. rawAddr —
|
||||
// host:port из lookup credentials; user/pass — долгосрочные TURN-реквизиты.
|
||||
// Open подключается к TURN, создаёт turn.Client и выделяет relay. rawAddr -
|
||||
// host:port из lookup credentials; user/pass - долгосрочные TURN-реквизиты.
|
||||
func Open(ctx context.Context, cfg Config, peer *net.UDPAddr, user, pass, rawAddr string) (*Stream, error) {
|
||||
urlhost, urlport, err := net.SplitHostPort(rawAddr)
|
||||
if err != nil {
|
||||
@@ -96,7 +97,9 @@ func Open(ctx context.Context, cfg Config, peer *net.UDPAddr, user, pass, rawAdd
|
||||
if derr != nil {
|
||||
return nil, fmt.Errorf("dial TURN (tcp): %w", derr)
|
||||
}
|
||||
wrapped := &netconn.SplitFirstWriteConn{Conn: c, SplitAt: 6, Delay: 20 * time.Millisecond}
|
||||
// Рез внутри STUN magic cookie (байты 4-7) рвёт DPI-матч на cookie.
|
||||
// Offset рандомен в [5,7] - убирает статический фингерпринт фикс-offset.
|
||||
wrapped := &netconn.SplitFirstWriteConn{Conn: c, SplitAt: 5 + randx.Intn(3), Delay: 20 * time.Millisecond}
|
||||
turnConn = turn.NewSTUNConn(wrapped)
|
||||
closeConn = c.Close
|
||||
}
|
||||
@@ -108,7 +111,7 @@ func Open(ctx context.Context, cfg Config, peer *net.UDPAddr, user, pass, rawAdd
|
||||
addrFamily = turn.RequestedAddressFamilyIPv6
|
||||
}
|
||||
|
||||
// Standalone CreatePermission refresh VK реджектит 400 — глушим (24h).
|
||||
// Standalone CreatePermission refresh VK реджектит 400 - глушим (24h).
|
||||
// Permission держится живым через ChannelBind refresh (RFC 8656 §11);
|
||||
// блэкхол ловим по провалу этого байнда (см. permwatch.go).
|
||||
permDead := make(chan struct{})
|
||||
|
||||
Reference in New Issue
Block a user