diff --git a/internal/netconn/multisplit.go b/internal/netconn/multisplit.go new file mode 100644 index 0000000..bc60ba6 --- /dev/null +++ b/internal/netconn/multisplit.go @@ -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 +} diff --git a/internal/netconn/multisplit_test.go b/internal/netconn/multisplit_test.go new file mode 100644 index 0000000..b916ee4 --- /dev/null +++ b/internal/netconn/multisplit_test.go @@ -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]) + } +} diff --git a/internal/transport/turndial/turndial.go b/internal/transport/turndial/turndial.go index 613b401..3efa542 100644 --- a/internal/transport/turndial/turndial.go +++ b/internal/transport/turndial/turndial.go @@ -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{})