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:
samosvalishe
2026-06-17 09:26:09 +03:00
parent 5851ad6959
commit cc405100ab
3 changed files with 342 additions and 9 deletions
+177
View File
@@ -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
}
+153
View File
@@ -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])
}
}
+12 -9
View File
@@ -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{})