From 58697121a92a199d28bd34219459dd87e3acf661 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 10:09:20 +0300 Subject: [PATCH 01/11] experiment: add RFC 6062 TURN TCP probe --- cmd/turn-tcp-probe/main.go | 90 +++++++++++++++ core/turn_tcp_probe.go | 194 +++++++++++++++++++++++++++++++++ core/turn_tcp_probe_test.go | 26 +++++ docs/turn-tcp-rfc6062-probe.md | 59 ++++++++++ 4 files changed, 369 insertions(+) create mode 100644 cmd/turn-tcp-probe/main.go create mode 100644 core/turn_tcp_probe.go create mode 100644 core/turn_tcp_probe_test.go create mode 100644 docs/turn-tcp-rfc6062-probe.md diff --git a/cmd/turn-tcp-probe/main.go b/cmd/turn-tcp-probe/main.go new file mode 100644 index 00000000..6ece0c41 --- /dev/null +++ b/cmd/turn-tcp-probe/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net" + "os" + "strings" + "time" + + core "wg-turn-client" +) + +func main() { + hash := flag.String("hash", "", "VK call hash or join link") + target := flag.String("target", "example.com:80", "TCP target in host:port form") + mode := flag.String("mode", "connect", "probe mode: connect or http") + timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") + flag.Parse() + + if strings.TrimSpace(*hash) == "" { + fmt.Fprintln(os.Stderr, "error: -hash is required") + flag.Usage() + os.Exit(2) + } + + payload, err := probePayload(*mode, *target) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + + fmt.Println("TURN TCP RFC 6062 probe") + fmt.Printf("Target: %s\n", *target) + fmt.Printf("Mode: %s\n", strings.ToLower(strings.TrimSpace(*mode))) + fmt.Println("Obtaining VK TURN credentials and trying TCP allocation...") + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + result, err := core.ProbeTurnTCP(ctx, core.TurnTCPProbeConfig{ + Hash: *hash, + Target: *target, + Payload: payload, + Timeout: *timeout, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) + os.Exit(1) + } + + fmt.Println("SUCCESS") + fmt.Printf("TURN endpoint: %s\n", result.TurnAddress) + fmt.Printf("Relayed address: %s\n", result.RelayAddress) + fmt.Printf("Target: %s\n", result.Target) + + if result.ResponsePreview != "" { + fmt.Println("Response preview:") + fmt.Print(result.ResponsePreview) + if !strings.HasSuffix(result.ResponsePreview, "\n") { + fmt.Println() + } + } +} + +func probePayload(mode, target string) ([]byte, error) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "connect": + return nil, nil + case "http": + host, port, err := net.SplitHostPort(strings.TrimSpace(target)) + if err != nil { + return nil, fmt.Errorf("parse target: %w", err) + } + if port != "80" { + return nil, fmt.Errorf("http mode requires target port 80, got %s", port) + } + host = strings.Trim(host, "[]") + if host == "" { + return nil, fmt.Errorf("target host is empty") + } + return []byte(fmt.Sprintf( + "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\nUser-Agent: PWDTT-TURN-TCP-Probe/1\r\n\r\n", + host, + )), nil + default: + return nil, fmt.Errorf("unsupported -mode %q; use connect or http", mode) + } +} diff --git a/core/turn_tcp_probe.go b/core/turn_tcp_probe.go new file mode 100644 index 00000000..02f4e832 --- /dev/null +++ b/core/turn_tcp_probe.go @@ -0,0 +1,194 @@ +package core + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/pion/turn/v5" +) + +const ( + defaultTurnTCPProbeTimeout = 45 * time.Second + turnTCPProbeReadLimit = 4096 +) + +var turnTCPProbeSequence atomic.Int64 + +// TurnTCPProbeConfig describes a one-shot RFC 6062 capability probe. +// Payload is optional: when empty, a successful relayed TCP connection is enough. +type TurnTCPProbeConfig struct { + Hash string + Target string + Payload []byte + Timeout time.Duration +} + +// TurnTCPProbeResult contains only non-secret diagnostic data. +type TurnTCPProbeResult struct { + TurnAddress string + RelayAddress string + Target string + ResponsePreview string +} + +// ProbeTurnTCP checks whether VK TURN credentials can be used for an RFC 6062 +// TCP allocation and an outgoing TCP connection to cfg.Target. +// +// It does not start WireGuard, does not contact a wdtt-server and never logs +// TURN credentials. +func ProbeTurnTCP(parent context.Context, cfg TurnTCPProbeConfig) (TurnTCPProbeResult, error) { + var result TurnTCPProbeResult + + if parent == nil { + parent = context.Background() + } + + hash := strings.TrimSpace(cfg.Hash) + if hash == "" { + return result, fmt.Errorf("VK hash is required") + } + + target := strings.TrimSpace(cfg.Target) + if target == "" { + return result, fmt.Errorf("target is required") + } + + targetAddr, err := net.ResolveTCPAddr("tcp", target) + if err != nil { + return result, fmt.Errorf("resolve target %q: %w", target, err) + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTurnTCPProbeTimeout + } + + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + streamID := int(980000 + turnTCPProbeSequence.Add(1)) + turnUser, turnPass, turnURLs, err := GetCreds(ctx, hash, streamID) + if err != nil { + return result, fmt.Errorf("get VK TURN credentials: %w", err) + } + if len(turnURLs) == 0 { + return result, fmt.Errorf("VK returned no TURN endpoints") + } + + var attempts []error + for _, candidate := range turnURLs { + if err := ctx.Err(); err != nil { + attempts = append(attempts, err) + break + } + + turnAddr := strings.TrimSpace(candidate) + if turnAddr == "" { + continue + } + + result, err = probeTurnTCPAtEndpoint(ctx, turnAddr, turnUser, turnPass, targetAddr, cfg.Payload) + if err == nil { + return result, nil + } + + attempts = append(attempts, fmt.Errorf("%s: %w", turnAddr, err)) + } + + if len(attempts) == 0 { + return result, fmt.Errorf("VK returned no usable TURN endpoints") + } + + return result, fmt.Errorf("all TURN TCP probe attempts failed: %w", errors.Join(attempts...)) +} + +func probeTurnTCPAtEndpoint( + ctx context.Context, + turnAddr string, + username string, + password string, + target *net.TCPAddr, + payload []byte, +) (TurnTCPProbeResult, error) { + result := TurnTCPProbeResult{ + TurnAddress: turnAddr, + Target: target.String(), + } + + serverAddr, err := net.ResolveTCPAddr("tcp", turnAddr) + if err != nil { + return result, fmt.Errorf("resolve TURN TCP endpoint: %w", err) + } + + controlConn, err := net.DialTCP("tcp", nil, serverAddr) + if err != nil { + return result, fmt.Errorf("connect to TURN over TCP: %w", err) + } + defer controlConn.Close() + + stopOnCancel := context.AfterFunc(ctx, func() { + _ = controlConn.Close() + }) + defer stopOnCancel() + + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Conn: turn.NewSTUNConn(controlConn), + Username: username, + Password: password, + LoggerFactory: &NullLoggerFactory{}, + }) + if err != nil { + return result, fmt.Errorf("create TURN client: %w", err) + } + defer client.Close() + + if err := client.Listen(); err != nil { + return result, fmt.Errorf("TURN listen: %w", err) + } + + allocation, err := client.AllocateTCP() + if err != nil { + return result, fmt.Errorf("AllocateTCP: %w", err) + } + defer allocation.Close() + + result.RelayAddress = allocation.Addr().String() + + relayedConn, err := allocation.DialTCP("tcp", nil, target) + if err != nil { + return result, fmt.Errorf("DialTCP %s: %w", target, err) + } + defer relayedConn.Close() + + if len(payload) == 0 { + return result, nil + } + + deadline := time.Now().Add(10 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + if err := relayedConn.SetDeadline(deadline); err != nil { + return result, fmt.Errorf("set relayed connection deadline: %w", err) + } + + if _, err := relayedConn.Write(payload); err != nil { + return result, fmt.Errorf("write probe payload: %w", err) + } + + buf := make([]byte, turnTCPProbeReadLimit) + n, err := relayedConn.Read(buf) + if err != nil { + return result, fmt.Errorf("read probe response: %w", err) + } + result.ResponsePreview = string(buf[:n]) + + return result, nil +} diff --git a/core/turn_tcp_probe_test.go b/core/turn_tcp_probe_test.go new file mode 100644 index 00000000..f7e79c81 --- /dev/null +++ b/core/turn_tcp_probe_test.go @@ -0,0 +1,26 @@ +package core + +import ( + "context" + "strings" + "testing" +) + +func TestProbeTurnTCPRejectsEmptyHash(t *testing.T) { + _, err := ProbeTurnTCP(context.Background(), TurnTCPProbeConfig{ + Target: "example.com:80", + }) + if err == nil || !strings.Contains(err.Error(), "VK hash is required") { + t.Fatalf("expected VK hash validation error, got %v", err) + } +} + +func TestProbeTurnTCPRejectsInvalidTarget(t *testing.T) { + _, err := ProbeTurnTCP(context.Background(), TurnTCPProbeConfig{ + Hash: "test-hash", + Target: "missing-port", + }) + if err == nil || !strings.Contains(err.Error(), "resolve target") { + t.Fatalf("expected target validation error, got %v", err) + } +} diff --git a/docs/turn-tcp-rfc6062-probe.md b/docs/turn-tcp-rfc6062-probe.md new file mode 100644 index 00000000..5d301d00 --- /dev/null +++ b/docs/turn-tcp-rfc6062-probe.md @@ -0,0 +1,59 @@ +# Эксперимент: TURN TCP / RFC 6062 без VPS + +## Гипотеза + +Проверить, разрешает ли используемая PWDTT инфраструктура VK/OK TURN: + +1. TCP allocation по RFC 6062; +2. исходящий TCP `CONNECT` к произвольному peer; +3. двустороннюю передачу данных без `wdtt-server` и без VPS. + +Эксперимент не меняет обычный режим PWDTT. Он запускается отдельной CLI-командой. + +## Запуск + +Нужен рабочий VK call hash, который PWDTT уже умеет использовать для получения TURN credentials. + +Из корня репозитория: + +```powershell +go run ./cmd/turn-tcp-probe -hash "" -target "example.com:80" -mode http +``` + +Успешный результат должен содержать: + +```text +SUCCESS +TURN endpoint: ... +Relayed address: ... +Target: ... +Response preview: +HTTP/1.1 ... +``` + +Это подтверждает одновременно TCP allocation, исходящий TCP peer и двустороннюю передачу данных через TURN. + +Для проверки только установки TCP-соединения: + +```powershell +go run ./cmd/turn-tcp-probe -hash "" -target "telegram.org:443" -mode connect +``` + +## Интерпретация ошибок + +- ошибка на `AllocateTCP` — сервер не поддерживает или политикой запрещает TCP allocation; +- ошибка на `DialTCP` / `CreatePermission` / `Connect` — allocation создан, но peer или исходящее TCP-соединение запрещены/недоступны; +- `SUCCESS` в режиме `connect` — TCP-соединение к peer через TURN установлено; +- `SUCCESS` + HTTP response в режиме `http` — подтверждена двусторонняя передача прикладных данных. + +CLI перебирает TURN endpoints, полученные для указанного VK hash, и выводит ошибки без username/password. + +## Ограничения + +Это диагностический Prototype, а не production proxy: + +- не поднимает SOCKS5; +- не интегрирован в GUI; +- не маршрутизирует системный трафик; +- не гарантирует, что политика TURN одинакова для всех endpoint/аккаунтов/сетей; +- работа зависит от внешней инфраструктуры VK/OK и может измениться независимо от PWDTT. From f2767c2552f8dc3bb9f8712fcca3d807fd1aa748 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 10:13:55 +0300 Subject: [PATCH 02/11] docs: clarify TURN probe hash input --- cmd/turn-tcp-probe/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/turn-tcp-probe/main.go b/cmd/turn-tcp-probe/main.go index 6ece0c41..9c809e15 100644 --- a/cmd/turn-tcp-probe/main.go +++ b/cmd/turn-tcp-probe/main.go @@ -13,7 +13,7 @@ import ( ) func main() { - hash := flag.String("hash", "", "VK call hash or join link") + hash := flag.String("hash", "", "VK call hash (the part after /call/join/)") target := flag.String("target", "example.com:80", "TCP target in host:port form") mode := flag.String("mode", "connect", "probe mode: connect or http") timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") From 39e7883dc36aad945b7b1b8b24d2f1dba74da394 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 10:33:08 +0300 Subject: [PATCH 03/11] experiment: add direct UDP TURN egress probe --- cmd/turn-udp-probe/main.go | 53 ++++++ core/turn_udp_probe.go | 283 +++++++++++++++++++++++++++++++++ core/turn_udp_probe_test.go | 62 ++++++++ docs/turn-tcp-rfc6062-probe.md | 81 ++++++---- 4 files changed, 450 insertions(+), 29 deletions(-) create mode 100644 cmd/turn-udp-probe/main.go create mode 100644 core/turn_udp_probe.go create mode 100644 core/turn_udp_probe_test.go diff --git a/cmd/turn-udp-probe/main.go b/cmd/turn-udp-probe/main.go new file mode 100644 index 00000000..90ae9e43 --- /dev/null +++ b/cmd/turn-udp-probe/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + core "wg-turn-client" +) + +func main() { + hash := flag.String("hash", "", "VK call hash (the part after /call/join/)") + target := flag.String("target", "1.1.1.1:53", "UDP DNS server in host:port form") + name := flag.String("name", "example.com", "DNS A query name") + timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") + flag.Parse() + + if strings.TrimSpace(*hash) == "" { + fmt.Fprintln(os.Stderr, "error: -hash is required") + flag.Usage() + os.Exit(2) + } + + fmt.Println("TURN UDP direct-egress probe") + fmt.Printf("Target: %s\n", *target) + fmt.Printf("DNS query: %s A\n", *name) + fmt.Println("Obtaining VK TURN credentials, allocating UDP relay and sending DNS query...") + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + result, err := core.ProbeTurnUDP(ctx, core.TurnUDPProbeConfig{ + Hash: *hash, + Target: *target, + QueryName: *name, + Timeout: *timeout, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) + os.Exit(1) + } + + fmt.Println("SUCCESS") + fmt.Printf("TURN endpoint: %s\n", result.TurnAddress) + fmt.Printf("Relayed address: %s\n", result.RelayAddress) + fmt.Printf("Target: %s\n", result.Target) + fmt.Printf("Response source: %s\n", result.Source) + fmt.Printf("DNS answers: %d\n", result.AnswerCount) + fmt.Printf("Response bytes: %d\n", result.ResponseBytes) +} diff --git a/core/turn_udp_probe.go b/core/turn_udp_probe.go new file mode 100644 index 00000000..2aa32002 --- /dev/null +++ b/core/turn_udp_probe.go @@ -0,0 +1,283 @@ +package core + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/pion/turn/v5" +) + +const ( + defaultTurnUDPProbeTimeout = 45 * time.Second + turnUDPProbeReadLimit = 4096 +) + +var turnUDPProbeSequence atomic.Int64 + +type TurnUDPProbeConfig struct { + Hash string + Target string + QueryName string + Timeout time.Duration +} + +type TurnUDPProbeResult struct { + TurnAddress string + RelayAddress string + Target string + Source string + QueryName string + ResponseBytes int + AnswerCount uint16 + RCode uint8 +} + +// ProbeTurnUDP checks whether VK TURN can relay UDP directly to an arbitrary +// external peer. The probe sends one DNS A query through the TURN allocation +// and validates the DNS response transaction ID and status. +// +// It does not start WireGuard and does not contact a wdtt-server. +func ProbeTurnUDP(parent context.Context, cfg TurnUDPProbeConfig) (TurnUDPProbeResult, error) { + var result TurnUDPProbeResult + + if parent == nil { + parent = context.Background() + } + + hash := strings.TrimSpace(cfg.Hash) + if hash == "" { + return result, fmt.Errorf("VK hash is required") + } + + target := strings.TrimSpace(cfg.Target) + if target == "" { + target = "1.1.1.1:53" + } + targetAddr, err := net.ResolveUDPAddr("udp", target) + if err != nil { + return result, fmt.Errorf("resolve UDP target %q: %w", target, err) + } + if targetAddr.IP == nil { + return result, fmt.Errorf("UDP target %q did not resolve to an IP address", target) + } + + queryName := strings.TrimSpace(cfg.QueryName) + if queryName == "" { + queryName = "example.com" + } + query, transactionID, err := buildDNSAQuery(queryName) + if err != nil { + return result, err + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTurnUDPProbeTimeout + } + + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + streamID := int(990000 + turnUDPProbeSequence.Add(1)) + turnUser, turnPass, turnURLs, err := GetCreds(ctx, hash, streamID) + if err != nil { + return result, fmt.Errorf("get VK TURN credentials: %w", err) + } + if len(turnURLs) == 0 { + return result, fmt.Errorf("VK returned no TURN endpoints") + } + + var attempts []error + for _, candidate := range turnURLs { + if err := ctx.Err(); err != nil { + attempts = append(attempts, err) + break + } + + turnAddr := strings.TrimSpace(candidate) + if turnAddr == "" { + continue + } + + result, err = probeTurnUDPAtEndpoint( + ctx, + turnAddr, + turnUser, + turnPass, + targetAddr, + queryName, + query, + transactionID, + ) + if err == nil { + return result, nil + } + attempts = append(attempts, fmt.Errorf("%s: %w", turnAddr, err)) + } + + if len(attempts) == 0 { + return result, fmt.Errorf("VK returned no usable TURN endpoints") + } + return result, fmt.Errorf("all TURN UDP probe attempts failed: %w", errors.Join(attempts...)) +} + +func probeTurnUDPAtEndpoint( + ctx context.Context, + turnAddr string, + username string, + password string, + target *net.UDPAddr, + queryName string, + query []byte, + transactionID uint16, +) (TurnUDPProbeResult, error) { + result := TurnUDPProbeResult{ + TurnAddress: turnAddr, + Target: target.String(), + QueryName: queryName, + } + + serverAddr, err := net.ResolveUDPAddr("udp", turnAddr) + if err != nil { + return result, fmt.Errorf("resolve TURN UDP endpoint: %w", err) + } + + controlConn, err := net.DialUDP("udp", nil, serverAddr) + if err != nil { + return result, fmt.Errorf("connect to TURN over UDP: %w", err) + } + defer controlConn.Close() + _ = controlConn.SetReadBuffer(socketBufSize) + _ = controlConn.SetWriteBuffer(socketBufSize) + + stopOnCancel := context.AfterFunc(ctx, func() { + _ = controlConn.Close() + }) + defer stopOnCancel() + + var turnConn net.PacketConn = &connectedUDPConn{controlConn} + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Conn: turnConn, + Username: username, + Password: password, + RequestedAddressFamily: turn.RequestedAddressFamilyIPv4, + LoggerFactory: &NullLoggerFactory{}, + }) + if err != nil { + return result, fmt.Errorf("create TURN client: %w", err) + } + defer client.Close() + + if err := client.Listen(); err != nil { + return result, fmt.Errorf("TURN listen: %w", err) + } + + relay, err := client.Allocate() + if err != nil { + return result, fmt.Errorf("Allocate UDP: %w", err) + } + defer relay.Close() + + result.RelayAddress = relay.LocalAddr().String() + + deadline := time.Now().Add(10 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + if err := relay.SetDeadline(deadline); err != nil { + return result, fmt.Errorf("set relay deadline: %w", err) + } + + if _, err := relay.WriteTo(query, target); err != nil { + return result, fmt.Errorf("send DNS query to %s through TURN: %w", target, err) + } + + buf := make([]byte, turnUDPProbeReadLimit) + n, source, err := relay.ReadFrom(buf) + if err != nil { + return result, fmt.Errorf("read DNS response through TURN: %w", err) + } + result.Source = source.String() + result.ResponseBytes = n + + answerCount, rcode, err := validateDNSResponse(buf[:n], transactionID) + if err != nil { + return result, fmt.Errorf("validate DNS response from %s: %w", source, err) + } + result.AnswerCount = answerCount + result.RCode = rcode + + return result, nil +} + +func buildDNSAQuery(name string) ([]byte, uint16, error) { + name = strings.TrimSuffix(strings.TrimSpace(name), ".") + if name == "" { + return nil, 0, fmt.Errorf("DNS query name is required") + } + + var idBytes [2]byte + if _, err := rand.Read(idBytes[:]); err != nil { + return nil, 0, fmt.Errorf("generate DNS transaction ID: %w", err) + } + transactionID := binary.BigEndian.Uint16(idBytes[:]) + + query := make([]byte, 12, 512) + binary.BigEndian.PutUint16(query[0:2], transactionID) + binary.BigEndian.PutUint16(query[2:4], 0x0100) + binary.BigEndian.PutUint16(query[4:6], 1) + + for _, label := range strings.Split(name, ".") { + if label == "" { + return nil, 0, fmt.Errorf("invalid DNS query name %q", name) + } + if len(label) > 63 { + return nil, 0, fmt.Errorf("DNS label is too long in %q", name) + } + query = append(query, byte(len(label))) + query = append(query, label...) + } + query = append(query, 0) + query = append(query, 0, 1) + query = append(query, 0, 1) + + if len(query) > 255 { + return nil, 0, fmt.Errorf("DNS query name is too long") + } + return query, transactionID, nil +} + +func validateDNSResponse(response []byte, expectedID uint16) (uint16, uint8, error) { + if len(response) < 12 { + return 0, 0, fmt.Errorf("response is too short: %d bytes", len(response)) + } + if binary.BigEndian.Uint16(response[0:2]) != expectedID { + return 0, 0, fmt.Errorf("transaction ID mismatch") + } + + flags := binary.BigEndian.Uint16(response[2:4]) + if flags&0x8000 == 0 { + return 0, 0, fmt.Errorf("packet is not a DNS response") + } + + rcode := uint8(flags & 0x000f) + if rcode != 0 { + return 0, rcode, fmt.Errorf("DNS server returned rcode=%d", rcode) + } + + answerCount := binary.BigEndian.Uint16(response[6:8]) + if answerCount == 0 { + return 0, rcode, fmt.Errorf("DNS response contains no answers") + } + + return answerCount, rcode, nil +} diff --git a/core/turn_udp_probe_test.go b/core/turn_udp_probe_test.go new file mode 100644 index 00000000..ffc16869 --- /dev/null +++ b/core/turn_udp_probe_test.go @@ -0,0 +1,62 @@ +package core + +import ( + "context" + "encoding/binary" + "strings" + "testing" +) + +func TestProbeTurnUDPRejectsEmptyHash(t *testing.T) { + _, err := ProbeTurnUDP(context.Background(), TurnUDPProbeConfig{}) + if err == nil || !strings.Contains(err.Error(), "VK hash is required") { + t.Fatalf("expected VK hash validation error, got %v", err) + } +} + +func TestBuildDNSAQuery(t *testing.T) { + query, transactionID, err := buildDNSAQuery("example.com") + if err != nil { + t.Fatalf("buildDNSAQuery failed: %v", err) + } + if len(query) < 12 { + t.Fatalf("query too short: %d", len(query)) + } + if got := binary.BigEndian.Uint16(query[0:2]); got != transactionID { + t.Fatalf("transaction ID mismatch: header=%d returned=%d", got, transactionID) + } + if got := binary.BigEndian.Uint16(query[4:6]); got != 1 { + t.Fatalf("QDCOUNT=%d, want 1", got) + } +} + +func TestValidateDNSResponse(t *testing.T) { + const transactionID = 0x1234 + response := make([]byte, 12) + binary.BigEndian.PutUint16(response[0:2], transactionID) + binary.BigEndian.PutUint16(response[2:4], 0x8180) + binary.BigEndian.PutUint16(response[6:8], 1) + + answers, rcode, err := validateDNSResponse(response, transactionID) + if err != nil { + t.Fatalf("validateDNSResponse failed: %v", err) + } + if answers != 1 { + t.Fatalf("answers=%d, want 1", answers) + } + if rcode != 0 { + t.Fatalf("rcode=%d, want 0", rcode) + } +} + +func TestValidateDNSResponseRejectsWrongTransactionID(t *testing.T) { + response := make([]byte, 12) + binary.BigEndian.PutUint16(response[0:2], 0x1111) + binary.BigEndian.PutUint16(response[2:4], 0x8180) + binary.BigEndian.PutUint16(response[6:8], 1) + + _, _, err := validateDNSResponse(response, 0x2222) + if err == nil || !strings.Contains(err.Error(), "transaction ID mismatch") { + t.Fatalf("expected transaction ID error, got %v", err) + } +} diff --git a/docs/turn-tcp-rfc6062-probe.md b/docs/turn-tcp-rfc6062-probe.md index 5d301d00..49475c4d 100644 --- a/docs/turn-tcp-rfc6062-probe.md +++ b/docs/turn-tcp-rfc6062-probe.md @@ -1,52 +1,74 @@ -# Эксперимент: TURN TCP / RFC 6062 без VPS +# Эксперимент: прямой TURN egress без VPS -## Гипотеза +## Цель -Проверить, разрешает ли используемая PWDTT инфраструктура VK/OK TURN: +Проверить, может ли используемая PWDTT инфраструктура VK/OK TURN самостоятельно передавать трафик к внешним peer без `wdtt-server` и VPS. -1. TCP allocation по RFC 6062; -2. исходящий TCP `CONNECT` к произвольному peer; -3. двустороннюю передачу данных без `wdtt-server` и без VPS. +Эксперимент не меняет обычный режим PWDTT. Все проверки запускаются отдельными CLI-командами. -Эксперимент не меняет обычный режим PWDTT. Он запускается отдельной CLI-командой. +## TCP / RFC 6062 -## Запуск - -Нужен рабочий VK call hash, который PWDTT уже умеет использовать для получения TURN credentials. - -Из корня репозитория: +Команда: ```powershell go run ./cmd/turn-tcp-probe -hash "" -target "example.com:80" -mode http ``` -Успешный результат должен содержать: +Фактический результат проверки 2026-09-07: оба полученных TURN endpoint отклонили `AllocateTCP()`: ```text -SUCCESS -TURN endpoint: ... -Relayed address: ... -Target: ... -Response preview: -HTTP/1.1 ... +442: TCP Transport is not allowed by the TURN Server configuration ``` -Это подтверждает одновременно TCP allocation, исходящий TCP peer и двустороннюю передачу данных через TURN. +Следовательно, протестированная конфигурация VK/OK TURN не предоставляет TCP relay по RFC 6062. До внешнего TCP peer выполнение не дошло. -Для проверки только установки TCP-соединения: +## UDP direct egress + +Следующая гипотеза проверяет обычный UDP allocation, который уже используется WDTT, но peer теперь является не `wdtt-server`, а публичным DNS-сервером. + +Схема: + +```text +PWDTT probe + ↓ +VK TURN Allocate() + ↓ +1.1.1.1:53 + ↓ +DNS response +``` + +Запуск из корня репозитория: ```powershell -go run ./cmd/turn-tcp-probe -hash "" -target "telegram.org:443" -mode connect +go run ./cmd/turn-udp-probe -hash "" -target "1.1.1.1:53" -name "example.com" ``` -## Интерпретация ошибок +Успешный результат: + +```text +TURN UDP direct-egress probe +... +SUCCESS +TURN endpoint: ... +Relayed address: ... +Target: 1.1.1.1:53 +Response source: ... +DNS answers: ... +Response bytes: ... +``` + +Успех означает, что TURN relay передал DNS query непосредственно внешнему UDP peer и вернул валидный DNS response с тем же transaction ID. Это подтверждает arbitrary UDP egress для проверенного endpoint без VPS. + +## Интерпретация ошибок UDP -- ошибка на `AllocateTCP` — сервер не поддерживает или политикой запрещает TCP allocation; -- ошибка на `DialTCP` / `CreatePermission` / `Connect` — allocation создан, но peer или исходящее TCP-соединение запрещены/недоступны; -- `SUCCESS` в режиме `connect` — TCP-соединение к peer через TURN установлено; -- `SUCCESS` + HTTP response в режиме `http` — подтверждена двусторонняя передача прикладных данных. +- ошибка `Allocate UDP` — обычный UDP allocation не был создан; +- ошибка `send DNS query` / permission-related error — внешний peer запрещён или недоступен; +- timeout на `read DNS response` — запрос ушёл, но валидный ответ через relay не получен; +- ошибка validation — через relay пришёл пакет, но он не является ожидаемым валидным DNS response; +- `SUCCESS` — подтверждена двусторонняя UDP-передача к публичному peer. -CLI перебирает TURN endpoints, полученные для указанного VK hash, и выводит ошибки без username/password. +CLI перебирают TURN endpoints, полученные для указанного VK hash, и не выводят TURN username/password. ## Ограничения @@ -55,5 +77,6 @@ CLI перебирает TURN endpoints, полученные для указа - не поднимает SOCKS5; - не интегрирован в GUI; - не маршрутизирует системный трафик; -- не гарантирует, что политика TURN одинакова для всех endpoint/аккаунтов/сетей; +- UDP probe проверяет только один безопасный DNS-сценарий; +- результат одного endpoint не гарантирует одинаковую политику всех TURN-серверов; - работа зависит от внешней инфраструктуры VK/OK и может измениться независимо от PWDTT. From 594e1a8aa9ef49d499f27957e9701545c8acecb0 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 10:46:34 +0300 Subject: [PATCH 04/11] experiment: add UDP STUN control probe --- cmd/turn-udp-stun-probe/main.go | 50 ++++++++ core/turn_udp_stun_probe.go | 209 +++++++++++++++++++++++++++++++ core/turn_udp_stun_probe_test.go | 29 +++++ 3 files changed, 288 insertions(+) create mode 100644 cmd/turn-udp-stun-probe/main.go create mode 100644 core/turn_udp_stun_probe.go create mode 100644 core/turn_udp_stun_probe_test.go diff --git a/cmd/turn-udp-stun-probe/main.go b/cmd/turn-udp-stun-probe/main.go new file mode 100644 index 00000000..e251e7e2 --- /dev/null +++ b/cmd/turn-udp-stun-probe/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + core "wg-turn-client" +) + +func main() { + hash := flag.String("hash", "", "VK call hash (the part after /call/join/)") + target := flag.String("target", "stun.cloudflare.com:3478", "public STUN server in host:port form") + timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") + flag.Parse() + + if strings.TrimSpace(*hash) == "" { + fmt.Fprintln(os.Stderr, "error: -hash is required") + flag.Usage() + os.Exit(2) + } + + fmt.Println("TURN UDP STUN direct-egress probe") + fmt.Printf("Target: %s\n", *target) + fmt.Println("Obtaining VK TURN credentials, allocating UDP relay and sending STUN Binding request...") + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + result, err := core.ProbeTurnUDPSTUN(ctx, core.TurnUDPSTUNProbeConfig{ + Hash: *hash, + Target: *target, + Timeout: *timeout, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) + os.Exit(1) + } + + fmt.Println("SUCCESS") + fmt.Printf("TURN endpoint: %s\n", result.TurnAddress) + fmt.Printf("Relayed address: %s\n", result.RelayAddress) + fmt.Printf("Target: %s\n", result.Target) + fmt.Printf("Response source: %s\n", result.ResponseSource) + fmt.Printf("STUN mapped address: %s\n", result.MappedAddress) + fmt.Printf("Response bytes: %d\n", result.ResponseBytes) +} diff --git a/core/turn_udp_stun_probe.go b/core/turn_udp_stun_probe.go new file mode 100644 index 00000000..da29f9db --- /dev/null +++ b/core/turn_udp_stun_probe.go @@ -0,0 +1,209 @@ +package core + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/pion/stun/v3" + "github.com/pion/turn/v5" +) + +const defaultTurnUDPSTUNProbeTimeout = 45 * time.Second + +var turnUDPSTUNProbeSequence atomic.Int64 + +type TurnUDPSTUNProbeConfig struct { + Hash string + Target string + Timeout time.Duration +} + +type TurnUDPSTUNProbeResult struct { + TurnAddress string + RelayAddress string + Target string + ResponseSource string + MappedAddress string + ResponseBytes int +} + +func ProbeTurnUDPSTUN(parent context.Context, cfg TurnUDPSTUNProbeConfig) (TurnUDPSTUNProbeResult, error) { + var result TurnUDPSTUNProbeResult + + if parent == nil { + parent = context.Background() + } + + hash := strings.TrimSpace(cfg.Hash) + if hash == "" { + return result, fmt.Errorf("VK hash is required") + } + + target := strings.TrimSpace(cfg.Target) + if target == "" { + target = "stun.cloudflare.com:3478" + } + targetAddr, err := net.ResolveUDPAddr("udp", target) + if err != nil { + return result, fmt.Errorf("resolve STUN target %q: %w", target, err) + } + if targetAddr.IP == nil { + return result, fmt.Errorf("STUN target %q did not resolve to an IP address", target) + } + + request, err := stun.Build(stun.TransactionID, stun.BindingRequest) + if err != nil { + return result, fmt.Errorf("build STUN binding request: %w", err) + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTurnUDPSTUNProbeTimeout + } + + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + streamID := int(995000 + turnUDPSTUNProbeSequence.Add(1)) + turnUser, turnPass, turnURLs, err := GetCreds(ctx, hash, streamID) + if err != nil { + return result, fmt.Errorf("get VK TURN credentials: %w", err) + } + if len(turnURLs) == 0 { + return result, fmt.Errorf("VK returned no TURN endpoints") + } + + var attempts []error + for _, candidate := range turnURLs { + if err := ctx.Err(); err != nil { + attempts = append(attempts, err) + break + } + + turnAddr := strings.TrimSpace(candidate) + if turnAddr == "" { + continue + } + + result, err = probeTurnUDPSTUNAtEndpoint( + ctx, + turnAddr, + turnUser, + turnPass, + targetAddr, + request, + ) + if err == nil { + return result, nil + } + attempts = append(attempts, fmt.Errorf("%s: %w", turnAddr, err)) + } + + if len(attempts) == 0 { + return result, fmt.Errorf("VK returned no usable TURN endpoints") + } + return result, fmt.Errorf("all TURN UDP STUN probe attempts failed: %w", errors.Join(attempts...)) +} + +func probeTurnUDPSTUNAtEndpoint( + ctx context.Context, + turnAddr string, + username string, + password string, + target *net.UDPAddr, + request *stun.Message, +) (TurnUDPSTUNProbeResult, error) { + result := TurnUDPSTUNProbeResult{ + TurnAddress: turnAddr, + Target: target.String(), + } + + serverAddr, err := net.ResolveUDPAddr("udp", turnAddr) + if err != nil { + return result, fmt.Errorf("resolve TURN UDP endpoint: %w", err) + } + + controlConn, err := net.DialUDP("udp", nil, serverAddr) + if err != nil { + return result, fmt.Errorf("connect to TURN over UDP: %w", err) + } + defer controlConn.Close() + _ = controlConn.SetReadBuffer(socketBufSize) + _ = controlConn.SetWriteBuffer(socketBufSize) + + stopOnCancel := context.AfterFunc(ctx, func() { + _ = controlConn.Close() + }) + defer stopOnCancel() + + var turnConn net.PacketConn = &connectedUDPConn{controlConn} + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Conn: turnConn, + Username: username, + Password: password, + RequestedAddressFamily: turn.RequestedAddressFamilyIPv4, + LoggerFactory: &NullLoggerFactory{}, + }) + if err != nil { + return result, fmt.Errorf("create TURN client: %w", err) + } + defer client.Close() + + if err := client.Listen(); err != nil { + return result, fmt.Errorf("TURN listen: %w", err) + } + + relay, err := client.Allocate() + if err != nil { + return result, fmt.Errorf("Allocate UDP: %w", err) + } + defer relay.Close() + + result.RelayAddress = relay.LocalAddr().String() + + deadline := time.Now().Add(10 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + if err := relay.SetDeadline(deadline); err != nil { + return result, fmt.Errorf("set relay deadline: %w", err) + } + + if _, err := relay.WriteTo(request.Raw, target); err != nil { + return result, fmt.Errorf("send STUN binding request to %s through TURN: %w", target, err) + } + + buf := make([]byte, turnUDPProbeReadLimit) + n, source, err := relay.ReadFrom(buf) + if err != nil { + return result, fmt.Errorf("read STUN response through TURN: %w", err) + } + result.ResponseSource = source.String() + result.ResponseBytes = n + + response := &stun.Message{Raw: append([]byte(nil), buf[:n]...)} + if err := response.Decode(); err != nil { + return result, fmt.Errorf("decode STUN response from %s: %w", source, err) + } + if response.TransactionID != request.TransactionID { + return result, fmt.Errorf("STUN transaction ID mismatch") + } + if response.Type != stun.BindingSuccess { + return result, fmt.Errorf("unexpected STUN response type: %s", response.Type) + } + + var mapped stun.XORMappedAddress + if err := mapped.GetFrom(response); err != nil { + return result, fmt.Errorf("read XOR-MAPPED-ADDRESS: %w", err) + } + result.MappedAddress = mapped.String() + + return result, nil +} diff --git a/core/turn_udp_stun_probe_test.go b/core/turn_udp_stun_probe_test.go new file mode 100644 index 00000000..87fbc57d --- /dev/null +++ b/core/turn_udp_stun_probe_test.go @@ -0,0 +1,29 @@ +package core + +import ( + "context" + "strings" + "testing" + + "github.com/pion/stun/v3" +) + +func TestProbeTurnUDPSTUNRejectsEmptyHash(t *testing.T) { + _, err := ProbeTurnUDPSTUN(context.Background(), TurnUDPSTUNProbeConfig{}) + if err == nil || !strings.Contains(err.Error(), "VK hash is required") { + t.Fatalf("expected VK hash validation error, got %v", err) + } +} + +func TestSTUNBindingRequestHasTransactionID(t *testing.T) { + request, err := stun.Build(stun.TransactionID, stun.BindingRequest) + if err != nil { + t.Fatalf("build STUN request: %v", err) + } + if request.Type != stun.BindingRequest { + t.Fatalf("request type=%s, want %s", request.Type, stun.BindingRequest) + } + if len(request.Raw) < 20 { + t.Fatalf("request too short: %d", len(request.Raw)) + } +} From 0a04c7d3a88601eefd730cbe9119417b28bc56aa Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 11:05:59 +0300 Subject: [PATCH 05/11] experiment: add Telegram reflector UDP probe --- cmd/turn-telegram-reflector-probe/main.go | 62 +++++ core/turn_telegram_reflector_probe.go | 275 +++++++++++++++++++++ core/turn_telegram_reflector_probe_test.go | 43 ++++ docs/telegram-voip-reflector-turn-probe.md | 59 +++++ 4 files changed, 439 insertions(+) create mode 100644 cmd/turn-telegram-reflector-probe/main.go create mode 100644 core/turn_telegram_reflector_probe.go create mode 100644 core/turn_telegram_reflector_probe_test.go create mode 100644 docs/telegram-voip-reflector-turn-probe.md diff --git a/cmd/turn-telegram-reflector-probe/main.go b/cmd/turn-telegram-reflector-probe/main.go new file mode 100644 index 00000000..af9ac3cc --- /dev/null +++ b/cmd/turn-telegram-reflector-probe/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + core "wg-turn-client" +) + +func main() { + hash := flag.String("hash", "", "VK call hash (the part after /call/join/)") + target := flag.String("target", "", "Telegram phoneConnection UDP reflector in ip:port form") + peerTag := flag.String("peer-tag", "", "Telegram phoneConnection peer_tag as 32 hex characters") + timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") + flag.Parse() + + if strings.TrimSpace(*hash) == "" { + fmt.Fprintln(os.Stderr, "error: -hash is required") + flag.Usage() + os.Exit(2) + } + if strings.TrimSpace(*target) == "" { + fmt.Fprintln(os.Stderr, "error: -target is required") + flag.Usage() + os.Exit(2) + } + if strings.TrimSpace(*peerTag) == "" { + fmt.Fprintln(os.Stderr, "error: -peer-tag is required") + flag.Usage() + os.Exit(2) + } + + fmt.Println("Telegram reflector via VK TURN UDP probe") + fmt.Printf("Telegram reflector: %s\n", *target) + fmt.Println("Peer tag: supplied (hidden)") + fmt.Println("Obtaining VK TURN credentials, allocating UDP relay and sending Telegram reflector hello...") + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + result, err := core.ProbeTelegramReflector(ctx, core.TelegramReflectorProbeConfig{ + Hash: *hash, + Target: *target, + PeerTagHex: *peerTag, + Timeout: *timeout, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) + os.Exit(1) + } + + fmt.Println("SUCCESS") + fmt.Printf("TURN endpoint: %s\n", result.TurnAddress) + fmt.Printf("Relayed address: %s\n", result.RelayAddress) + fmt.Printf("Telegram reflector: %s\n", result.Target) + fmt.Printf("Response source: %s\n", result.ResponseSource) + fmt.Printf("Response bytes: %d\n", result.ResponseBytes) +} diff --git a/core/turn_telegram_reflector_probe.go b/core/turn_telegram_reflector_probe.go new file mode 100644 index 00000000..2528778d --- /dev/null +++ b/core/turn_telegram_reflector_probe.go @@ -0,0 +1,275 @@ +package core + +import ( + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/pion/turn/v5" +) + +const defaultTelegramReflectorProbeTimeout = 45 * time.Second + +var telegramReflectorProbeSequence atomic.Int64 + +// TelegramReflectorProbeConfig describes a one-shot Telegram VoIP reflector +// reachability probe through VK/OK TURN UDP. +type TelegramReflectorProbeConfig struct { + Hash string + Target string + PeerTagHex string + Timeout time.Duration +} + +// TelegramReflectorProbeResult contains only non-secret diagnostic data. +type TelegramReflectorProbeResult struct { + TurnAddress string + RelayAddress string + Target string + ResponseSource string + ResponseBytes int +} + +// ProbeTelegramReflector sends the UDP reflector hello used by current tgcalls +// through a VK/OK TURN UDP allocation. +// +// Target and peer_tag are dynamic per Telegram call and must come from a +// phoneConnection entry for an active call. The peer_tag is never logged. +func ProbeTelegramReflector(parent context.Context, cfg TelegramReflectorProbeConfig) (TelegramReflectorProbeResult, error) { + var result TelegramReflectorProbeResult + + if parent == nil { + parent = context.Background() + } + + hash := strings.TrimSpace(cfg.Hash) + if hash == "" { + return result, fmt.Errorf("VK hash is required") + } + + target := strings.TrimSpace(cfg.Target) + if target == "" { + return result, fmt.Errorf("Telegram reflector target is required") + } + targetAddr, err := net.ResolveUDPAddr("udp", target) + if err != nil { + return result, fmt.Errorf("resolve Telegram reflector target %q: %w", target, err) + } + if targetAddr.IP == nil { + return result, fmt.Errorf("Telegram reflector target %q did not resolve to an IP address", target) + } + + hello, peerTagPrefix, err := buildTelegramReflectorHello(cfg.PeerTagHex) + if err != nil { + return result, err + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTelegramReflectorProbeTimeout + } + + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + streamID := int(997000 + telegramReflectorProbeSequence.Add(1)) + turnUser, turnPass, turnURLs, err := GetCreds(ctx, hash, streamID) + if err != nil { + return result, fmt.Errorf("get VK TURN credentials: %w", err) + } + if len(turnURLs) == 0 { + return result, fmt.Errorf("VK returned no TURN endpoints") + } + + var attempts []error + for _, candidate := range turnURLs { + if err := ctx.Err(); err != nil { + attempts = append(attempts, err) + break + } + + turnAddr := strings.TrimSpace(candidate) + if turnAddr == "" { + continue + } + + result, err = probeTelegramReflectorAtEndpoint( + ctx, + turnAddr, + turnUser, + turnPass, + targetAddr, + hello, + peerTagPrefix, + ) + if err == nil { + return result, nil + } + attempts = append(attempts, fmt.Errorf("%s: %w", turnAddr, err)) + } + + if len(attempts) == 0 { + return result, fmt.Errorf("VK returned no usable TURN endpoints") + } + return result, fmt.Errorf("all Telegram reflector probe attempts failed: %w", errors.Join(attempts...)) +} + +func probeTelegramReflectorAtEndpoint( + ctx context.Context, + turnAddr string, + username string, + password string, + target *net.UDPAddr, + hello []byte, + peerTagPrefix []byte, +) (TelegramReflectorProbeResult, error) { + result := TelegramReflectorProbeResult{ + TurnAddress: turnAddr, + Target: target.String(), + } + + serverAddr, err := net.ResolveUDPAddr("udp", turnAddr) + if err != nil { + return result, fmt.Errorf("resolve TURN UDP endpoint: %w", err) + } + + controlConn, err := net.DialUDP("udp", nil, serverAddr) + if err != nil { + return result, fmt.Errorf("connect to TURN over UDP: %w", err) + } + defer controlConn.Close() + _ = controlConn.SetReadBuffer(socketBufSize) + _ = controlConn.SetWriteBuffer(socketBufSize) + + stopOnCancel := context.AfterFunc(ctx, func() { + _ = controlConn.Close() + }) + defer stopOnCancel() + + var turnConn net.PacketConn = &connectedUDPConn{controlConn} + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Conn: turnConn, + Username: username, + Password: password, + RequestedAddressFamily: turn.RequestedAddressFamilyIPv4, + LoggerFactory: &NullLoggerFactory{}, + }) + if err != nil { + return result, fmt.Errorf("create TURN client: %w", err) + } + defer client.Close() + + if err := client.Listen(); err != nil { + return result, fmt.Errorf("TURN listen: %w", err) + } + + relay, err := client.Allocate() + if err != nil { + return result, fmt.Errorf("Allocate UDP: %w", err) + } + defer relay.Close() + + result.RelayAddress = relay.LocalAddr().String() + + deadline := time.Now().Add(10 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + if err := relay.SetDeadline(deadline); err != nil { + return result, fmt.Errorf("set relay deadline: %w", err) + } + + if _, err := relay.WriteTo(hello, target); err != nil { + return result, fmt.Errorf("send Telegram reflector hello to %s through TURN: %w", target, err) + } + + buf := make([]byte, turnUDPProbeReadLimit) + for { + n, source, err := relay.ReadFrom(buf) + if err != nil { + return result, fmt.Errorf("read Telegram reflector response through TURN: %w", err) + } + if !sameUDPAddr(source, target) { + continue + } + if n < 16 { + return result, fmt.Errorf("Telegram reflector response is too short: %d bytes", n) + } + if !equalBytes(buf[:12], peerTagPrefix) { + return result, fmt.Errorf("Telegram reflector response peer_tag prefix mismatch") + } + + result.ResponseSource = source.String() + result.ResponseBytes = n + return result, nil + } +} + +func buildTelegramReflectorHello(peerTagHex string) ([]byte, []byte, error) { + peerTagHex = strings.TrimSpace(peerTagHex) + if peerTagHex == "" { + return nil, nil, fmt.Errorf("Telegram peer_tag is required") + } + + peerTag, err := hex.DecodeString(peerTagHex) + if err != nil { + return nil, nil, fmt.Errorf("decode Telegram peer_tag hex: %w", err) + } + if len(peerTag) != 16 { + return nil, nil, fmt.Errorf("Telegram peer_tag must be exactly 16 bytes (32 hex characters), got %d bytes", len(peerTag)) + } + + localTag := make([]byte, 4) + for { + if _, err := rand.Read(localTag); err != nil { + return nil, nil, fmt.Errorf("generate Telegram reflector local tag: %w", err) + } + if localTag[0] != 0 || localTag[1] != 0 || localTag[2] != 0 || localTag[3] != 0 { + break + } + } + + packet := make([]byte, 0, 40) + packet = append(packet, peerTag[:12]...) + packet = append(packet, localTag...) + for i := 0; i < 12; i++ { + packet = append(packet, 0xff) + } + packet = append(packet, 0xfe, 0xff, 0xff, 0xff) + + var pingValue [8]byte + binary.BigEndian.PutUint64(pingValue[:], 123) + packet = append(packet, pingValue[:]...) + + prefix := append([]byte(nil), peerTag[:12]...) + return packet, prefix, nil +} + +func sameUDPAddr(addr net.Addr, expected *net.UDPAddr) bool { + udpAddr, ok := addr.(*net.UDPAddr) + if !ok { + return false + } + return udpAddr.Port == expected.Port && udpAddr.IP.Equal(expected.IP) +} + +func equalBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/core/turn_telegram_reflector_probe_test.go b/core/turn_telegram_reflector_probe_test.go new file mode 100644 index 00000000..1341f3f6 --- /dev/null +++ b/core/turn_telegram_reflector_probe_test.go @@ -0,0 +1,43 @@ +package core + +import ( + "context" + "encoding/binary" + "strings" + "testing" +) + +func TestProbeTelegramReflectorRejectsEmptyHash(t *testing.T) { + _, err := ProbeTelegramReflector(context.Background(), TelegramReflectorProbeConfig{}) + if err == nil || !strings.Contains(err.Error(), "VK hash is required") { + t.Fatalf("expected VK hash validation error, got %v", err) + } +} + +func TestBuildTelegramReflectorHello(t *testing.T) { + const peerTag = "00112233445566778899aabbccddeeff" + + packet, prefix, err := buildTelegramReflectorHello(peerTag) + if err != nil { + t.Fatalf("buildTelegramReflectorHello failed: %v", err) + } + if len(packet) != 40 { + t.Fatalf("packet length=%d, want 40", len(packet)) + } + if got := len(prefix); got != 12 { + t.Fatalf("prefix length=%d, want 12", got) + } + if packet[28] != 0xfe { + t.Fatalf("special marker=%#x, want 0xfe", packet[28]) + } + if got := binary.BigEndian.Uint64(packet[32:40]); got != 123 { + t.Fatalf("ping value=%d, want 123", got) + } +} + +func TestBuildTelegramReflectorHelloRejectsInvalidPeerTag(t *testing.T) { + _, _, err := buildTelegramReflectorHello("0011") + if err == nil || !strings.Contains(err.Error(), "exactly 16 bytes") { + t.Fatalf("expected peer_tag length error, got %v", err) + } +} diff --git a/docs/telegram-voip-reflector-turn-probe.md b/docs/telegram-voip-reflector-turn-probe.md new file mode 100644 index 00000000..2536a0fb --- /dev/null +++ b/docs/telegram-voip-reflector-turn-probe.md @@ -0,0 +1,59 @@ +# Telegram VoIP reflector через VK TURN UDP + +## Что проверяется + +Этот probe проверяет не MTProto-сообщения, а UDP relay для Telegram-звонков. + +Актуальный Telegram Desktop объявляет для звонков `udp_p2p` и `udp_reflector`. Для обычного `phoneConnection` сервер возвращает: + +- `ip`; +- `port`; +- `peer_tag` длиной 16 байт. + +Текущий tgcalls использует эти данные для Telegram reflector. Probe воспроизводит его UDP hello поверх уже подтверждённого VK/OK TURN UDP allocation. + +Схема: + +```text +PWDTT probe + -> VK/OK TURN UDP allocation + -> Telegram VoIP reflector ip:port + -> Telegram reflector response + -> VK/OK TURN relay + -> probe +``` + +## Запуск + +Нужны данные одного UDP `phoneConnection` из активного Telegram-звонка: + +- reflector `ip:port`; +- `peer_tag` как 32 hex-символа. + +`peer_tag` чувствителен в рамках конкретного звонка: не публикуйте его. CLI не выводит его в лог. + +```powershell +go run ./cmd/turn-telegram-reflector-probe ` + -hash "" ` + -target "" ` + -peer-tag "<32_HEX_PEER_TAG>" +``` + +## Критерий успеха + +`SUCCESS` выводится только если: + +1. VK/OK TURN UDP allocation создан; +2. tgcalls-compatible reflector hello отправлен к указанному Telegram endpoint; +3. ответ получен именно от этого `ip:port`; +4. первые 12 байт peer tag в ответе совпадают с активным звонком. + +Успех подтвердит, что Telegram VoIP UDP reflector доступен через VK TURN без `wdtt-server`/VPS. + +## Ограничения + +- endpoint и peer_tag динамические и относятся к активному звонку; +- probe не инициирует Telegram-звонок и не получает `phone.getCall` самостоятельно; +- probe не передаёт голос/видео; +- обычный MTProto messaging по-прежнему остаётся TCP/HTTP stream-транспортом; +- peer_tag не следует сохранять в issue, CI logs или публичные артефакты. From bfb63836022ffdb79510b8a4d4090bf1cd0b8ae8 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 11:14:57 +0300 Subject: [PATCH 06/11] experiment: discover Telegram reflector from packet capture --- cmd/telegram-reflector-discover/main.go | 47 +++++ cmd/turn-telegram-reflector-probe/main.go | 31 +++- core/telegram_reflector_capture.go | 196 +++++++++++++++++++++ core/telegram_reflector_capture_test.go | 120 +++++++++++++ core/turn_telegram_reflector_probe.go | 42 +++-- core/turn_telegram_reflector_probe_test.go | 31 +++- docs/telegram-voip-reflector-turn-probe.md | 64 ++++--- 7 files changed, 475 insertions(+), 56 deletions(-) create mode 100644 cmd/telegram-reflector-discover/main.go create mode 100644 core/telegram_reflector_capture.go create mode 100644 core/telegram_reflector_capture_test.go diff --git a/cmd/telegram-reflector-discover/main.go b/cmd/telegram-reflector-discover/main.go new file mode 100644 index 00000000..feb9a328 --- /dev/null +++ b/cmd/telegram-reflector-discover/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "os" + + core "wg-turn-client" +) + +func main() { + pcapPath := flag.String("pcap", "", "pcapng file produced by pktmon etl2pcap") + flag.Parse() + + if *pcapPath == "" { + fmt.Fprintln(os.Stderr, "error: -pcap is required") + os.Exit(2) + } + + file, err := os.Open(*pcapPath) + if err != nil { + fmt.Fprintf(os.Stderr, "FAILED: open capture: %v\n", err) + os.Exit(1) + } + defer file.Close() + + candidates, err := core.DiscoverTelegramReflectorFromPCAPNG(file) + if err != nil { + fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) + os.Exit(1) + } + if len(candidates) == 0 { + fmt.Println("No Telegram reflector hello packets found.") + fmt.Println("Capture an active 1:1 Telegram call with full packet payloads, then retry.") + os.Exit(1) + } + + fmt.Printf("Found %d Telegram reflector candidate(s):\n", len(candidates)) + for i, candidate := range candidates { + fmt.Printf("\nCandidate %d\n", i+1) + fmt.Printf("Source: %s\n", candidate.Source) + fmt.Printf("Target: %s\n", candidate.Target) + fmt.Printf("Peer tag prefix: %s\n", candidate.PeerTagPrefixHex) + fmt.Println("Probe arguments:") + fmt.Printf(" -target %q -peer-tag-prefix %q\n", candidate.Target, candidate.PeerTagPrefixHex) + } +} diff --git a/cmd/turn-telegram-reflector-probe/main.go b/cmd/turn-telegram-reflector-probe/main.go index af9ac3cc..9094e673 100644 --- a/cmd/turn-telegram-reflector-probe/main.go +++ b/cmd/turn-telegram-reflector-probe/main.go @@ -13,8 +13,9 @@ import ( func main() { hash := flag.String("hash", "", "VK call hash (the part after /call/join/)") - target := flag.String("target", "", "Telegram phoneConnection UDP reflector in ip:port form") - peerTag := flag.String("peer-tag", "", "Telegram phoneConnection peer_tag as 32 hex characters") + target := flag.String("target", "", "Telegram reflector in ip:port form") + peerTagPrefix := flag.String("peer-tag-prefix", "", "first 12 bytes of Telegram peer_tag as 24 hex characters") + peerTag := flag.String("peer-tag", "", "full Telegram peer_tag as 32 hex characters (legacy alternative)") timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") flag.Parse() @@ -23,20 +24,32 @@ func main() { flag.Usage() os.Exit(2) } + + tag := strings.TrimSpace(*peerTagPrefix) + if tag == "" { + tag = strings.TrimSpace(*peerTag) + } + if strings.TrimSpace(*target) == "" { - fmt.Fprintln(os.Stderr, "error: -target is required") - flag.Usage() + fmt.Fprintln(os.Stderr, "error: -target is required; do not pass the example literally") os.Exit(2) } - if strings.TrimSpace(*peerTag) == "" { - fmt.Fprintln(os.Stderr, "error: -peer-tag is required") - flag.Usage() + if strings.ContainsAny(*target, "<>") { + fmt.Fprintln(os.Stderr, "error: -target still contains an example placeholder; discover a real reflector from an active Telegram call first") + os.Exit(2) + } + if tag == "" { + fmt.Fprintln(os.Stderr, "error: -peer-tag-prefix is required; use 24 hex characters discovered from an active call") + os.Exit(2) + } + if strings.ContainsAny(tag, "<>") { + fmt.Fprintln(os.Stderr, "error: peer tag still contains an example placeholder") os.Exit(2) } fmt.Println("Telegram reflector via VK TURN UDP probe") fmt.Printf("Telegram reflector: %s\n", *target) - fmt.Println("Peer tag: supplied (hidden)") + fmt.Println("Peer tag prefix: supplied (hidden)") fmt.Println("Obtaining VK TURN credentials, allocating UDP relay and sending Telegram reflector hello...") ctx, cancel := context.WithTimeout(context.Background(), *timeout) @@ -45,7 +58,7 @@ func main() { result, err := core.ProbeTelegramReflector(ctx, core.TelegramReflectorProbeConfig{ Hash: *hash, Target: *target, - PeerTagHex: *peerTag, + PeerTagHex: tag, Timeout: *timeout, }) if err != nil { diff --git a/core/telegram_reflector_capture.go b/core/telegram_reflector_capture.go new file mode 100644 index 00000000..145d37b3 --- /dev/null +++ b/core/telegram_reflector_capture.go @@ -0,0 +1,196 @@ +package core + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "strconv" +) + +const maxTelegramReflectorCaptureSize = 256 << 20 + +type TelegramReflectorCaptureCandidate struct { + Source string + Target string + PeerTagPrefixHex string +} + +func DiscoverTelegramReflectorFromPCAPNG(r io.Reader) ([]TelegramReflectorCaptureCandidate, error) { + if r == nil { + return nil, fmt.Errorf("pcapng reader is required") + } + + data, err := io.ReadAll(io.LimitReader(r, maxTelegramReflectorCaptureSize+1)) + if err != nil { + return nil, fmt.Errorf("read pcapng: %w", err) + } + if len(data) > maxTelegramReflectorCaptureSize { + return nil, fmt.Errorf("pcapng is too large; limit is %d MiB", maxTelegramReflectorCaptureSize>>20) + } + if len(data) < 12 { + return nil, fmt.Errorf("pcapng is too short") + } + + var order binary.ByteOrder + var foundSection bool + var results []TelegramReflectorCaptureCandidate + seen := make(map[string]struct{}) + + for offset := 0; offset+12 <= len(data); { + if bytes.Equal(data[offset:offset+4], []byte{0x0a, 0x0d, 0x0d, 0x0a}) { + if offset+12 > len(data) { + break + } + switch { + case bytes.Equal(data[offset+8:offset+12], []byte{0x4d, 0x3c, 0x2b, 0x1a}): + order = binary.LittleEndian + case bytes.Equal(data[offset+8:offset+12], []byte{0x1a, 0x2b, 0x3c, 0x4d}): + order = binary.BigEndian + default: + return nil, fmt.Errorf("unsupported pcapng byte-order magic at offset %d", offset) + } + foundSection = true + } + + if !foundSection || order == nil { + return nil, fmt.Errorf("pcapng section header not found") + } + + blockLen := int(order.Uint32(data[offset+4 : offset+8])) + if blockLen < 12 || offset+blockLen > len(data) { + return nil, fmt.Errorf("invalid pcapng block length %d at offset %d", blockLen, offset) + } + if int(order.Uint32(data[offset+blockLen-4:offset+blockLen])) != blockLen { + return nil, fmt.Errorf("pcapng block length trailer mismatch at offset %d", offset) + } + + blockType := order.Uint32(data[offset : offset+4]) + switch blockType { + case 0x00000006: + if blockLen >= 32 { + capturedLen := int(order.Uint32(data[offset+20 : offset+24])) + packetStart := offset + 28 + packetEnd := packetStart + capturedLen + if packetEnd <= offset+blockLen-4 { + appendTelegramReflectorPacketCandidates(data[packetStart:packetEnd], &results, seen) + } + } + case 0x00000003: + packetStart := offset + 12 + packetEnd := offset + blockLen - 4 + if packetEnd > packetStart { + appendTelegramReflectorPacketCandidates(data[packetStart:packetEnd], &results, seen) + } + } + + offset += blockLen + } + + if !foundSection { + return nil, fmt.Errorf("pcapng section header not found") + } + return results, nil +} + +func appendTelegramReflectorPacketCandidates(packet []byte, results *[]TelegramReflectorCaptureCandidate, seen map[string]struct{}) { + for offset := 0; offset < len(packet); offset++ { + if candidate, ok := parseIPv4TelegramReflectorHello(packet[offset:]); ok { + addTelegramReflectorCandidate(candidate, results, seen) + return + } + if candidate, ok := parseIPv6TelegramReflectorHello(packet[offset:]); ok { + addTelegramReflectorCandidate(candidate, results, seen) + return + } + } +} + +func addTelegramReflectorCandidate(candidate TelegramReflectorCaptureCandidate, results *[]TelegramReflectorCaptureCandidate, seen map[string]struct{}) { + key := candidate.Target + "|" + candidate.PeerTagPrefixHex + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + *results = append(*results, candidate) +} + +func parseIPv4TelegramReflectorHello(packet []byte) (TelegramReflectorCaptureCandidate, bool) { + if len(packet) < 20 || packet[0]>>4 != 4 { + return TelegramReflectorCaptureCandidate{}, false + } + ihl := int(packet[0]&0x0f) * 4 + if ihl < 20 || len(packet) < ihl+8 || packet[9] != 17 { + return TelegramReflectorCaptureCandidate{}, false + } + + totalLen := int(binary.BigEndian.Uint16(packet[2:4])) + if totalLen < ihl+8 || totalLen > len(packet) { + return TelegramReflectorCaptureCandidate{}, false + } + + udp := packet[ihl:totalLen] + return parseUDPTelegramReflectorHello( + net.IP(packet[12:16]), + net.IP(packet[16:20]), + udp, + ) +} + +func parseIPv6TelegramReflectorHello(packet []byte) (TelegramReflectorCaptureCandidate, bool) { + if len(packet) < 48 || packet[0]>>4 != 6 || packet[6] != 17 { + return TelegramReflectorCaptureCandidate{}, false + } + + payloadLen := int(binary.BigEndian.Uint16(packet[4:6])) + totalLen := 40 + payloadLen + if totalLen < 48 || totalLen > len(packet) { + return TelegramReflectorCaptureCandidate{}, false + } + + return parseUDPTelegramReflectorHello( + net.IP(packet[8:24]), + net.IP(packet[24:40]), + packet[40:totalLen], + ) +} + +func parseUDPTelegramReflectorHello(sourceIP, targetIP net.IP, udp []byte) (TelegramReflectorCaptureCandidate, bool) { + if len(udp) < 8 { + return TelegramReflectorCaptureCandidate{}, false + } + udpLen := int(binary.BigEndian.Uint16(udp[4:6])) + if udpLen < 8 || udpLen > len(udp) { + return TelegramReflectorCaptureCandidate{}, false + } + + payload := udp[8:udpLen] + if !isTelegramReflectorHelloPayload(payload) { + return TelegramReflectorCaptureCandidate{}, false + } + + sourcePort := int(binary.BigEndian.Uint16(udp[0:2])) + targetPort := int(binary.BigEndian.Uint16(udp[2:4])) + return TelegramReflectorCaptureCandidate{ + Source: net.JoinHostPort(sourceIP.String(), strconv.Itoa(sourcePort)), + Target: net.JoinHostPort(targetIP.String(), strconv.Itoa(targetPort)), + PeerTagPrefixHex: hex.EncodeToString(payload[:12]), + }, true +} + +func isTelegramReflectorHelloPayload(payload []byte) bool { + if len(payload) != 40 { + return false + } + for i := 16; i < 28; i++ { + if payload[i] != 0xff { + return false + } + } + if payload[28] != 0xfe || payload[29] != 0xff || payload[30] != 0xff || payload[31] != 0xff { + return false + } + return binary.BigEndian.Uint64(payload[32:40]) == 123 +} diff --git a/core/telegram_reflector_capture_test.go b/core/telegram_reflector_capture_test.go new file mode 100644 index 00000000..7a650661 --- /dev/null +++ b/core/telegram_reflector_capture_test.go @@ -0,0 +1,120 @@ +package core + +import ( + "bytes" + "encoding/binary" + "net" + "testing" +) + +func TestDiscoverTelegramReflectorFromPCAPNG(t *testing.T) { + const prefixHex = "00112233445566778899aabb" + payload := []byte{ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x01, 0x02, 0x03, 0x04, + } + for i := 0; i < 12; i++ { + payload = append(payload, 0xff) + } + payload = append(payload, 0xfe, 0xff, 0xff, 0xff) + var ping [8]byte + binary.BigEndian.PutUint64(ping[:], 123) + payload = append(payload, ping[:]...) + + packet := makeIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(149, 154, 167, 50), + 40000, + 599, + payload, + ) + pcap := makePCAPNG(packet) + + candidates, err := DiscoverTelegramReflectorFromPCAPNG(bytes.NewReader(pcap)) + if err != nil { + t.Fatalf("DiscoverTelegramReflectorFromPCAPNG failed: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("candidates=%d, want 1", len(candidates)) + } + if candidates[0].Target != "149.154.167.50:599" { + t.Fatalf("target=%q", candidates[0].Target) + } + if candidates[0].PeerTagPrefixHex != prefixHex { + t.Fatalf("prefix=%q, want %q", candidates[0].PeerTagPrefixHex, prefixHex) + } +} + +func TestDiscoverTelegramReflectorFromPCAPNGNoMatch(t *testing.T) { + packet := makeIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(1, 1, 1, 1), + 40000, + 53, + []byte("not a reflector packet"), + ) + candidates, err := DiscoverTelegramReflectorFromPCAPNG(bytes.NewReader(makePCAPNG(packet))) + if err != nil { + t.Fatalf("DiscoverTelegramReflectorFromPCAPNG failed: %v", err) + } + if len(candidates) != 0 { + t.Fatalf("candidates=%d, want 0", len(candidates)) + } +} + +func makeIPv4UDPPacket(sourceIP, targetIP net.IP, sourcePort, targetPort int, payload []byte) []byte { + udpLen := 8 + len(payload) + totalLen := 20 + udpLen + packet := make([]byte, totalLen) + packet[0] = 0x45 + binary.BigEndian.PutUint16(packet[2:4], uint16(totalLen)) + packet[8] = 64 + packet[9] = 17 + copy(packet[12:16], sourceIP.To4()) + copy(packet[16:20], targetIP.To4()) + + udp := packet[20:] + binary.BigEndian.PutUint16(udp[0:2], uint16(sourcePort)) + binary.BigEndian.PutUint16(udp[2:4], uint16(targetPort)) + binary.BigEndian.PutUint16(udp[4:6], uint16(udpLen)) + copy(udp[8:], payload) + return packet +} + +func makePCAPNG(packet []byte) []byte { + var out bytes.Buffer + + writeBlock := func(blockType uint32, body []byte) { + blockLen := uint32(12 + len(body)) + padding := (4 - (blockLen % 4)) % 4 + blockLen += padding + + _ = binary.Write(&out, binary.LittleEndian, blockType) + _ = binary.Write(&out, binary.LittleEndian, blockLen) + out.Write(body) + out.Write(make([]byte, padding)) + _ = binary.Write(&out, binary.LittleEndian, blockLen) + } + + sectionBody := make([]byte, 16) + binary.LittleEndian.PutUint32(sectionBody[0:4], 0x1a2b3c4d) + binary.LittleEndian.PutUint16(sectionBody[4:6], 1) + binary.LittleEndian.PutUint16(sectionBody[6:8], 0) + for i := 8; i < 16; i++ { + sectionBody[i] = 0xff + } + writeBlock(0x0a0d0d0a, sectionBody) + + idb := make([]byte, 8) + binary.LittleEndian.PutUint16(idb[0:2], 1) + binary.LittleEndian.PutUint32(idb[4:8], 65535) + writeBlock(0x00000001, idb) + + epb := make([]byte, 20+len(packet)) + binary.LittleEndian.PutUint32(epb[12:16], uint32(len(packet))) + binary.LittleEndian.PutUint32(epb[16:20], uint32(len(packet))) + copy(epb[20:], packet) + writeBlock(0x00000006, epb) + + return out.Bytes() +} diff --git a/core/turn_telegram_reflector_probe.go b/core/turn_telegram_reflector_probe.go index 2528778d..d8c9aabb 100644 --- a/core/turn_telegram_reflector_probe.go +++ b/core/turn_telegram_reflector_probe.go @@ -19,8 +19,6 @@ const defaultTelegramReflectorProbeTimeout = 45 * time.Second var telegramReflectorProbeSequence atomic.Int64 -// TelegramReflectorProbeConfig describes a one-shot Telegram VoIP reflector -// reachability probe through VK/OK TURN UDP. type TelegramReflectorProbeConfig struct { Hash string Target string @@ -28,20 +26,14 @@ type TelegramReflectorProbeConfig struct { Timeout time.Duration } -// TelegramReflectorProbeResult contains only non-secret diagnostic data. type TelegramReflectorProbeResult struct { - TurnAddress string - RelayAddress string - Target string + TurnAddress string + RelayAddress string + Target string ResponseSource string - ResponseBytes int + ResponseBytes int } -// ProbeTelegramReflector sends the UDP reflector hello used by current tgcalls -// through a VK/OK TURN UDP allocation. -// -// Target and peer_tag are dynamic per Telegram call and must come from a -// phoneConnection entry for an active call. The peer_tag is never logged. func ProbeTelegramReflector(parent context.Context, cfg TelegramReflectorProbeConfig) (TelegramReflectorProbeResult, error) { var result TelegramReflectorProbeResult @@ -58,6 +50,10 @@ func ProbeTelegramReflector(parent context.Context, cfg TelegramReflectorProbeCo if target == "" { return result, fmt.Errorf("Telegram reflector target is required") } + if looksLikePlaceholder(target) { + return result, fmt.Errorf("Telegram reflector target is still a placeholder: %q", target) + } + targetAddr, err := net.ResolveUDPAddr("udp", target) if err != nil { return result, fmt.Errorf("resolve Telegram reflector target %q: %w", target, err) @@ -214,19 +210,27 @@ func probeTelegramReflectorAtEndpoint( } } +// buildTelegramReflectorHello accepts either the 12-byte peer_tag prefix that +// tgcalls actually uses, or the original 16-byte phoneConnection peer_tag. +// Current tgcalls discards the last four bytes and replaces them with a fresh +// local random tag before sending the reflector hello. func buildTelegramReflectorHello(peerTagHex string) ([]byte, []byte, error) { peerTagHex = strings.TrimSpace(peerTagHex) if peerTagHex == "" { - return nil, nil, fmt.Errorf("Telegram peer_tag is required") + return nil, nil, fmt.Errorf("Telegram peer_tag prefix is required") + } + if looksLikePlaceholder(peerTagHex) { + return nil, nil, fmt.Errorf("Telegram peer_tag is still a placeholder") } peerTag, err := hex.DecodeString(peerTagHex) if err != nil { return nil, nil, fmt.Errorf("decode Telegram peer_tag hex: %w", err) } - if len(peerTag) != 16 { - return nil, nil, fmt.Errorf("Telegram peer_tag must be exactly 16 bytes (32 hex characters), got %d bytes", len(peerTag)) + if len(peerTag) != 12 && len(peerTag) != 16 { + return nil, nil, fmt.Errorf("Telegram peer_tag must be a 12-byte prefix (24 hex characters) or full 16 bytes (32 hex characters), got %d bytes", len(peerTag)) } + peerTagPrefix := peerTag[:12] localTag := make([]byte, 4) for { @@ -239,7 +243,7 @@ func buildTelegramReflectorHello(peerTagHex string) ([]byte, []byte, error) { } packet := make([]byte, 0, 40) - packet = append(packet, peerTag[:12]...) + packet = append(packet, peerTagPrefix...) packet = append(packet, localTag...) for i := 0; i < 12; i++ { packet = append(packet, 0xff) @@ -250,10 +254,14 @@ func buildTelegramReflectorHello(peerTagHex string) ([]byte, []byte, error) { binary.BigEndian.PutUint64(pingValue[:], 123) packet = append(packet, pingValue[:]...) - prefix := append([]byte(nil), peerTag[:12]...) + prefix := append([]byte(nil), peerTagPrefix...) return packet, prefix, nil } +func looksLikePlaceholder(value string) bool { + return strings.Contains(value, "<") || strings.Contains(value, ">") +} + func sameUDPAddr(addr net.Addr, expected *net.UDPAddr) bool { udpAddr, ok := addr.(*net.UDPAddr) if !ok { diff --git a/core/turn_telegram_reflector_probe_test.go b/core/turn_telegram_reflector_probe_test.go index 1341f3f6..afb8c30a 100644 --- a/core/turn_telegram_reflector_probe_test.go +++ b/core/turn_telegram_reflector_probe_test.go @@ -14,17 +14,27 @@ func TestProbeTelegramReflectorRejectsEmptyHash(t *testing.T) { } } -func TestBuildTelegramReflectorHello(t *testing.T) { - const peerTag = "00112233445566778899aabbccddeeff" +func TestProbeTelegramReflectorRejectsPlaceholderTarget(t *testing.T) { + _, err := ProbeTelegramReflector(context.Background(), TelegramReflectorProbeConfig{ + Hash: "test", + Target: "", + }) + if err == nil || !strings.Contains(err.Error(), "placeholder") { + t.Fatalf("expected placeholder validation error, got %v", err) + } +} - packet, prefix, err := buildTelegramReflectorHello(peerTag) +func TestBuildTelegramReflectorHelloFromPrefix(t *testing.T) { + const prefix = "00112233445566778899aabb" + + packet, gotPrefix, err := buildTelegramReflectorHello(prefix) if err != nil { t.Fatalf("buildTelegramReflectorHello failed: %v", err) } if len(packet) != 40 { t.Fatalf("packet length=%d, want 40", len(packet)) } - if got := len(prefix); got != 12 { + if got := len(gotPrefix); got != 12 { t.Fatalf("prefix length=%d, want 12", got) } if packet[28] != 0xfe { @@ -35,9 +45,20 @@ func TestBuildTelegramReflectorHello(t *testing.T) { } } +func TestBuildTelegramReflectorHelloAcceptsFullPeerTag(t *testing.T) { + const peerTag = "00112233445566778899aabbccddeeff" + _, prefix, err := buildTelegramReflectorHello(peerTag) + if err != nil { + t.Fatalf("buildTelegramReflectorHello failed: %v", err) + } + if len(prefix) != 12 { + t.Fatalf("prefix length=%d, want 12", len(prefix)) + } +} + func TestBuildTelegramReflectorHelloRejectsInvalidPeerTag(t *testing.T) { _, _, err := buildTelegramReflectorHello("0011") - if err == nil || !strings.Contains(err.Error(), "exactly 16 bytes") { + if err == nil || !strings.Contains(err.Error(), "12-byte prefix") { t.Fatalf("expected peer_tag length error, got %v", err) } } diff --git a/docs/telegram-voip-reflector-turn-probe.md b/docs/telegram-voip-reflector-turn-probe.md index 2536a0fb..8dd8498f 100644 --- a/docs/telegram-voip-reflector-turn-probe.md +++ b/docs/telegram-voip-reflector-turn-probe.md @@ -2,43 +2,57 @@ ## Что проверяется -Этот probe проверяет не MTProto-сообщения, а UDP relay для Telegram-звонков. +Этот probe проверяет UDP relay для Telegram-звонков, а не обычный MTProto messaging. -Актуальный Telegram Desktop объявляет для звонков `udp_p2p` и `udp_reflector`. Для обычного `phoneConnection` сервер возвращает: +Актуальный Telegram Desktop объявляет для звонков `udp_p2p` и `udp_reflector`. Для обычного `phoneConnection` сервер возвращает `ip`, `port` и 16-байтовый `peer_tag`. -- `ip`; -- `port`; -- `peer_tag` длиной 16 байт. +Текущий tgcalls фактически использует только первые 12 байт `peer_tag`: последние 4 байта заменяются случайным локальным tag перед отправкой reflector hello. Поэтому probe принимает либо 12-байтовый prefix, либо полный 16-байтовый peer tag. -Текущий tgcalls использует эти данные для Telegram reflector. Probe воспроизводит его UDP hello поверх уже подтверждённого VK/OK TURN UDP allocation. +## Автоматическое получение target и peer-tag-prefix на Windows -Схема: +Не подставляйте примеры вида `` буквально. -```text -PWDTT probe - -> VK/OK TURN UDP allocation - -> Telegram VoIP reflector ip:port - -> Telegram reflector response - -> VK/OK TURN relay - -> probe +Встроенный Windows Packet Monitor (`pktmon`) может записать короткий активный Telegram-звонок. Запускайте PowerShell от администратора. + +Из корня репозитория: + +```powershell +pktmon stop 2>$null +pktmon filter remove 2>$null +pktmon start --capture --pkt-size 0 --file-name telegram-call.etl +``` + +После запуска capture начните обычный 1:1 звонок в Telegram Desktop, дождитесь соединения и подержите звонок несколько секунд. Затем: + +```powershell +pktmon stop +pktmon etl2pcap telegram-call.etl --out telegram-call.pcapng + +go run ./cmd/telegram-reflector-discover -pcap ".\telegram-call.pcapng" ``` -## Запуск +Discovery ищет точную 40-байтовую сигнатуру reflector hello из текущего tgcalls и выводит: -Нужны данные одного UDP `phoneConnection` из активного Telegram-звонка: +```text +Target: 149.x.x.x:port +Peer tag prefix: 24_hex_characters +Probe arguments: + -target "..." -peer-tag-prefix "..." +``` -- reflector `ip:port`; -- `peer_tag` как 32 hex-символа. +Если найдено несколько кандидатов, сначала проверяйте каждый UDP target по очереди. -`peer_tag` чувствителен в рамках конкретного звонка: не публикуйте его. CLI не выводит его в лог. +## Запуск probe ```powershell go run ./cmd/turn-telegram-reflector-probe ` -hash "" ` - -target "" ` - -peer-tag "<32_HEX_PEER_TAG>" + -target "" ` + -peer-tag-prefix "<24_HEX_PREFIX_FROM_DISCOVERY>" ``` +Старый параметр `-peer-tag` с полным 32-символьным hex также поддерживается. + ## Критерий успеха `SUCCESS` выводится только если: @@ -46,14 +60,14 @@ go run ./cmd/turn-telegram-reflector-probe ` 1. VK/OK TURN UDP allocation создан; 2. tgcalls-compatible reflector hello отправлен к указанному Telegram endpoint; 3. ответ получен именно от этого `ip:port`; -4. первые 12 байт peer tag в ответе совпадают с активным звонком. +4. первые 12 байт peer tag в ответе совпадают с обнаруженным prefix. Успех подтвердит, что Telegram VoIP UDP reflector доступен через VK TURN без `wdtt-server`/VPS. ## Ограничения -- endpoint и peer_tag динамические и относятся к активному звонку; +- endpoint и peer-tag-prefix динамические и относятся к конкретному активному звонку; +- capture содержит сетевые метаданные звонка, поэтому не публикуйте `.etl`/`.pcapng`; - probe не инициирует Telegram-звонок и не получает `phone.getCall` самостоятельно; - probe не передаёт голос/видео; -- обычный MTProto messaging по-прежнему остаётся TCP/HTTP stream-транспортом; -- peer_tag не следует сохранять в issue, CI logs или публичные артефакты. +- обычный MTProto messaging по-прежнему остаётся stream-транспортом. From 22e40877e7abf06888f6c159c38c862ec7cd0c88 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 14:19:19 +0300 Subject: [PATCH 07/11] experiment: probe ordinary Telegram MTProto over TURN UDP --- cmd/turn-telegram-mtproto-udp-probe/main.go | 57 +++ core/turn_telegram_mtproto_udp_probe.go | 370 +++++++++++++++++++ core/turn_telegram_mtproto_udp_probe_test.go | 85 +++++ docs/telegram-mtproto-turn-udp-probe.md | 60 +++ 4 files changed, 572 insertions(+) create mode 100644 cmd/turn-telegram-mtproto-udp-probe/main.go create mode 100644 core/turn_telegram_mtproto_udp_probe.go create mode 100644 core/turn_telegram_mtproto_udp_probe_test.go create mode 100644 docs/telegram-mtproto-turn-udp-probe.md diff --git a/cmd/turn-telegram-mtproto-udp-probe/main.go b/cmd/turn-telegram-mtproto-udp-probe/main.go new file mode 100644 index 00000000..ed463f2e --- /dev/null +++ b/cmd/turn-telegram-mtproto-udp-probe/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + core "wg-turn-client" +) + +func main() { + hash := flag.String("hash", "", "VK call hash (the part after /call/join/)") + target := flag.String("target", "149.154.167.51:443", "Telegram DC UDP target in ip:port form") + timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") + flag.Parse() + + if strings.TrimSpace(*hash) == "" { + fmt.Fprintln(os.Stderr, "error: -hash is required") + flag.Usage() + os.Exit(2) + } + if strings.Contains(*target, "<") || strings.Contains(*target, ">") { + fmt.Fprintln(os.Stderr, "error: replace the -target placeholder with a real Telegram DC ip:port") + os.Exit(2) + } + + fmt.Println("Telegram MTProto via VK TURN UDP probe") + fmt.Printf("Telegram DC: %s\n", *target) + fmt.Println("Request: unauthenticated req_pq_multi") + fmt.Println("Framing candidates: raw, abridged, abridged-init, intermediate, intermediate-init, padded-intermediate-init, full") + fmt.Println("Obtaining VK TURN credentials and probing Telegram directly over UDP...") + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + result, err := core.ProbeTelegramMTProtoUDP(ctx, core.TelegramMTProtoUDPProbeConfig{ + Hash: *hash, + Target: *target, + Timeout: *timeout, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) + os.Exit(1) + } + + fmt.Println("SUCCESS") + fmt.Printf("TURN endpoint: %s\n", result.TurnAddress) + fmt.Printf("Relayed address: %s\n", result.RelayAddress) + fmt.Printf("Telegram DC: %s\n", result.Target) + fmt.Printf("Response source: %s\n", result.ResponseSource) + fmt.Printf("MTProto UDP framing: %s\n", result.Mode) + fmt.Printf("Response bytes: %d\n", result.ResponseBytes) + fmt.Println("Validated: resPQ with matching nonce") +} diff --git a/core/turn_telegram_mtproto_udp_probe.go b/core/turn_telegram_mtproto_udp_probe.go new file mode 100644 index 00000000..22f209ca --- /dev/null +++ b/core/turn_telegram_mtproto_udp_probe.go @@ -0,0 +1,370 @@ +package core + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/pion/turn/v5" +) + +const ( + defaultTelegramMTProtoUDPProbeTimeout = 45 * time.Second + telegramMTProtoPerModeTimeout = 3 * time.Second +) + +var telegramMTProtoUDPProbeSequence atomic.Int64 + +type TelegramMTProtoUDPProbeConfig struct { + Hash string + Target string + Timeout time.Duration +} + +type TelegramMTProtoUDPProbeResult struct { + TurnAddress string + RelayAddress string + Target string + ResponseSource string + Mode string + ResponseBytes int +} + +type telegramMTProtoUDPCandidate struct { + mode string + packet []byte + nonce [16]byte +} + +// ProbeTelegramMTProtoUDP checks whether a Telegram DC accepts an ordinary +// unauthenticated MTProto req_pq_multi exchange over UDP when the datagram is +// relayed through VK/OK TURN. +// +// No Telegram account, auth key, API ID, or wdtt-server is used. +func ProbeTelegramMTProtoUDP(parent context.Context, cfg TelegramMTProtoUDPProbeConfig) (TelegramMTProtoUDPProbeResult, error) { + var result TelegramMTProtoUDPProbeResult + + if parent == nil { + parent = context.Background() + } + + hash := strings.TrimSpace(cfg.Hash) + if hash == "" { + return result, fmt.Errorf("VK hash is required") + } + + target := strings.TrimSpace(cfg.Target) + if target == "" { + target = "149.154.167.51:443" + } + if strings.Contains(target, "<") || strings.Contains(target, ">") { + return result, fmt.Errorf("Telegram target still contains a placeholder: %q", target) + } + targetAddr, err := net.ResolveUDPAddr("udp", target) + if err != nil { + return result, fmt.Errorf("resolve Telegram UDP target %q: %w", target, err) + } + if targetAddr.IP == nil { + return result, fmt.Errorf("Telegram UDP target %q did not resolve to an IP address", target) + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTelegramMTProtoUDPProbeTimeout + } + + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + streamID := int(998000 + telegramMTProtoUDPProbeSequence.Add(1)) + turnUser, turnPass, turnURLs, err := GetCreds(ctx, hash, streamID) + if err != nil { + return result, fmt.Errorf("get VK TURN credentials: %w", err) + } + if len(turnURLs) == 0 { + return result, fmt.Errorf("VK returned no TURN endpoints") + } + + var attempts []error + for _, candidate := range turnURLs { + if err := ctx.Err(); err != nil { + attempts = append(attempts, err) + break + } + + turnAddr := strings.TrimSpace(candidate) + if turnAddr == "" { + continue + } + + result, err = probeTelegramMTProtoUDPAtEndpoint( + ctx, + turnAddr, + turnUser, + turnPass, + targetAddr, + ) + if err == nil { + return result, nil + } + attempts = append(attempts, fmt.Errorf("%s: %w", turnAddr, err)) + } + + if len(attempts) == 0 { + return result, fmt.Errorf("VK returned no usable TURN endpoints") + } + return result, fmt.Errorf("all Telegram MTProto UDP probe attempts failed: %w", errors.Join(attempts...)) +} + +func probeTelegramMTProtoUDPAtEndpoint( + ctx context.Context, + turnAddr string, + username string, + password string, + target *net.UDPAddr, +) (TelegramMTProtoUDPProbeResult, error) { + result := TelegramMTProtoUDPProbeResult{ + TurnAddress: turnAddr, + Target: target.String(), + } + + serverAddr, err := net.ResolveUDPAddr("udp", turnAddr) + if err != nil { + return result, fmt.Errorf("resolve TURN UDP endpoint: %w", err) + } + + controlConn, err := net.DialUDP("udp", nil, serverAddr) + if err != nil { + return result, fmt.Errorf("connect to TURN over UDP: %w", err) + } + defer controlConn.Close() + _ = controlConn.SetReadBuffer(socketBufSize) + _ = controlConn.SetWriteBuffer(socketBufSize) + + stopOnCancel := context.AfterFunc(ctx, func() { + _ = controlConn.Close() + }) + defer stopOnCancel() + + var turnConn net.PacketConn = &connectedUDPConn{controlConn} + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Conn: turnConn, + Username: username, + Password: password, + RequestedAddressFamily: turn.RequestedAddressFamilyIPv4, + LoggerFactory: &NullLoggerFactory{}, + }) + if err != nil { + return result, fmt.Errorf("create TURN client: %w", err) + } + defer client.Close() + + if err := client.Listen(); err != nil { + return result, fmt.Errorf("TURN listen: %w", err) + } + + relay, err := client.Allocate() + if err != nil { + return result, fmt.Errorf("Allocate UDP: %w", err) + } + defer relay.Close() + result.RelayAddress = relay.LocalAddr().String() + + candidates, err := buildTelegramMTProtoUDPCandidates(time.Now()) + if err != nil { + return result, err + } + + var modeErrors []error + buf := make([]byte, turnUDPProbeReadLimit) + + for _, probe := range candidates { + if err := ctx.Err(); err != nil { + modeErrors = append(modeErrors, err) + break + } + + deadline := time.Now().Add(telegramMTProtoPerModeTimeout) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + if err := relay.SetDeadline(deadline); err != nil { + return result, fmt.Errorf("set relay deadline: %w", err) + } + + if _, err := relay.WriteTo(probe.packet, target); err != nil { + modeErrors = append(modeErrors, fmt.Errorf("%s send: %w", probe.mode, err)) + continue + } + + for { + n, source, err := relay.ReadFrom(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + modeErrors = append(modeErrors, fmt.Errorf("%s: no response", probe.mode)) + break + } + modeErrors = append(modeErrors, fmt.Errorf("%s read: %w", probe.mode, err)) + break + } + + if validateTelegramResPQ(buf[:n], probe.nonce) { + result.ResponseSource = source.String() + result.ResponseBytes = n + result.Mode = probe.mode + return result, nil + } + + // A datagram came back through the TURN permission, but it wasn't + // the req_pq_multi response for this candidate. Keep listening until + // the per-mode deadline in case the valid response follows. + result.ResponseSource = source.String() + result.ResponseBytes = n + } + } + + if len(modeErrors) == 0 { + return result, fmt.Errorf("no MTProto UDP framing candidates were tested") + } + return result, fmt.Errorf("Telegram DC returned no valid resPQ: %w", errors.Join(modeErrors...)) +} + +func buildTelegramMTProtoUDPCandidates(now time.Time) ([]telegramMTProtoUDPCandidate, error) { + modes := []string{ + "raw", + "abridged", + "abridged-init", + "intermediate", + "intermediate-init", + "padded-intermediate-init", + "full", + } + + result := make([]telegramMTProtoUDPCandidate, 0, len(modes)) + for _, mode := range modes { + payload, nonce, err := buildTelegramReqPQMulti(now) + if err != nil { + return nil, err + } + packet, err := wrapTelegramMTProtoUDP(mode, payload) + if err != nil { + return nil, err + } + result = append(result, telegramMTProtoUDPCandidate{ + mode: mode, + packet: packet, + nonce: nonce, + }) + } + return result, nil +} + +func buildTelegramReqPQMulti(now time.Time) ([]byte, [16]byte, error) { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, nonce, fmt.Errorf("generate MTProto nonce: %w", err) + } + + var fractional [4]byte + if _, err := rand.Read(fractional[:]); err != nil { + return nil, nonce, fmt.Errorf("generate MTProto message ID: %w", err) + } + + messageID := (uint64(now.Unix()) << 32) | uint64(binary.LittleEndian.Uint32(fractional[:])&^3) + + payload := make([]byte, 40) + // auth_key_id = 0 for unencrypted MTProto messages. + binary.LittleEndian.PutUint64(payload[8:16], messageID) + binary.LittleEndian.PutUint32(payload[16:20], 20) + binary.LittleEndian.PutUint32(payload[20:24], 0xbe7e8ef1) // req_pq_multi + copy(payload[24:40], nonce[:]) + + return payload, nonce, nil +} + +func wrapTelegramMTProtoUDP(mode string, payload []byte) ([]byte, error) { + switch mode { + case "raw": + return append([]byte(nil), payload...), nil + + case "abridged": + if len(payload)%4 != 0 || len(payload)/4 >= 127 { + return nil, fmt.Errorf("payload cannot use short abridged envelope") + } + packet := make([]byte, 1, 1+len(payload)) + packet[0] = byte(len(payload) / 4) + return append(packet, payload...), nil + + case "abridged-init": + if len(payload)%4 != 0 || len(payload)/4 >= 127 { + return nil, fmt.Errorf("payload cannot use short abridged envelope") + } + packet := []byte{0xef, byte(len(payload) / 4)} + return append(packet, payload...), nil + + case "intermediate": + packet := make([]byte, 4, 4+len(payload)) + binary.LittleEndian.PutUint32(packet[:4], uint32(len(payload))) + return append(packet, payload...), nil + + case "intermediate-init": + packet := []byte{0xee, 0xee, 0xee, 0xee, 0, 0, 0, 0} + binary.LittleEndian.PutUint32(packet[4:8], uint32(len(payload))) + return append(packet, payload...), nil + + case "padded-intermediate-init": + packet := []byte{0xdd, 0xdd, 0xdd, 0xdd, 0, 0, 0, 0} + binary.LittleEndian.PutUint32(packet[4:8], uint32(len(payload))) + return append(packet, payload...), nil + + case "full": + packet := make([]byte, 8, 12+len(payload)) + binary.LittleEndian.PutUint32(packet[0:4], uint32(len(payload)+12)) + binary.LittleEndian.PutUint32(packet[4:8], 0) + packet = append(packet, payload...) + checksum := crc32.ChecksumIEEE(packet) + var crc [4]byte + binary.LittleEndian.PutUint32(crc[:], checksum) + packet = append(packet, crc[:]...) + return packet, nil + + default: + return nil, fmt.Errorf("unsupported MTProto UDP framing mode %q", mode) + } +} + +func validateTelegramResPQ(datagram []byte, expectedNonce [16]byte) bool { + const ( + resPQConstructor = uint32(0x05162463) + plainHeaderSize = 20 + ) + + for offset := plainHeaderSize; offset+20 <= len(datagram); offset++ { + if binary.LittleEndian.Uint32(datagram[offset:offset+4]) != resPQConstructor { + continue + } + if !equalBytes(datagram[offset+4:offset+20], expectedNonce[:]) { + continue + } + + header := datagram[offset-plainHeaderSize : offset] + if binary.LittleEndian.Uint64(header[0:8]) != 0 { + continue + } + bodyLen := binary.LittleEndian.Uint32(header[16:20]) + if bodyLen < 20 { + continue + } + return true + } + return false +} diff --git a/core/turn_telegram_mtproto_udp_probe_test.go b/core/turn_telegram_mtproto_udp_probe_test.go new file mode 100644 index 00000000..aea39b5f --- /dev/null +++ b/core/turn_telegram_mtproto_udp_probe_test.go @@ -0,0 +1,85 @@ +package core + +import ( + "encoding/binary" + "testing" + "time" +) + +func TestBuildTelegramReqPQMulti(t *testing.T) { + payload, nonce, err := buildTelegramReqPQMulti(time.Unix(1_700_000_000, 0)) + if err != nil { + t.Fatalf("buildTelegramReqPQMulti: %v", err) + } + if len(payload) != 40 { + t.Fatalf("payload length=%d, want 40", len(payload)) + } + if got := binary.LittleEndian.Uint64(payload[0:8]); got != 0 { + t.Fatalf("auth_key_id=%d, want 0", got) + } + if got := binary.LittleEndian.Uint32(payload[16:20]); got != 20 { + t.Fatalf("body length=%d, want 20", got) + } + if got := binary.LittleEndian.Uint32(payload[20:24]); got != 0xbe7e8ef1 { + t.Fatalf("constructor=%#x, want req_pq_multi", got) + } + if !equalBytes(payload[24:40], nonce[:]) { + t.Fatal("nonce not serialized into req_pq_multi") + } +} + +func TestWrapTelegramMTProtoUDP(t *testing.T) { + payload := make([]byte, 40) + + cases := map[string]int{ + "raw": 40, + "abridged": 41, + "abridged-init": 42, + "intermediate": 44, + "intermediate-init": 48, + "padded-intermediate-init": 48, + "full": 52, + } + + for mode, wantLen := range cases { + packet, err := wrapTelegramMTProtoUDP(mode, payload) + if err != nil { + t.Fatalf("%s: %v", mode, err) + } + if len(packet) != wantLen { + t.Fatalf("%s length=%d, want %d", mode, len(packet), wantLen) + } + } + + full, err := wrapTelegramMTProtoUDP("full", payload) + if err != nil { + t.Fatal(err) + } + if got := binary.LittleEndian.Uint32(full[0:4]); got != 52 { + t.Fatalf("full length field=%d, want 52", got) + } +} + +func TestValidateTelegramResPQ(t *testing.T) { + var nonce [16]byte + for i := range nonce { + nonce[i] = byte(i + 1) + } + + response := make([]byte, 20+4+16+8) + binary.LittleEndian.PutUint64(response[0:8], 0) + binary.LittleEndian.PutUint64(response[8:16], 123) + binary.LittleEndian.PutUint32(response[16:20], 28) + binary.LittleEndian.PutUint32(response[20:24], 0x05162463) + copy(response[24:40], nonce[:]) + + wrapped := append([]byte{0xee, 0xee, 0xee, 0xee, byte(len(response)), 0, 0, 0}, response...) + if !validateTelegramResPQ(wrapped, nonce) { + t.Fatal("expected valid resPQ") + } + + nonce[0] ^= 0xff + if validateTelegramResPQ(wrapped, nonce) { + t.Fatal("accepted resPQ with wrong nonce") + } +} diff --git a/docs/telegram-mtproto-turn-udp-probe.md b/docs/telegram-mtproto-turn-udp-probe.md new file mode 100644 index 00000000..1de310a5 --- /dev/null +++ b/docs/telegram-mtproto-turn-udp-probe.md @@ -0,0 +1,60 @@ +# Ordinary Telegram MTProto over VK TURN UDP + +## Goal + +Test the original no-VPS goal directly: + +```text +Telegram client-side probe + -> VK/OK TURN UDP relay + -> Telegram MTProto DC + -> MTProto response + -> VK/OK TURN + -> probe +``` + +This is not a Telegram VoIP test. + +## Why this experiment exists + +Telegram's current general MTProto documentation still lists UDP as an underlying transport and notes that, with UDP, a response may come from a different IP address. The dedicated transport page currently documents TCP, WebSocket/WSS and HTTP/HTTPS, but not the UDP wire format. + +Because there is no current public UDP framing specification, the probe sends a real unauthenticated `req_pq_multi` using several plausible framings: + +- raw MTProto plaintext payload; +- abridged, with and without the TCP-style `0xef` initializer; +- intermediate, with and without the `0xeeeeeeee` initializer; +- padded intermediate with `0xdddddddd` initializer; +- full MTProto transport envelope with sequence number and CRC32. + +No Telegram account or auth key is required for `req_pq_multi`. + +## Default Telegram target + +The probe defaults to DC2 from Telegram Desktop's built-in DC list: + +```text +149.154.167.51:443 +``` + +An alternate DC can be supplied with `-target`. + +## Run + +```powershell +go run ./cmd/turn-telegram-mtproto-udp-probe ` + -hash "" ` + -target "149.154.167.51:443" +``` + +## Acceptance criterion + +The probe prints `SUCCESS` only when a returned UDP datagram contains a valid plaintext MTProto `resPQ#05162463` whose nonce exactly matches the random nonce from the corresponding `req_pq_multi`. + +A timeout is not by itself proof that Telegram has no UDP support: Telegram's general documentation notes that UDP responses may originate from a different server IP. TURN permissions are peer-IP scoped, so such a response can be filtered unless the alternate source IP also has a permission. A positive `resPQ`, however, is definitive proof that ordinary MTProto reaches a Telegram DC through VK TURN without a VPS. + +## What success would unlock + +If this probe succeeds, the next step is to implement a reliable local stream/datagram adapter around the working Telegram UDP transport and then connect Telegram-facing traffic to it. + +If every DC and framing candidate times out, direct ordinary MTProto-over-UDP becomes unlikely and the next no-VPS path to test is Telegram HTTPS/WSS over a UDP-native QUIC carrier. From db3dde8e2b0295276303ccf9e6e105c7ea4f4966 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 23:51:23 +0300 Subject: [PATCH 08/11] experiment: add no-VPS Telegram verdict runner --- cmd/telegram-reflector-discover/main.go | 47 ---- cmd/turn-no-vps-probe/main.go | 156 ++++++++++++ cmd/turn-no-vps-probe/main_test.go | 46 ++++ cmd/turn-telegram-reflector-probe/main.go | 75 ------ core/telegram_reflector_capture.go | 196 -------------- core/telegram_reflector_capture_test.go | 120 --------- core/turn_telegram_reflector_probe.go | 283 --------------------- core/turn_telegram_reflector_probe_test.go | 64 ----- docs/telegram-mtproto-turn-udp-probe.md | 59 ++--- docs/telegram-voip-reflector-turn-probe.md | 73 ------ docs/turn-tcp-rfc6062-probe.md | 156 ++++++++---- 11 files changed, 336 insertions(+), 939 deletions(-) delete mode 100644 cmd/telegram-reflector-discover/main.go create mode 100644 cmd/turn-no-vps-probe/main.go create mode 100644 cmd/turn-no-vps-probe/main_test.go delete mode 100644 cmd/turn-telegram-reflector-probe/main.go delete mode 100644 core/telegram_reflector_capture.go delete mode 100644 core/telegram_reflector_capture_test.go delete mode 100644 core/turn_telegram_reflector_probe.go delete mode 100644 core/turn_telegram_reflector_probe_test.go delete mode 100644 docs/telegram-voip-reflector-turn-probe.md diff --git a/cmd/telegram-reflector-discover/main.go b/cmd/telegram-reflector-discover/main.go deleted file mode 100644 index feb9a328..00000000 --- a/cmd/telegram-reflector-discover/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - - core "wg-turn-client" -) - -func main() { - pcapPath := flag.String("pcap", "", "pcapng file produced by pktmon etl2pcap") - flag.Parse() - - if *pcapPath == "" { - fmt.Fprintln(os.Stderr, "error: -pcap is required") - os.Exit(2) - } - - file, err := os.Open(*pcapPath) - if err != nil { - fmt.Fprintf(os.Stderr, "FAILED: open capture: %v\n", err) - os.Exit(1) - } - defer file.Close() - - candidates, err := core.DiscoverTelegramReflectorFromPCAPNG(file) - if err != nil { - fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) - os.Exit(1) - } - if len(candidates) == 0 { - fmt.Println("No Telegram reflector hello packets found.") - fmt.Println("Capture an active 1:1 Telegram call with full packet payloads, then retry.") - os.Exit(1) - } - - fmt.Printf("Found %d Telegram reflector candidate(s):\n", len(candidates)) - for i, candidate := range candidates { - fmt.Printf("\nCandidate %d\n", i+1) - fmt.Printf("Source: %s\n", candidate.Source) - fmt.Printf("Target: %s\n", candidate.Target) - fmt.Printf("Peer tag prefix: %s\n", candidate.PeerTagPrefixHex) - fmt.Println("Probe arguments:") - fmt.Printf(" -target %q -peer-tag-prefix %q\n", candidate.Target, candidate.PeerTagPrefixHex) - } -} diff --git a/cmd/turn-no-vps-probe/main.go b/cmd/turn-no-vps-probe/main.go new file mode 100644 index 00000000..e9215831 --- /dev/null +++ b/cmd/turn-no-vps-probe/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net" + "os" + "strings" + "time" + + core "wg-turn-client" +) + +const defaultTelegramTargets = "149.154.175.50:443,149.154.167.51:443,95.161.76.100:443,149.154.175.100:443,149.154.167.91:443,149.154.171.5:443" + +func main() { + hashInput := flag.String("hash", "", "VK call hash or full https://vk.com/call/join/... link") + stunTarget := flag.String("stun-target", "stun.cloudflare.com:3478", "public STUN control target") + telegramTargets := flag.String("telegram-targets", defaultTelegramTargets, "comma-separated Telegram DC UDP targets") + controlTimeout := flag.Duration("control-timeout", 45*time.Second, "UDP control probe timeout") + telegramTimeout := flag.Duration("telegram-timeout", 30*time.Second, "timeout per Telegram DC") + flag.Parse() + + hash := normalizeVKHash(*hashInput) + if hash == "" { + fmt.Fprintln(os.Stderr, "error: -hash is required") + flag.Usage() + os.Exit(2) + } + + targets, err := parseTargets(*telegramTargets) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + + fmt.Println("PWDTT no-VPS direct Telegram verdict probe") + fmt.Println("Stage 1/2: verify bidirectional external UDP through VK/OK TURN") + + controlCtx, cancelControl := context.WithTimeout(context.Background(), *controlTimeout) + control, err := core.ProbeTurnUDPSTUN(controlCtx, core.TurnUDPSTUNProbeConfig{ + Hash: hash, + Target: *stunTarget, + Timeout: *controlTimeout, + }) + cancelControl() + if err != nil { + fmt.Fprintf(os.Stderr, "UDP CONTROL FAILED: %v\n", err) + fmt.Println() + fmt.Println("VERDICT") + fmt.Println("UDP egress: NOT CONFIRMED") + fmt.Println("Ordinary Telegram MTProto over TURN UDP: NOT TESTED") + fmt.Println("Direct no-VPS Telegram messaging: NOT CONFIRMED") + os.Exit(1) + } + + fmt.Println("UDP CONTROL PASS") + fmt.Printf(" TURN endpoint: %s\n", control.TurnAddress) + fmt.Printf(" Relay: %s\n", control.RelayAddress) + fmt.Printf(" STUN target: %s\n", control.Target) + fmt.Printf(" Response source: %s\n", control.ResponseSource) + fmt.Printf(" Mapped address: %s\n", control.MappedAddress) + fmt.Println() + fmt.Println("Stage 2/2: probe ordinary unauthenticated MTProto over UDP") + fmt.Printf("Testing %d Telegram DC target(s) with all implemented framing candidates.\n", len(targets)) + + for i, target := range targets { + fmt.Printf("[%d/%d] %s ...\n", i+1, len(targets), target) + + probeCtx, cancelProbe := context.WithTimeout(context.Background(), *telegramTimeout) + result, probeErr := core.ProbeTelegramMTProtoUDP(probeCtx, core.TelegramMTProtoUDPProbeConfig{ + Hash: hash, + Target: target, + Timeout: *telegramTimeout, + }) + cancelProbe() + + if probeErr != nil { + fmt.Printf(" NO VALID resPQ: %v\n", probeErr) + continue + } + + fmt.Println(" MTProto UDP PASS") + fmt.Printf(" TURN endpoint: %s\n", result.TurnAddress) + fmt.Printf(" Relay: %s\n", result.RelayAddress) + fmt.Printf(" Response source: %s\n", result.ResponseSource) + fmt.Printf(" Framing: %s\n", result.Mode) + fmt.Printf(" Response bytes: %d\n", result.ResponseBytes) + fmt.Println() + fmt.Println("VERDICT") + fmt.Println("UDP egress: PASS") + fmt.Println("Ordinary Telegram MTProto over TURN UDP: PASS") + fmt.Println("Direct no-VPS Telegram messaging transport: CONFIRMED AT PROTOCOL-PROBE LEVEL") + fmt.Println("Next step: implement a persistent client transport around the confirmed framing.") + return + } + + fmt.Println() + fmt.Println("VERDICT") + fmt.Println("UDP egress: PASS") + fmt.Println("Ordinary Telegram MTProto over tested UDP framings/DCs: NO VALID RESPONSE") + fmt.Println("Direct no-VPS Telegram messaging: NOT CONFIRMED") + fmt.Println("The result does not prove impossibility; it shows that the tested direct MTProto-over-UDP variants did not work.") + os.Exit(3) +} + +func normalizeVKHash(input string) string { + value := strings.TrimSpace(input) + if value == "" { + return "" + } + + const marker = "/call/join/" + if index := strings.Index(value, marker); index >= 0 { + value = value[index+len(marker):] + } + if index := strings.IndexAny(value, "?#/"); index >= 0 { + value = value[:index] + } + value = strings.TrimSpace(value) + if strings.ContainsAny(value, "<>") { + return "" + } + return value +} + +func parseTargets(input string) ([]string, error) { + parts := strings.Split(input, ",") + result := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + + for _, part := range parts { + target := strings.TrimSpace(part) + if target == "" { + continue + } + host, port, err := net.SplitHostPort(target) + if err != nil { + return nil, fmt.Errorf("invalid Telegram target %q: %w", target, err) + } + if strings.Trim(host, "[]") == "" || port == "" { + return nil, fmt.Errorf("invalid Telegram target %q", target) + } + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + result = append(result, target) + } + + if len(result) == 0 { + return nil, fmt.Errorf("at least one Telegram target is required") + } + return result, nil +} diff --git a/cmd/turn-no-vps-probe/main_test.go b/cmd/turn-no-vps-probe/main_test.go new file mode 100644 index 00000000..15bb8094 --- /dev/null +++ b/cmd/turn-no-vps-probe/main_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestNormalizeVKHash(t *testing.T) { + cases := map[string]string{ + "abc123": "abc123", + " https://vk.com/call/join/abc123 ": "abc123", + "https://vk.com/call/join/abc123?foo=bar": "abc123", + "": "", + "": "", + } + + for input, want := range cases { + if got := normalizeVKHash(input); got != want { + t.Fatalf("normalizeVKHash(%q)=%q, want %q", input, got, want) + } + } +} + +func TestParseTargets(t *testing.T) { + got, err := parseTargets("149.154.175.50:443, 149.154.167.51:443,149.154.175.50:443") + if err != nil { + t.Fatalf("parseTargets: %v", err) + } + + want := []string{ + "149.154.175.50:443", + "149.154.167.51:443", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseTargets=%v, want %v", got, want) + } +} + +func TestParseTargetsRejectsInvalid(t *testing.T) { + if _, err := parseTargets("149.154.175.50"); err == nil { + t.Fatal("expected invalid target error") + } + if _, err := parseTargets(" , "); err == nil { + t.Fatal("expected empty target list error") + } +} diff --git a/cmd/turn-telegram-reflector-probe/main.go b/cmd/turn-telegram-reflector-probe/main.go deleted file mode 100644 index 9094e673..00000000 --- a/cmd/turn-telegram-reflector-probe/main.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "os" - "strings" - "time" - - core "wg-turn-client" -) - -func main() { - hash := flag.String("hash", "", "VK call hash (the part after /call/join/)") - target := flag.String("target", "", "Telegram reflector in ip:port form") - peerTagPrefix := flag.String("peer-tag-prefix", "", "first 12 bytes of Telegram peer_tag as 24 hex characters") - peerTag := flag.String("peer-tag", "", "full Telegram peer_tag as 32 hex characters (legacy alternative)") - timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") - flag.Parse() - - if strings.TrimSpace(*hash) == "" { - fmt.Fprintln(os.Stderr, "error: -hash is required") - flag.Usage() - os.Exit(2) - } - - tag := strings.TrimSpace(*peerTagPrefix) - if tag == "" { - tag = strings.TrimSpace(*peerTag) - } - - if strings.TrimSpace(*target) == "" { - fmt.Fprintln(os.Stderr, "error: -target is required; do not pass the example literally") - os.Exit(2) - } - if strings.ContainsAny(*target, "<>") { - fmt.Fprintln(os.Stderr, "error: -target still contains an example placeholder; discover a real reflector from an active Telegram call first") - os.Exit(2) - } - if tag == "" { - fmt.Fprintln(os.Stderr, "error: -peer-tag-prefix is required; use 24 hex characters discovered from an active call") - os.Exit(2) - } - if strings.ContainsAny(tag, "<>") { - fmt.Fprintln(os.Stderr, "error: peer tag still contains an example placeholder") - os.Exit(2) - } - - fmt.Println("Telegram reflector via VK TURN UDP probe") - fmt.Printf("Telegram reflector: %s\n", *target) - fmt.Println("Peer tag prefix: supplied (hidden)") - fmt.Println("Obtaining VK TURN credentials, allocating UDP relay and sending Telegram reflector hello...") - - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - - result, err := core.ProbeTelegramReflector(ctx, core.TelegramReflectorProbeConfig{ - Hash: *hash, - Target: *target, - PeerTagHex: tag, - Timeout: *timeout, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) - os.Exit(1) - } - - fmt.Println("SUCCESS") - fmt.Printf("TURN endpoint: %s\n", result.TurnAddress) - fmt.Printf("Relayed address: %s\n", result.RelayAddress) - fmt.Printf("Telegram reflector: %s\n", result.Target) - fmt.Printf("Response source: %s\n", result.ResponseSource) - fmt.Printf("Response bytes: %d\n", result.ResponseBytes) -} diff --git a/core/telegram_reflector_capture.go b/core/telegram_reflector_capture.go deleted file mode 100644 index 145d37b3..00000000 --- a/core/telegram_reflector_capture.go +++ /dev/null @@ -1,196 +0,0 @@ -package core - -import ( - "bytes" - "encoding/binary" - "encoding/hex" - "fmt" - "io" - "net" - "strconv" -) - -const maxTelegramReflectorCaptureSize = 256 << 20 - -type TelegramReflectorCaptureCandidate struct { - Source string - Target string - PeerTagPrefixHex string -} - -func DiscoverTelegramReflectorFromPCAPNG(r io.Reader) ([]TelegramReflectorCaptureCandidate, error) { - if r == nil { - return nil, fmt.Errorf("pcapng reader is required") - } - - data, err := io.ReadAll(io.LimitReader(r, maxTelegramReflectorCaptureSize+1)) - if err != nil { - return nil, fmt.Errorf("read pcapng: %w", err) - } - if len(data) > maxTelegramReflectorCaptureSize { - return nil, fmt.Errorf("pcapng is too large; limit is %d MiB", maxTelegramReflectorCaptureSize>>20) - } - if len(data) < 12 { - return nil, fmt.Errorf("pcapng is too short") - } - - var order binary.ByteOrder - var foundSection bool - var results []TelegramReflectorCaptureCandidate - seen := make(map[string]struct{}) - - for offset := 0; offset+12 <= len(data); { - if bytes.Equal(data[offset:offset+4], []byte{0x0a, 0x0d, 0x0d, 0x0a}) { - if offset+12 > len(data) { - break - } - switch { - case bytes.Equal(data[offset+8:offset+12], []byte{0x4d, 0x3c, 0x2b, 0x1a}): - order = binary.LittleEndian - case bytes.Equal(data[offset+8:offset+12], []byte{0x1a, 0x2b, 0x3c, 0x4d}): - order = binary.BigEndian - default: - return nil, fmt.Errorf("unsupported pcapng byte-order magic at offset %d", offset) - } - foundSection = true - } - - if !foundSection || order == nil { - return nil, fmt.Errorf("pcapng section header not found") - } - - blockLen := int(order.Uint32(data[offset+4 : offset+8])) - if blockLen < 12 || offset+blockLen > len(data) { - return nil, fmt.Errorf("invalid pcapng block length %d at offset %d", blockLen, offset) - } - if int(order.Uint32(data[offset+blockLen-4:offset+blockLen])) != blockLen { - return nil, fmt.Errorf("pcapng block length trailer mismatch at offset %d", offset) - } - - blockType := order.Uint32(data[offset : offset+4]) - switch blockType { - case 0x00000006: - if blockLen >= 32 { - capturedLen := int(order.Uint32(data[offset+20 : offset+24])) - packetStart := offset + 28 - packetEnd := packetStart + capturedLen - if packetEnd <= offset+blockLen-4 { - appendTelegramReflectorPacketCandidates(data[packetStart:packetEnd], &results, seen) - } - } - case 0x00000003: - packetStart := offset + 12 - packetEnd := offset + blockLen - 4 - if packetEnd > packetStart { - appendTelegramReflectorPacketCandidates(data[packetStart:packetEnd], &results, seen) - } - } - - offset += blockLen - } - - if !foundSection { - return nil, fmt.Errorf("pcapng section header not found") - } - return results, nil -} - -func appendTelegramReflectorPacketCandidates(packet []byte, results *[]TelegramReflectorCaptureCandidate, seen map[string]struct{}) { - for offset := 0; offset < len(packet); offset++ { - if candidate, ok := parseIPv4TelegramReflectorHello(packet[offset:]); ok { - addTelegramReflectorCandidate(candidate, results, seen) - return - } - if candidate, ok := parseIPv6TelegramReflectorHello(packet[offset:]); ok { - addTelegramReflectorCandidate(candidate, results, seen) - return - } - } -} - -func addTelegramReflectorCandidate(candidate TelegramReflectorCaptureCandidate, results *[]TelegramReflectorCaptureCandidate, seen map[string]struct{}) { - key := candidate.Target + "|" + candidate.PeerTagPrefixHex - if _, exists := seen[key]; exists { - return - } - seen[key] = struct{}{} - *results = append(*results, candidate) -} - -func parseIPv4TelegramReflectorHello(packet []byte) (TelegramReflectorCaptureCandidate, bool) { - if len(packet) < 20 || packet[0]>>4 != 4 { - return TelegramReflectorCaptureCandidate{}, false - } - ihl := int(packet[0]&0x0f) * 4 - if ihl < 20 || len(packet) < ihl+8 || packet[9] != 17 { - return TelegramReflectorCaptureCandidate{}, false - } - - totalLen := int(binary.BigEndian.Uint16(packet[2:4])) - if totalLen < ihl+8 || totalLen > len(packet) { - return TelegramReflectorCaptureCandidate{}, false - } - - udp := packet[ihl:totalLen] - return parseUDPTelegramReflectorHello( - net.IP(packet[12:16]), - net.IP(packet[16:20]), - udp, - ) -} - -func parseIPv6TelegramReflectorHello(packet []byte) (TelegramReflectorCaptureCandidate, bool) { - if len(packet) < 48 || packet[0]>>4 != 6 || packet[6] != 17 { - return TelegramReflectorCaptureCandidate{}, false - } - - payloadLen := int(binary.BigEndian.Uint16(packet[4:6])) - totalLen := 40 + payloadLen - if totalLen < 48 || totalLen > len(packet) { - return TelegramReflectorCaptureCandidate{}, false - } - - return parseUDPTelegramReflectorHello( - net.IP(packet[8:24]), - net.IP(packet[24:40]), - packet[40:totalLen], - ) -} - -func parseUDPTelegramReflectorHello(sourceIP, targetIP net.IP, udp []byte) (TelegramReflectorCaptureCandidate, bool) { - if len(udp) < 8 { - return TelegramReflectorCaptureCandidate{}, false - } - udpLen := int(binary.BigEndian.Uint16(udp[4:6])) - if udpLen < 8 || udpLen > len(udp) { - return TelegramReflectorCaptureCandidate{}, false - } - - payload := udp[8:udpLen] - if !isTelegramReflectorHelloPayload(payload) { - return TelegramReflectorCaptureCandidate{}, false - } - - sourcePort := int(binary.BigEndian.Uint16(udp[0:2])) - targetPort := int(binary.BigEndian.Uint16(udp[2:4])) - return TelegramReflectorCaptureCandidate{ - Source: net.JoinHostPort(sourceIP.String(), strconv.Itoa(sourcePort)), - Target: net.JoinHostPort(targetIP.String(), strconv.Itoa(targetPort)), - PeerTagPrefixHex: hex.EncodeToString(payload[:12]), - }, true -} - -func isTelegramReflectorHelloPayload(payload []byte) bool { - if len(payload) != 40 { - return false - } - for i := 16; i < 28; i++ { - if payload[i] != 0xff { - return false - } - } - if payload[28] != 0xfe || payload[29] != 0xff || payload[30] != 0xff || payload[31] != 0xff { - return false - } - return binary.BigEndian.Uint64(payload[32:40]) == 123 -} diff --git a/core/telegram_reflector_capture_test.go b/core/telegram_reflector_capture_test.go deleted file mode 100644 index 7a650661..00000000 --- a/core/telegram_reflector_capture_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package core - -import ( - "bytes" - "encoding/binary" - "net" - "testing" -) - -func TestDiscoverTelegramReflectorFromPCAPNG(t *testing.T) { - const prefixHex = "00112233445566778899aabb" - payload := []byte{ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, - 0x01, 0x02, 0x03, 0x04, - } - for i := 0; i < 12; i++ { - payload = append(payload, 0xff) - } - payload = append(payload, 0xfe, 0xff, 0xff, 0xff) - var ping [8]byte - binary.BigEndian.PutUint64(ping[:], 123) - payload = append(payload, ping[:]...) - - packet := makeIPv4UDPPacket( - net.IPv4(10, 0, 0, 2), - net.IPv4(149, 154, 167, 50), - 40000, - 599, - payload, - ) - pcap := makePCAPNG(packet) - - candidates, err := DiscoverTelegramReflectorFromPCAPNG(bytes.NewReader(pcap)) - if err != nil { - t.Fatalf("DiscoverTelegramReflectorFromPCAPNG failed: %v", err) - } - if len(candidates) != 1 { - t.Fatalf("candidates=%d, want 1", len(candidates)) - } - if candidates[0].Target != "149.154.167.50:599" { - t.Fatalf("target=%q", candidates[0].Target) - } - if candidates[0].PeerTagPrefixHex != prefixHex { - t.Fatalf("prefix=%q, want %q", candidates[0].PeerTagPrefixHex, prefixHex) - } -} - -func TestDiscoverTelegramReflectorFromPCAPNGNoMatch(t *testing.T) { - packet := makeIPv4UDPPacket( - net.IPv4(10, 0, 0, 2), - net.IPv4(1, 1, 1, 1), - 40000, - 53, - []byte("not a reflector packet"), - ) - candidates, err := DiscoverTelegramReflectorFromPCAPNG(bytes.NewReader(makePCAPNG(packet))) - if err != nil { - t.Fatalf("DiscoverTelegramReflectorFromPCAPNG failed: %v", err) - } - if len(candidates) != 0 { - t.Fatalf("candidates=%d, want 0", len(candidates)) - } -} - -func makeIPv4UDPPacket(sourceIP, targetIP net.IP, sourcePort, targetPort int, payload []byte) []byte { - udpLen := 8 + len(payload) - totalLen := 20 + udpLen - packet := make([]byte, totalLen) - packet[0] = 0x45 - binary.BigEndian.PutUint16(packet[2:4], uint16(totalLen)) - packet[8] = 64 - packet[9] = 17 - copy(packet[12:16], sourceIP.To4()) - copy(packet[16:20], targetIP.To4()) - - udp := packet[20:] - binary.BigEndian.PutUint16(udp[0:2], uint16(sourcePort)) - binary.BigEndian.PutUint16(udp[2:4], uint16(targetPort)) - binary.BigEndian.PutUint16(udp[4:6], uint16(udpLen)) - copy(udp[8:], payload) - return packet -} - -func makePCAPNG(packet []byte) []byte { - var out bytes.Buffer - - writeBlock := func(blockType uint32, body []byte) { - blockLen := uint32(12 + len(body)) - padding := (4 - (blockLen % 4)) % 4 - blockLen += padding - - _ = binary.Write(&out, binary.LittleEndian, blockType) - _ = binary.Write(&out, binary.LittleEndian, blockLen) - out.Write(body) - out.Write(make([]byte, padding)) - _ = binary.Write(&out, binary.LittleEndian, blockLen) - } - - sectionBody := make([]byte, 16) - binary.LittleEndian.PutUint32(sectionBody[0:4], 0x1a2b3c4d) - binary.LittleEndian.PutUint16(sectionBody[4:6], 1) - binary.LittleEndian.PutUint16(sectionBody[6:8], 0) - for i := 8; i < 16; i++ { - sectionBody[i] = 0xff - } - writeBlock(0x0a0d0d0a, sectionBody) - - idb := make([]byte, 8) - binary.LittleEndian.PutUint16(idb[0:2], 1) - binary.LittleEndian.PutUint32(idb[4:8], 65535) - writeBlock(0x00000001, idb) - - epb := make([]byte, 20+len(packet)) - binary.LittleEndian.PutUint32(epb[12:16], uint32(len(packet))) - binary.LittleEndian.PutUint32(epb[16:20], uint32(len(packet))) - copy(epb[20:], packet) - writeBlock(0x00000006, epb) - - return out.Bytes() -} diff --git a/core/turn_telegram_reflector_probe.go b/core/turn_telegram_reflector_probe.go deleted file mode 100644 index d8c9aabb..00000000 --- a/core/turn_telegram_reflector_probe.go +++ /dev/null @@ -1,283 +0,0 @@ -package core - -import ( - "context" - "crypto/rand" - "encoding/binary" - "encoding/hex" - "errors" - "fmt" - "net" - "strings" - "sync/atomic" - "time" - - "github.com/pion/turn/v5" -) - -const defaultTelegramReflectorProbeTimeout = 45 * time.Second - -var telegramReflectorProbeSequence atomic.Int64 - -type TelegramReflectorProbeConfig struct { - Hash string - Target string - PeerTagHex string - Timeout time.Duration -} - -type TelegramReflectorProbeResult struct { - TurnAddress string - RelayAddress string - Target string - ResponseSource string - ResponseBytes int -} - -func ProbeTelegramReflector(parent context.Context, cfg TelegramReflectorProbeConfig) (TelegramReflectorProbeResult, error) { - var result TelegramReflectorProbeResult - - if parent == nil { - parent = context.Background() - } - - hash := strings.TrimSpace(cfg.Hash) - if hash == "" { - return result, fmt.Errorf("VK hash is required") - } - - target := strings.TrimSpace(cfg.Target) - if target == "" { - return result, fmt.Errorf("Telegram reflector target is required") - } - if looksLikePlaceholder(target) { - return result, fmt.Errorf("Telegram reflector target is still a placeholder: %q", target) - } - - targetAddr, err := net.ResolveUDPAddr("udp", target) - if err != nil { - return result, fmt.Errorf("resolve Telegram reflector target %q: %w", target, err) - } - if targetAddr.IP == nil { - return result, fmt.Errorf("Telegram reflector target %q did not resolve to an IP address", target) - } - - hello, peerTagPrefix, err := buildTelegramReflectorHello(cfg.PeerTagHex) - if err != nil { - return result, err - } - - timeout := cfg.Timeout - if timeout <= 0 { - timeout = defaultTelegramReflectorProbeTimeout - } - - ctx, cancel := context.WithTimeout(parent, timeout) - defer cancel() - - streamID := int(997000 + telegramReflectorProbeSequence.Add(1)) - turnUser, turnPass, turnURLs, err := GetCreds(ctx, hash, streamID) - if err != nil { - return result, fmt.Errorf("get VK TURN credentials: %w", err) - } - if len(turnURLs) == 0 { - return result, fmt.Errorf("VK returned no TURN endpoints") - } - - var attempts []error - for _, candidate := range turnURLs { - if err := ctx.Err(); err != nil { - attempts = append(attempts, err) - break - } - - turnAddr := strings.TrimSpace(candidate) - if turnAddr == "" { - continue - } - - result, err = probeTelegramReflectorAtEndpoint( - ctx, - turnAddr, - turnUser, - turnPass, - targetAddr, - hello, - peerTagPrefix, - ) - if err == nil { - return result, nil - } - attempts = append(attempts, fmt.Errorf("%s: %w", turnAddr, err)) - } - - if len(attempts) == 0 { - return result, fmt.Errorf("VK returned no usable TURN endpoints") - } - return result, fmt.Errorf("all Telegram reflector probe attempts failed: %w", errors.Join(attempts...)) -} - -func probeTelegramReflectorAtEndpoint( - ctx context.Context, - turnAddr string, - username string, - password string, - target *net.UDPAddr, - hello []byte, - peerTagPrefix []byte, -) (TelegramReflectorProbeResult, error) { - result := TelegramReflectorProbeResult{ - TurnAddress: turnAddr, - Target: target.String(), - } - - serverAddr, err := net.ResolveUDPAddr("udp", turnAddr) - if err != nil { - return result, fmt.Errorf("resolve TURN UDP endpoint: %w", err) - } - - controlConn, err := net.DialUDP("udp", nil, serverAddr) - if err != nil { - return result, fmt.Errorf("connect to TURN over UDP: %w", err) - } - defer controlConn.Close() - _ = controlConn.SetReadBuffer(socketBufSize) - _ = controlConn.SetWriteBuffer(socketBufSize) - - stopOnCancel := context.AfterFunc(ctx, func() { - _ = controlConn.Close() - }) - defer stopOnCancel() - - var turnConn net.PacketConn = &connectedUDPConn{controlConn} - client, err := turn.NewClient(&turn.ClientConfig{ - STUNServerAddr: turnAddr, - TURNServerAddr: turnAddr, - Conn: turnConn, - Username: username, - Password: password, - RequestedAddressFamily: turn.RequestedAddressFamilyIPv4, - LoggerFactory: &NullLoggerFactory{}, - }) - if err != nil { - return result, fmt.Errorf("create TURN client: %w", err) - } - defer client.Close() - - if err := client.Listen(); err != nil { - return result, fmt.Errorf("TURN listen: %w", err) - } - - relay, err := client.Allocate() - if err != nil { - return result, fmt.Errorf("Allocate UDP: %w", err) - } - defer relay.Close() - - result.RelayAddress = relay.LocalAddr().String() - - deadline := time.Now().Add(10 * time.Second) - if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { - deadline = ctxDeadline - } - if err := relay.SetDeadline(deadline); err != nil { - return result, fmt.Errorf("set relay deadline: %w", err) - } - - if _, err := relay.WriteTo(hello, target); err != nil { - return result, fmt.Errorf("send Telegram reflector hello to %s through TURN: %w", target, err) - } - - buf := make([]byte, turnUDPProbeReadLimit) - for { - n, source, err := relay.ReadFrom(buf) - if err != nil { - return result, fmt.Errorf("read Telegram reflector response through TURN: %w", err) - } - if !sameUDPAddr(source, target) { - continue - } - if n < 16 { - return result, fmt.Errorf("Telegram reflector response is too short: %d bytes", n) - } - if !equalBytes(buf[:12], peerTagPrefix) { - return result, fmt.Errorf("Telegram reflector response peer_tag prefix mismatch") - } - - result.ResponseSource = source.String() - result.ResponseBytes = n - return result, nil - } -} - -// buildTelegramReflectorHello accepts either the 12-byte peer_tag prefix that -// tgcalls actually uses, or the original 16-byte phoneConnection peer_tag. -// Current tgcalls discards the last four bytes and replaces them with a fresh -// local random tag before sending the reflector hello. -func buildTelegramReflectorHello(peerTagHex string) ([]byte, []byte, error) { - peerTagHex = strings.TrimSpace(peerTagHex) - if peerTagHex == "" { - return nil, nil, fmt.Errorf("Telegram peer_tag prefix is required") - } - if looksLikePlaceholder(peerTagHex) { - return nil, nil, fmt.Errorf("Telegram peer_tag is still a placeholder") - } - - peerTag, err := hex.DecodeString(peerTagHex) - if err != nil { - return nil, nil, fmt.Errorf("decode Telegram peer_tag hex: %w", err) - } - if len(peerTag) != 12 && len(peerTag) != 16 { - return nil, nil, fmt.Errorf("Telegram peer_tag must be a 12-byte prefix (24 hex characters) or full 16 bytes (32 hex characters), got %d bytes", len(peerTag)) - } - peerTagPrefix := peerTag[:12] - - localTag := make([]byte, 4) - for { - if _, err := rand.Read(localTag); err != nil { - return nil, nil, fmt.Errorf("generate Telegram reflector local tag: %w", err) - } - if localTag[0] != 0 || localTag[1] != 0 || localTag[2] != 0 || localTag[3] != 0 { - break - } - } - - packet := make([]byte, 0, 40) - packet = append(packet, peerTagPrefix...) - packet = append(packet, localTag...) - for i := 0; i < 12; i++ { - packet = append(packet, 0xff) - } - packet = append(packet, 0xfe, 0xff, 0xff, 0xff) - - var pingValue [8]byte - binary.BigEndian.PutUint64(pingValue[:], 123) - packet = append(packet, pingValue[:]...) - - prefix := append([]byte(nil), peerTagPrefix...) - return packet, prefix, nil -} - -func looksLikePlaceholder(value string) bool { - return strings.Contains(value, "<") || strings.Contains(value, ">") -} - -func sameUDPAddr(addr net.Addr, expected *net.UDPAddr) bool { - udpAddr, ok := addr.(*net.UDPAddr) - if !ok { - return false - } - return udpAddr.Port == expected.Port && udpAddr.IP.Equal(expected.IP) -} - -func equalBytes(a, b []byte) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} diff --git a/core/turn_telegram_reflector_probe_test.go b/core/turn_telegram_reflector_probe_test.go deleted file mode 100644 index afb8c30a..00000000 --- a/core/turn_telegram_reflector_probe_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package core - -import ( - "context" - "encoding/binary" - "strings" - "testing" -) - -func TestProbeTelegramReflectorRejectsEmptyHash(t *testing.T) { - _, err := ProbeTelegramReflector(context.Background(), TelegramReflectorProbeConfig{}) - if err == nil || !strings.Contains(err.Error(), "VK hash is required") { - t.Fatalf("expected VK hash validation error, got %v", err) - } -} - -func TestProbeTelegramReflectorRejectsPlaceholderTarget(t *testing.T) { - _, err := ProbeTelegramReflector(context.Background(), TelegramReflectorProbeConfig{ - Hash: "test", - Target: "", - }) - if err == nil || !strings.Contains(err.Error(), "placeholder") { - t.Fatalf("expected placeholder validation error, got %v", err) - } -} - -func TestBuildTelegramReflectorHelloFromPrefix(t *testing.T) { - const prefix = "00112233445566778899aabb" - - packet, gotPrefix, err := buildTelegramReflectorHello(prefix) - if err != nil { - t.Fatalf("buildTelegramReflectorHello failed: %v", err) - } - if len(packet) != 40 { - t.Fatalf("packet length=%d, want 40", len(packet)) - } - if got := len(gotPrefix); got != 12 { - t.Fatalf("prefix length=%d, want 12", got) - } - if packet[28] != 0xfe { - t.Fatalf("special marker=%#x, want 0xfe", packet[28]) - } - if got := binary.BigEndian.Uint64(packet[32:40]); got != 123 { - t.Fatalf("ping value=%d, want 123", got) - } -} - -func TestBuildTelegramReflectorHelloAcceptsFullPeerTag(t *testing.T) { - const peerTag = "00112233445566778899aabbccddeeff" - _, prefix, err := buildTelegramReflectorHello(peerTag) - if err != nil { - t.Fatalf("buildTelegramReflectorHello failed: %v", err) - } - if len(prefix) != 12 { - t.Fatalf("prefix length=%d, want 12", len(prefix)) - } -} - -func TestBuildTelegramReflectorHelloRejectsInvalidPeerTag(t *testing.T) { - _, _, err := buildTelegramReflectorHello("0011") - if err == nil || !strings.Contains(err.Error(), "12-byte prefix") { - t.Fatalf("expected peer_tag length error, got %v", err) - } -} diff --git a/docs/telegram-mtproto-turn-udp-probe.md b/docs/telegram-mtproto-turn-udp-probe.md index 1de310a5..b600e4bb 100644 --- a/docs/telegram-mtproto-turn-udp-probe.md +++ b/docs/telegram-mtproto-turn-udp-probe.md @@ -4,57 +4,58 @@ Test the original no-VPS goal directly: -```text -Telegram client-side probe +~~~text +client-side protocol probe -> VK/OK TURN UDP relay -> Telegram MTProto DC -> MTProto response -> VK/OK TURN -> probe -``` +~~~ -This is not a Telegram VoIP test. +This is for ordinary Telegram MTProto, not Telegram VoIP. -## Why this experiment exists +## Probe -Telegram's current general MTProto documentation still lists UDP as an underlying transport and notes that, with UDP, a response may come from a different IP address. The dedicated transport page currently documents TCP, WebSocket/WSS and HTTP/HTTPS, but not the UDP wire format. +cmd/turn-telegram-mtproto-udp-probe sends an unauthenticated req_pq_multi directly to one Telegram DC through the VK/OK TURN UDP allocation. -Because there is no current public UDP framing specification, the probe sends a real unauthenticated `req_pq_multi` using several plausible framings: +Implemented framing candidates: - raw MTProto plaintext payload; -- abridged, with and without the TCP-style `0xef` initializer; -- intermediate, with and without the `0xeeeeeeee` initializer; -- padded intermediate with `0xdddddddd` initializer; -- full MTProto transport envelope with sequence number and CRC32. +- abridged; +- abridged with the TCP-style 0xef initializer; +- intermediate; +- intermediate with 0xeeeeeeee initializer; +- padded intermediate with 0xdddddddd initializer; +- full transport envelope with sequence number and CRC32. -No Telegram account or auth key is required for `req_pq_multi`. +No Telegram account, auth key or API ID is required for req_pq_multi. -## Default Telegram target +## Single-DC run -The probe defaults to DC2 from Telegram Desktop's built-in DC list: +~~~powershell +go run ./cmd/turn-telegram-mtproto-udp-probe -hash "" -target "149.154.167.51:443" +~~~ -```text -149.154.167.51:443 -``` +## Recommended no-VPS run -An alternate DC can be supplied with `-target`. +Use the aggregate runner instead of manually testing one DC: -## Run +~~~powershell +$vk = Read-Host "VK call hash or full join link" +go run ./cmd/turn-no-vps-probe -hash $vk +~~~ -```powershell -go run ./cmd/turn-telegram-mtproto-udp-probe ` - -hash "" ` - -target "149.154.167.51:443" -``` +It first proves current bidirectional UDP egress with a public STUN Binding exchange and then checks all current built-in production IPv4 Telegram Desktop DC targets. ## Acceptance criterion -The probe prints `SUCCESS` only when a returned UDP datagram contains a valid plaintext MTProto `resPQ#05162463` whose nonce exactly matches the random nonce from the corresponding `req_pq_multi`. +SUCCESS is printed only when the returned UDP datagram contains a valid plaintext MTProto resPQ#05162463 whose nonce exactly matches the random nonce from the corresponding req_pq_multi. -A timeout is not by itself proof that Telegram has no UDP support: Telegram's general documentation notes that UDP responses may originate from a different server IP. TURN permissions are peer-IP scoped, so such a response can be filtered unless the alternate source IP also has a permission. A positive `resPQ`, however, is definitive proof that ordinary MTProto reaches a Telegram DC through VK TURN without a VPS. +A timeout is not proof that Telegram has no UDP support. A positive matching resPQ, however, is definitive protocol-level evidence that ordinary MTProto can reach that Telegram DC through VK TURN without a VPS. -## What success would unlock +## Current interpretation -If this probe succeeds, the next step is to implement a reliable local stream/datagram adapter around the working Telegram UDP transport and then connect Telegram-facing traffic to it. +Manual STUN testing has already confirmed bidirectional direct external UDP egress through the tested VK/OK TURN endpoint. -If every DC and framing candidate times out, direct ordinary MTProto-over-UDP becomes unlikely and the next no-VPS path to test is Telegram HTTPS/WSS over a UDP-native QUIC carrier. +Therefore, if the aggregate runner reports STUN PASS but no valid Telegram response across all tested DCs/framing candidates, the remaining obstacle is Telegram transport compatibility or peer policy rather than general TURN UDP egress. diff --git a/docs/telegram-voip-reflector-turn-probe.md b/docs/telegram-voip-reflector-turn-probe.md deleted file mode 100644 index 8dd8498f..00000000 --- a/docs/telegram-voip-reflector-turn-probe.md +++ /dev/null @@ -1,73 +0,0 @@ -# Telegram VoIP reflector через VK TURN UDP - -## Что проверяется - -Этот probe проверяет UDP relay для Telegram-звонков, а не обычный MTProto messaging. - -Актуальный Telegram Desktop объявляет для звонков `udp_p2p` и `udp_reflector`. Для обычного `phoneConnection` сервер возвращает `ip`, `port` и 16-байтовый `peer_tag`. - -Текущий tgcalls фактически использует только первые 12 байт `peer_tag`: последние 4 байта заменяются случайным локальным tag перед отправкой reflector hello. Поэтому probe принимает либо 12-байтовый prefix, либо полный 16-байтовый peer tag. - -## Автоматическое получение target и peer-tag-prefix на Windows - -Не подставляйте примеры вида `` буквально. - -Встроенный Windows Packet Monitor (`pktmon`) может записать короткий активный Telegram-звонок. Запускайте PowerShell от администратора. - -Из корня репозитория: - -```powershell -pktmon stop 2>$null -pktmon filter remove 2>$null -pktmon start --capture --pkt-size 0 --file-name telegram-call.etl -``` - -После запуска capture начните обычный 1:1 звонок в Telegram Desktop, дождитесь соединения и подержите звонок несколько секунд. Затем: - -```powershell -pktmon stop -pktmon etl2pcap telegram-call.etl --out telegram-call.pcapng - -go run ./cmd/telegram-reflector-discover -pcap ".\telegram-call.pcapng" -``` - -Discovery ищет точную 40-байтовую сигнатуру reflector hello из текущего tgcalls и выводит: - -```text -Target: 149.x.x.x:port -Peer tag prefix: 24_hex_characters -Probe arguments: - -target "..." -peer-tag-prefix "..." -``` - -Если найдено несколько кандидатов, сначала проверяйте каждый UDP target по очереди. - -## Запуск probe - -```powershell -go run ./cmd/turn-telegram-reflector-probe ` - -hash "" ` - -target "" ` - -peer-tag-prefix "<24_HEX_PREFIX_FROM_DISCOVERY>" -``` - -Старый параметр `-peer-tag` с полным 32-символьным hex также поддерживается. - -## Критерий успеха - -`SUCCESS` выводится только если: - -1. VK/OK TURN UDP allocation создан; -2. tgcalls-compatible reflector hello отправлен к указанному Telegram endpoint; -3. ответ получен именно от этого `ip:port`; -4. первые 12 байт peer tag в ответе совпадают с обнаруженным prefix. - -Успех подтвердит, что Telegram VoIP UDP reflector доступен через VK TURN без `wdtt-server`/VPS. - -## Ограничения - -- endpoint и peer-tag-prefix динамические и относятся к конкретному активному звонку; -- capture содержит сетевые метаданные звонка, поэтому не публикуйте `.etl`/`.pcapng`; -- probe не инициирует Telegram-звонок и не получает `phone.getCall` самостоятельно; -- probe не передаёт голос/видео; -- обычный MTProto messaging по-прежнему остаётся stream-транспортом. diff --git a/docs/turn-tcp-rfc6062-probe.md b/docs/turn-tcp-rfc6062-probe.md index 49475c4d..a95604bc 100644 --- a/docs/turn-tcp-rfc6062-probe.md +++ b/docs/turn-tcp-rfc6062-probe.md @@ -2,81 +2,133 @@ ## Цель -Проверить, может ли используемая PWDTT инфраструктура VK/OK TURN самостоятельно передавать трафик к внешним peer без `wdtt-server` и VPS. +Проверить исходную гипотезу PWDTT: можно ли исключить wdtt-server/VPS и передавать трафик через инфраструктуру VK/OK TURN непосредственно внешним peer. -Эксперимент не меняет обычный режим PWDTT. Все проверки запускаются отдельными CLI-командами. +Эксперимент не меняет обычный runtime PWDTT. Все проверки запускаются отдельными CLI-командами. -## TCP / RFC 6062 +## Что уже подтверждено вручную -Команда: +### TCP / RFC 6062 -```powershell -go run ./cmd/turn-tcp-probe -hash "" -target "example.com:80" -mode http -``` +Оба полученных 2026-09-07 TURN endpoint приняли control/auth path, но отклонили TCP allocation: -Фактический результат проверки 2026-09-07: оба полученных TURN endpoint отклонили `AllocateTCP()`: - -```text +~~~text 442: TCP Transport is not allowed by the TURN Server configuration -``` +~~~ + +Следовательно, стандартный RFC 6062 TURN TCP relay на протестированной конфигурации выключен. + +### UDP direct egress + +DNS-проверка к 1.1.1.1:53 создавала UDP allocation и отправляла пакет, но ответ не возвращался. + +Контрольная STUN-проверка затем успешно прошла через внешний peer: + +~~~text +TURN endpoint: 91.231.135.154:19302 +Relayed address: 91.231.135.154:42880 +Target: 162.159.207.0:3478 +Response source: 162.159.207.0:3478 +STUN mapped address: 91.231.135.154:42880 +Response bytes: 32 +~~~ + +Это подтверждает двусторонний внешний UDP egress через протестированный VK/OK TURN без VPS. Таймаут DNS/53 не является доказательством общего запрета произвольного UDP. + +## Основной no-VPS verdict runner + +Для исходной задачи используйте: + +~~~powershell +Set-Location "C:\base\projects\PWDTT" +$vk = Read-Host "VK call hash or full join link" +go run ./cmd/turn-no-vps-probe -hash $vk +~~~ + +Runner выполняет два этапа: + +1. STUN control: подтверждает, что внешний двусторонний UDP через TURN работает в текущем запуске. +2. Ordinary Telegram MTProto UDP: пробует production IPv4 DC из встроенного списка Telegram Desktop и все реализованные framing-кандидаты. + +Список DC по умолчанию: + +~~~text +149.154.175.50:443 +149.154.167.51:443 +95.161.76.100:443 +149.154.175.100:443 +149.154.167.91:443 +149.154.171.5:443 +~~~ + +Список можно заменить: + +~~~powershell +go run ./cmd/turn-no-vps-probe -hash $vk -telegram-targets "149.154.167.51:443,149.154.167.91:443" +~~~ + +Runner принимает как raw hash, так и полный https://vk.com/call/join/... URL и не печатает TURN credentials. + +## Интерпретация verdict + +### UDP control failed + +~~~text +UDP egress: NOT CONFIRMED +Ordinary Telegram MTProto over TURN UDP: NOT TESTED +~~~ + +Сначала нужно восстановить рабочий VK hash/TURN allocation или выяснить изменение политики TURN. + +### UDP PASS, Telegram no response -Следовательно, протестированная конфигурация VK/OK TURN не предоставляет TCP relay по RFC 6062. До внешнего TCP peer выполнение не дошло. +~~~text +UDP egress: PASS +Ordinary Telegram MTProto over tested UDP framings/DCs: NO VALID RESPONSE +Direct no-VPS Telegram messaging: NOT CONFIRMED +~~~ -## UDP direct egress +Это означает, что ограничение уже не в общем UDP egress. Оно находится на уровне Telegram transport/framing/peer policy. Это не доказывает математическую невозможность UDP transport, но протестированные варианты не дают обычный MTProto exchange. -Следующая гипотеза проверяет обычный UDP allocation, который уже используется WDTT, но peer теперь является не `wdtt-server`, а публичным DNS-сервером. +### Telegram PASS -Схема: +Успех принимается только после получения валидного plaintext resPQ#05162463 с nonce, совпадающим с отправленным req_pq_multi. -```text -PWDTT probe - ↓ -VK TURN Allocate() - ↓ -1.1.1.1:53 - ↓ -DNS response -``` +~~~text +UDP egress: PASS +Ordinary Telegram MTProto over TURN UDP: PASS +Direct no-VPS Telegram messaging transport: CONFIRMED AT PROTOCOL-PROBE LEVEL +~~~ -Запуск из корня репозитория: +После этого имеет смысл писать persistent transport adapter. До такого результата интегрировать эксперимент в GUI/WireGuard runtime не следует. -```powershell -go run ./cmd/turn-udp-probe -hash "" -target "1.1.1.1:53" -name "example.com" -``` +## Отдельные probes -Успешный результат: +DNS: -```text -TURN UDP direct-egress probe -... -SUCCESS -TURN endpoint: ... -Relayed address: ... -Target: 1.1.1.1:53 -Response source: ... -DNS answers: ... -Response bytes: ... -``` +~~~powershell +go run ./cmd/turn-udp-probe -hash $vk -target "1.1.1.1:53" -name "example.com" +~~~ -Успех означает, что TURN relay передал DNS query непосредственно внешнему UDP peer и вернул валидный DNS response с тем же transaction ID. Это подтверждает arbitrary UDP egress для проверенного endpoint без VPS. +STUN: -## Интерпретация ошибок UDP +~~~powershell +go run ./cmd/turn-udp-stun-probe -hash $vk +~~~ -- ошибка `Allocate UDP` — обычный UDP allocation не был создан; -- ошибка `send DNS query` / permission-related error — внешний peer запрещён или недоступен; -- timeout на `read DNS response` — запрос ушёл, но валидный ответ через relay не получен; -- ошибка validation — через relay пришёл пакет, но он не является ожидаемым валидным DNS response; -- `SUCCESS` — подтверждена двусторонняя UDP-передача к публичному peer. +Один Telegram DC: -CLI перебирают TURN endpoints, полученные для указанного VK hash, и не выводят TURN username/password. +~~~powershell +go run ./cmd/turn-telegram-mtproto-udp-probe -hash $vk -target "149.154.167.51:443" +~~~ ## Ограничения -Это диагностический Prototype, а не production proxy: +Это diagnostic prototype: - не поднимает SOCKS5; - не интегрирован в GUI; - не маршрутизирует системный трафик; -- UDP probe проверяет только один безопасный DNS-сценарий; -- результат одного endpoint не гарантирует одинаковую политику всех TURN-серверов; -- работа зависит от внешней инфраструктуры VK/OK и может измениться независимо от PWDTT. +- RFC 6062 TCP relay на протестированных TURN endpoint выключен; +- обычный Telegram MTProto-over-UDP остаётся экспериментальной гипотезой до положительного resPQ; +- политика внешней инфраструктуры VK/OK может измениться независимо от PWDTT. From 29765eb7b569ff0975278d5f65e050994a9d9fd8 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 23:53:03 +0300 Subject: [PATCH 09/11] fix: decouple MTProto probe from removed reflector helper --- core/turn_telegram_mtproto_udp_probe.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/turn_telegram_mtproto_udp_probe.go b/core/turn_telegram_mtproto_udp_probe.go index 22f209ca..13a668e2 100644 --- a/core/turn_telegram_mtproto_udp_probe.go +++ b/core/turn_telegram_mtproto_udp_probe.go @@ -1,6 +1,7 @@ package core import ( + "bytes" "context" "crypto/rand" "encoding/binary" @@ -352,7 +353,7 @@ func validateTelegramResPQ(datagram []byte, expectedNonce [16]byte) bool { if binary.LittleEndian.Uint32(datagram[offset:offset+4]) != resPQConstructor { continue } - if !equalBytes(datagram[offset+4:offset+20], expectedNonce[:]) { + if !bytes.Equal(datagram[offset+4:offset+20], expectedNonce[:]) { continue } From e7b4e9a88cfb3312c955c67728103d2c54b7b989 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Mon, 7 Sep 2026 23:53:07 +0300 Subject: [PATCH 10/11] test: use standard byte comparison in MTProto probe --- core/turn_telegram_mtproto_udp_probe_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/turn_telegram_mtproto_udp_probe_test.go b/core/turn_telegram_mtproto_udp_probe_test.go index aea39b5f..01ba5823 100644 --- a/core/turn_telegram_mtproto_udp_probe_test.go +++ b/core/turn_telegram_mtproto_udp_probe_test.go @@ -1,6 +1,7 @@ package core import ( + "bytes" "encoding/binary" "testing" "time" @@ -23,7 +24,7 @@ func TestBuildTelegramReqPQMulti(t *testing.T) { if got := binary.LittleEndian.Uint32(payload[20:24]); got != 0xbe7e8ef1 { t.Fatalf("constructor=%#x, want req_pq_multi", got) } - if !equalBytes(payload[24:40], nonce[:]) { + if !bytes.Equal(payload[24:40], nonce[:]) { t.Fatal("nonce not serialized into req_pq_multi") } } From 1223d39a07129b015659e00378df9168ae64f364 Mon Sep 17 00:00:00 2001 From: Regstar2 Date: Tue, 8 Sep 2026 20:15:04 +0300 Subject: [PATCH 11/11] experiment: add protocol-neutral TURN UDP egress probe --- cmd/turn-udp-raw-probe/main.go | 147 ++++++++++++++++ cmd/turn-udp-raw-probe/main_test.go | 41 +++++ core/turn_udp_raw_probe.go | 260 ++++++++++++++++++++++++++++ core/turn_udp_raw_probe_test.go | 60 +++++++ docs/turn-udp-raw-probe.md | 74 ++++++++ 5 files changed, 582 insertions(+) create mode 100644 cmd/turn-udp-raw-probe/main.go create mode 100644 cmd/turn-udp-raw-probe/main_test.go create mode 100644 core/turn_udp_raw_probe.go create mode 100644 core/turn_udp_raw_probe_test.go create mode 100644 docs/turn-udp-raw-probe.md diff --git a/cmd/turn-udp-raw-probe/main.go b/cmd/turn-udp-raw-probe/main.go new file mode 100644 index 00000000..be87d9b4 --- /dev/null +++ b/cmd/turn-udp-raw-probe/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "encoding/hex" + "flag" + "fmt" + "os" + "strings" + "time" + + core "wg-turn-client" +) + +func main() { + hashInput := flag.String("hash", "", "VK call hash or full https://vk.com/call/join/... link") + target := flag.String("target", "", "UDP target in host:port form") + payloadHex := flag.String("payload-hex", "", "one UDP payload as hex") + payloadText := flag.String("payload-text", "", "one UDP payload as UTF-8 text") + timeout := flag.Duration("timeout", 45*time.Second, "overall probe timeout") + readTimeout := flag.Duration("read-timeout", 5*time.Second, "response wait per TURN endpoint") + flag.Parse() + + hash := normalizeVKHash(*hashInput) + if hash == "" { + fmt.Fprintln(os.Stderr, "error: -hash is required") + os.Exit(2) + } + if strings.TrimSpace(*target) == "" { + fmt.Fprintln(os.Stderr, "error: -target is required") + os.Exit(2) + } + + payload, err := parsePayload(*payloadHex, *payloadText) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + + fmt.Println("TURN raw UDP direct-egress probe") + fmt.Printf("Target: %s\n", *target) + fmt.Printf("Payload bytes: %d\n", len(payload)) + fmt.Println("One datagram will be sent per VK/OK TURN endpoint.") + fmt.Println() + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + result, err := core.ProbeTurnUDPRaw(ctx, core.TurnUDPRawProbeConfig{ + Hash: hash, + Target: *target, + Payload: payload, + Timeout: *timeout, + ReadTimeout: *readTimeout, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "FAILED: %v\n", err) + os.Exit(1) + } + + for i, attempt := range result.Attempts { + fmt.Printf("Attempt %d\n", i+1) + fmt.Printf(" TURN endpoint: %s\n", attempt.TurnAddress) + fmt.Printf(" Target: %s\n", attempt.Target) + if attempt.Allocated { + fmt.Printf(" Allocate: PASS (%s)\n", attempt.RelayAddress) + } else { + fmt.Println(" Allocate: FAIL") + } + if attempt.SentBytes > 0 { + fmt.Printf(" Send: PASS (%d bytes)\n", attempt.SentBytes) + } else { + fmt.Println(" Send: NOT CONFIRMED") + } + switch { + case attempt.ResponseBytes > 0: + fmt.Printf(" Receive: PASS (%d bytes from %s)\n", attempt.ResponseBytes, attempt.ResponseSource) + fmt.Printf(" Response hex preview: %s\n", attempt.ResponseHexPreview) + case attempt.ReceiveTimedOut: + fmt.Printf(" Receive: TIMEOUT after %s\n", readTimeout.String()) + default: + fmt.Println(" Receive: NOT CONFIRMED") + } + if attempt.Error != "" { + fmt.Printf(" Error: %s\n", attempt.Error) + } + fmt.Println() + } + + fmt.Println("VERDICT") + switch { + case result.AnyResponse: + fmt.Println("UDP allocation: PASS") + fmt.Println("Outbound UDP send: PASS") + fmt.Println("Bidirectional UDP response: PASS") + fmt.Println("Direct UDP egress to this target through VK/OK TURN: CONFIRMED") + case result.AnySent: + fmt.Println("UDP allocation: PASS") + fmt.Println("Outbound UDP send: PASS") + fmt.Println("Bidirectional UDP response: NOT CONFIRMED") + fmt.Println("The target did not reply through the relay; this is not equivalent to an allocation/send failure.") + os.Exit(3) + default: + fmt.Println("UDP allocation/send path: NOT CONFIRMED") + os.Exit(1) + } +} + +func parsePayload(hexValue, textValue string) ([]byte, error) { + hexValue = strings.TrimSpace(hexValue) + if hexValue != "" && textValue != "" { + return nil, fmt.Errorf("use only one of -payload-hex or -payload-text") + } + if hexValue == "" && textValue == "" { + return nil, fmt.Errorf("one of -payload-hex or -payload-text is required") + } + if hexValue != "" { + payload, err := hex.DecodeString(hexValue) + if err != nil { + return nil, fmt.Errorf("decode -payload-hex: %w", err) + } + if len(payload) == 0 { + return nil, fmt.Errorf("payload must not be empty") + } + return payload, nil + } + return []byte(textValue), nil +} + +func normalizeVKHash(input string) string { + value := strings.TrimSpace(input) + if value == "" { + return "" + } + const marker = "/call/join/" + if index := strings.Index(value, marker); index >= 0 { + value = value[index+len(marker):] + } + if index := strings.IndexAny(value, "?#/"); index >= 0 { + value = value[:index] + } + value = strings.TrimSpace(value) + if strings.ContainsAny(value, "<>") { + return "" + } + return value +} diff --git a/cmd/turn-udp-raw-probe/main_test.go b/cmd/turn-udp-raw-probe/main_test.go new file mode 100644 index 00000000..c6a1bce6 --- /dev/null +++ b/cmd/turn-udp-raw-probe/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestParsePayloadHex(t *testing.T) { + got, err := parsePayload("00ff10", "") + if err != nil { + t.Fatalf("parsePayload failed: %v", err) + } + if !bytes.Equal(got, []byte{0x00, 0xff, 0x10}) { + t.Fatalf("payload=%x", got) + } +} + +func TestParsePayloadText(t *testing.T) { + got, err := parsePayload("", "ping") + if err != nil { + t.Fatalf("parsePayload failed: %v", err) + } + if string(got) != "ping" { + t.Fatalf("payload=%q", got) + } +} + +func TestParsePayloadRejectsBoth(t *testing.T) { + _, err := parsePayload("00", "x") + if err == nil || !strings.Contains(err.Error(), "only one") { + t.Fatalf("expected exclusivity error, got %v", err) + } +} + +func TestNormalizeVKHashFullLink(t *testing.T) { + got := normalizeVKHash("https://vk.com/call/join/abc123?foo=bar") + if got != "abc123" { + t.Fatalf("hash=%q, want abc123", got) + } +} diff --git a/core/turn_udp_raw_probe.go b/core/turn_udp_raw_probe.go new file mode 100644 index 00000000..98befa10 --- /dev/null +++ b/core/turn_udp_raw_probe.go @@ -0,0 +1,260 @@ +package core + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/pion/turn/v5" +) + +const ( + defaultTurnUDPRawProbeTimeout = 45 * time.Second + defaultTurnUDPRawProbeReadTimeout = 5 * time.Second + maxTurnUDPRawProbePayload = 1200 + turnUDPRawPreviewBytes = 64 +) + +var turnUDPRawProbeSequence atomic.Int64 + +// TurnUDPRawProbeConfig describes a one-datagram UDP egress diagnostic. +// Payload is intentionally capped to avoid fragmentation and accidental bulk traffic. +type TurnUDPRawProbeConfig struct { + Hash string + Target string + Payload []byte + Timeout time.Duration + ReadTimeout time.Duration +} + +// TurnUDPRawAttempt records every stage separately for one VK/OK TURN endpoint. +// TURN credentials are deliberately never included. +type TurnUDPRawAttempt struct { + TurnAddress string + RelayAddress string + Target string + Allocated bool + SentBytes int + ResponseSource string + ResponseBytes int + ResponseHexPreview string + ReceiveTimedOut bool + Error string +} + +// TurnUDPRawProbeResult aggregates all TURN endpoint attempts. +type TurnUDPRawProbeResult struct { + Attempts []TurnUDPRawAttempt + AnyAllocated bool + AnySent bool + AnyResponse bool +} + +// ProbeTurnUDPRaw allocates UDP relay(s) on VK/OK TURN, sends exactly one +// caller-supplied datagram to the target per TURN endpoint, then waits for one +// response datagram. +// +// A receive timeout is diagnostic data, not an allocation/send failure. This +// makes it possible to distinguish "TURN could not send" from "packet was sent +// but the peer did not reply". +func ProbeTurnUDPRaw(parent context.Context, cfg TurnUDPRawProbeConfig) (TurnUDPRawProbeResult, error) { + var result TurnUDPRawProbeResult + + if parent == nil { + parent = context.Background() + } + + hash := strings.TrimSpace(cfg.Hash) + if hash == "" { + return result, fmt.Errorf("VK hash is required") + } + + target := strings.TrimSpace(cfg.Target) + if target == "" { + return result, fmt.Errorf("UDP target is required") + } + targetAddr, err := net.ResolveUDPAddr("udp", target) + if err != nil { + return result, fmt.Errorf("resolve UDP target %q: %w", target, err) + } + if targetAddr.IP == nil { + return result, fmt.Errorf("UDP target %q did not resolve to an IP address", target) + } + + if len(cfg.Payload) == 0 { + return result, fmt.Errorf("UDP payload is required") + } + if len(cfg.Payload) > maxTurnUDPRawProbePayload { + return result, fmt.Errorf("UDP payload is too large: %d bytes (max %d)", len(cfg.Payload), maxTurnUDPRawProbePayload) + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTurnUDPRawProbeTimeout + } + readTimeout := cfg.ReadTimeout + if readTimeout <= 0 { + readTimeout = defaultTurnUDPRawProbeReadTimeout + } + + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + streamID := int(998000 + turnUDPRawProbeSequence.Add(1)) + turnUser, turnPass, turnURLs, err := GetCreds(ctx, hash, streamID) + if err != nil { + return result, fmt.Errorf("get VK TURN credentials: %w", err) + } + if len(turnURLs) == 0 { + return result, fmt.Errorf("VK returned no TURN endpoints") + } + + for _, candidate := range turnURLs { + turnAddr := strings.TrimSpace(candidate) + if turnAddr == "" { + continue + } + if err := ctx.Err(); err != nil { + result.Attempts = append(result.Attempts, TurnUDPRawAttempt{ + TurnAddress: turnAddr, + Target: targetAddr.String(), + Error: err.Error(), + }) + break + } + + attempt := probeTurnUDPRawAtEndpoint( + ctx, + turnAddr, + turnUser, + turnPass, + targetAddr, + cfg.Payload, + readTimeout, + ) + result.Attempts = append(result.Attempts, attempt) + result.AnyAllocated = result.AnyAllocated || attempt.Allocated + result.AnySent = result.AnySent || attempt.SentBytes > 0 + result.AnyResponse = result.AnyResponse || attempt.ResponseBytes > 0 + } + + if len(result.Attempts) == 0 { + return result, errors.New("VK returned no usable TURN endpoints") + } + return result, nil +} + +func probeTurnUDPRawAtEndpoint( + ctx context.Context, + turnAddr string, + username string, + password string, + target *net.UDPAddr, + payload []byte, + readTimeout time.Duration, +) TurnUDPRawAttempt { + attempt := TurnUDPRawAttempt{ + TurnAddress: turnAddr, + Target: target.String(), + } + + serverAddr, err := net.ResolveUDPAddr("udp", turnAddr) + if err != nil { + attempt.Error = fmt.Sprintf("resolve TURN UDP endpoint: %v", err) + return attempt + } + + controlConn, err := net.DialUDP("udp", nil, serverAddr) + if err != nil { + attempt.Error = fmt.Sprintf("connect to TURN over UDP: %v", err) + return attempt + } + defer controlConn.Close() + _ = controlConn.SetReadBuffer(socketBufSize) + _ = controlConn.SetWriteBuffer(socketBufSize) + + stopOnCancel := context.AfterFunc(ctx, func() { + _ = controlConn.Close() + }) + defer stopOnCancel() + + var turnConn net.PacketConn = &connectedUDPConn{controlConn} + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Conn: turnConn, + Username: username, + Password: password, + RequestedAddressFamily: turn.RequestedAddressFamilyIPv4, + LoggerFactory: &NullLoggerFactory{}, + }) + if err != nil { + attempt.Error = fmt.Sprintf("create TURN client: %v", err) + return attempt + } + defer client.Close() + + if err := client.Listen(); err != nil { + attempt.Error = fmt.Sprintf("TURN listen: %v", err) + return attempt + } + + relay, err := client.Allocate() + if err != nil { + attempt.Error = fmt.Sprintf("Allocate UDP: %v", err) + return attempt + } + defer relay.Close() + + attempt.Allocated = true + attempt.RelayAddress = relay.LocalAddr().String() + + deadline := time.Now().Add(readTimeout) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + if err := relay.SetDeadline(deadline); err != nil { + attempt.Error = fmt.Sprintf("set relay deadline: %v", err) + return attempt + } + + n, err := relay.WriteTo(payload, target) + if err != nil { + attempt.Error = fmt.Sprintf("send UDP datagram to %s through TURN: %v", target, err) + return attempt + } + attempt.SentBytes = n + + buf := make([]byte, turnUDPProbeReadLimit) + n, source, err := relay.ReadFrom(buf) + if err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + attempt.ReceiveTimedOut = true + return attempt + } + if ctx.Err() != nil { + attempt.Error = ctx.Err().Error() + return attempt + } + attempt.Error = fmt.Sprintf("read UDP response through TURN: %v", err) + return attempt + } + + attempt.ResponseSource = source.String() + attempt.ResponseBytes = n + attempt.ResponseHexPreview = turnUDPRawHexPreview(buf[:n]) + return attempt +} + +func turnUDPRawHexPreview(data []byte) string { + if len(data) > turnUDPRawPreviewBytes { + data = data[:turnUDPRawPreviewBytes] + } + return hex.EncodeToString(data) +} diff --git a/core/turn_udp_raw_probe_test.go b/core/turn_udp_raw_probe_test.go new file mode 100644 index 00000000..0941d1be --- /dev/null +++ b/core/turn_udp_raw_probe_test.go @@ -0,0 +1,60 @@ +package core + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestProbeTurnUDPRawRejectsEmptyHash(t *testing.T) { + _, err := ProbeTurnUDPRaw(context.Background(), TurnUDPRawProbeConfig{ + Target: "127.0.0.1:9", + Payload: []byte{1}, + }) + if err == nil || !strings.Contains(err.Error(), "VK hash is required") { + t.Fatalf("expected VK hash validation error, got %v", err) + } +} + +func TestProbeTurnUDPRawRejectsEmptyTarget(t *testing.T) { + _, err := ProbeTurnUDPRaw(context.Background(), TurnUDPRawProbeConfig{ + Hash: "dummy", + Payload: []byte{1}, + }) + if err == nil || !strings.Contains(err.Error(), "UDP target is required") { + t.Fatalf("expected target validation error, got %v", err) + } +} + +func TestProbeTurnUDPRawRejectsEmptyPayload(t *testing.T) { + _, err := ProbeTurnUDPRaw(context.Background(), TurnUDPRawProbeConfig{ + Hash: "dummy", + Target: "127.0.0.1:9", + }) + if err == nil || !strings.Contains(err.Error(), "UDP payload is required") { + t.Fatalf("expected payload validation error, got %v", err) + } +} + +func TestProbeTurnUDPRawRejectsOversizedPayload(t *testing.T) { + _, err := ProbeTurnUDPRaw(context.Background(), TurnUDPRawProbeConfig{ + Hash: "dummy", + Target: "127.0.0.1:9", + Payload: bytes.Repeat([]byte{1}, maxTurnUDPRawProbePayload+1), + }) + if err == nil || !strings.Contains(err.Error(), "UDP payload is too large") { + t.Fatalf("expected payload size validation error, got %v", err) + } +} + +func TestTurnUDPRawHexPreview(t *testing.T) { + data := bytes.Repeat([]byte{0xab}, turnUDPRawPreviewBytes+10) + got := turnUDPRawHexPreview(data) + if len(got) != turnUDPRawPreviewBytes*2 { + t.Fatalf("preview hex length=%d, want %d", len(got), turnUDPRawPreviewBytes*2) + } + if strings.Contains(got, " ") { + t.Fatalf("preview must be compact hex, got %q", got) + } +} diff --git a/docs/turn-udp-raw-probe.md b/docs/turn-udp-raw-probe.md new file mode 100644 index 00000000..7ecb3052 --- /dev/null +++ b/docs/turn-udp-raw-probe.md @@ -0,0 +1,74 @@ +# Raw UDP direct-egress probe + +## Purpose + +This diagnostic answers one narrow question without assuming DNS, STUN, Telegram VoIP, or MTProto framing: + +> Can the current VK/OK TURN allocation send one caller-supplied UDP datagram to a chosen external peer, and does any UDP response return through the same relay? + +It does not start WireGuard, SOCKS, the GUI, or `wdtt-server`. + +## Run + +PowerShell: + +~~~powershell +Set-Location "C:\base\projects\PWDTT" + +$vk = Read-Host "VK call hash or full join link" + +go run ./cmd/turn-udp-raw-probe ` + -hash $vk ` + -target "host.example:12345" ` + -payload-text "ping" +~~~ + +Hex payload: + +~~~powershell +go run ./cmd/turn-udp-raw-probe ` + -hash $vk ` + -target "203.0.113.10:9999" ` + -payload-hex "01020304" +~~~ + +The probe sends exactly one datagram per VK/OK TURN endpoint. Payload size is capped at 1200 bytes. + +## Stage reporting + +Every TURN endpoint is reported independently: + +~~~text +Attempt 1 + TURN endpoint: ... + Target: ... + Allocate: PASS (...) + Send: PASS (4 bytes) + Receive: TIMEOUT after 5s +~~~ + +This distinction is intentional: + +- `Allocate: FAIL` means a usable UDP relay was not created; +- `Send: PASS` means Pion accepted the datagram for the external peer through the relay; +- `Receive: PASS` proves a datagram returned through TURN; +- `Receive: TIMEOUT` does not turn an allocation/send success into an allocation failure. + +Exit codes: + +- `0`: at least one response datagram was received; +- `3`: allocation/send succeeded but no response was received; +- `1`: allocation/send path was not confirmed; +- `2`: invalid CLI arguments. + +## Why this exists + +The existing probes answer protocol-specific questions: + +- `turn-udp-probe`: DNS A request/response validation; +- `turn-udp-stun-probe`: STUN Binding validation; +- `turn-telegram-mtproto-udp-probe`: experimental MTProto UDP framing. + +The raw probe is the protocol-neutral control. It is useful when testing an external UDP echo service, a service you control temporarily, or any protocol where you already know the exact request datagram. + +A positive response only confirms the tested peer/port and current TURN endpoint policy. It does not imply unrestricted Internet UDP access.