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-tcp-probe/main.go b/cmd/turn-tcp-probe/main.go new file mode 100644 index 00000000..9c809e15 --- /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 (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") + 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/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/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/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/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_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/core/turn_telegram_mtproto_udp_probe.go b/core/turn_telegram_mtproto_udp_probe.go new file mode 100644 index 00000000..13a668e2 --- /dev/null +++ b/core/turn_telegram_mtproto_udp_probe.go @@ -0,0 +1,371 @@ +package core + +import ( + "bytes" + "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 !bytes.Equal(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..01ba5823 --- /dev/null +++ b/core/turn_telegram_mtproto_udp_probe_test.go @@ -0,0 +1,86 @@ +package core + +import ( + "bytes" + "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 !bytes.Equal(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/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/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/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)) + } +} diff --git a/docs/telegram-mtproto-turn-udp-probe.md b/docs/telegram-mtproto-turn-udp-probe.md new file mode 100644 index 00000000..b600e4bb --- /dev/null +++ b/docs/telegram-mtproto-turn-udp-probe.md @@ -0,0 +1,61 @@ +# Ordinary Telegram MTProto over VK TURN UDP + +## Goal + +Test the original no-VPS goal directly: + +~~~text +client-side protocol probe + -> VK/OK TURN UDP relay + -> Telegram MTProto DC + -> MTProto response + -> VK/OK TURN + -> probe +~~~ + +This is for ordinary Telegram MTProto, not Telegram VoIP. + +## Probe + +cmd/turn-telegram-mtproto-udp-probe sends an unauthenticated req_pq_multi directly to one Telegram DC through the VK/OK TURN UDP allocation. + +Implemented framing candidates: + +- raw MTProto plaintext payload; +- 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, auth key or API ID is required for req_pq_multi. + +## Single-DC run + +~~~powershell +go run ./cmd/turn-telegram-mtproto-udp-probe -hash "" -target "149.154.167.51:443" +~~~ + +## Recommended no-VPS run + +Use the aggregate runner instead of manually testing one DC: + +~~~powershell +$vk = Read-Host "VK call hash or full join link" +go run ./cmd/turn-no-vps-probe -hash $vk +~~~ + +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 + +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 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. + +## Current interpretation + +Manual STUN testing has already confirmed bidirectional direct external UDP egress through the tested VK/OK TURN endpoint. + +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/turn-tcp-rfc6062-probe.md b/docs/turn-tcp-rfc6062-probe.md new file mode 100644 index 00000000..a95604bc --- /dev/null +++ b/docs/turn-tcp-rfc6062-probe.md @@ -0,0 +1,134 @@ +# Эксперимент: прямой TURN egress без VPS + +## Цель + +Проверить исходную гипотезу PWDTT: можно ли исключить wdtt-server/VPS и передавать трафик через инфраструктуру VK/OK TURN непосредственно внешним peer. + +Эксперимент не меняет обычный runtime PWDTT. Все проверки запускаются отдельными CLI-командами. + +## Что уже подтверждено вручную + +### TCP / RFC 6062 + +Оба полученных 2026-09-07 TURN endpoint приняли control/auth path, но отклонили TCP allocation: + +~~~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 + +~~~text +UDP egress: PASS +Ordinary Telegram MTProto over tested UDP framings/DCs: NO VALID RESPONSE +Direct no-VPS Telegram messaging: NOT CONFIRMED +~~~ + +Это означает, что ограничение уже не в общем UDP egress. Оно находится на уровне Telegram transport/framing/peer policy. Это не доказывает математическую невозможность UDP transport, но протестированные варианты не дают обычный MTProto exchange. + +### Telegram PASS + +Успех принимается только после получения валидного plaintext resPQ#05162463 с nonce, совпадающим с отправленным req_pq_multi. + +~~~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 не следует. + +## Отдельные probes + +DNS: + +~~~powershell +go run ./cmd/turn-udp-probe -hash $vk -target "1.1.1.1:53" -name "example.com" +~~~ + +STUN: + +~~~powershell +go run ./cmd/turn-udp-stun-probe -hash $vk +~~~ + +Один Telegram DC: + +~~~powershell +go run ./cmd/turn-telegram-mtproto-udp-probe -hash $vk -target "149.154.167.51:443" +~~~ + +## Ограничения + +Это diagnostic prototype: + +- не поднимает SOCKS5; +- не интегрирован в GUI; +- не маршрутизирует системный трафик; +- RFC 6062 TCP relay на протестированных TURN endpoint выключен; +- обычный Telegram MTProto-over-UDP остаётся экспериментальной гипотезой до положительного resPQ; +- политика внешней инфраструктуры VK/OK может измениться независимо от PWDTT. 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.