using System.Collections.ObjectModel; using System.Net; using System.Net.Security; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Security.Authentication; using System.Text; using System.Text.Json.Nodes; using System.Windows; using Application = System.Windows.Application; using OpenFileDialog = Microsoft.Win32.OpenFileDialog; using AppTunnel.Models; using AppTunnel.Services; namespace AppTunnel.ViewModels; public partial class MainViewModel { #region Connection Methods private async Task ToggleConnectionAsync() { if (_connectionState == ConnectionState.Connecting) { // Cancel ongoing connection attempt _connectionCts?.Cancel(); return; } if (IsConnected) { await DisconnectAsync(); } else { await ConnectAsync(); } } private async Task ConnectAsync() { var tunnelType = _selectedProfile?.TunnelType ?? TunnelType.L2tpIpsec; // Guard: validate required fields per tunnel type if (tunnelType == TunnelType.L2tpIpsec && string.IsNullOrWhiteSpace(ServerAddress)) { Logger.Warning("ConnectAsync: ServerAddress is empty for L2TP"); StatusText = "آدرس سرور را وارد کنید"; return; } if (tunnelType == TunnelType.V2Ray && string.IsNullOrWhiteSpace(_selectedProfile?.V2RayConfig)) { Logger.Warning("ConnectAsync: V2RayConfig is empty"); StatusText = "کانفیگ V2Ray را وارد کنید"; return; } if (!ValidateSocks5Port(out var socksError)) { StatusText = socksError; Socks5PortStatusText = socksError; return; } // Save current state before connecting SaveCurrentProfileState(); if (_selectedProfile != null) { _selectedProfile.LastUsedAt = DateTime.Now; SaveProfiles(); } // Create new CancellationTokenSource for this connection attempt _connectionCts?.Dispose(); _connectionCts = new CancellationTokenSource(); IsBusy = true; ConnectionState = ConnectionState.Connecting; StatusText = "در حال اتصال..."; var config = _selectedProfile?.ToServerConfig() ?? new ServerConfig { ServerAddress = ServerAddress.Trim(), Username = Username.Trim(), Password = Password, PreSharedKey = PreSharedKey, AutoTuneMtu = AutoTuneMtu, EnableDnsOptimization = IsDnsOptimizationEnabled, EnableGameMode = IsGameModeEnabled }; bool success; try { // Register the watchdog callback before connecting so V2RayTunnelProvider // can invoke it if sing-box collapses mid-session. _vpnService.OnTunnelFailed = () => Application.Current?.Dispatcher.BeginInvoke(() => _ = HandleVpnDroppedAsync()); success = await _vpnService.ConnectAsync(config, _connectionCts.Token); } catch (OperationCanceledException) { ConnectionState = ConnectionState.Disconnected; StatusText = "اتصال لغو شد"; IsBusy = false; return; } if (success) { ConnectionState = ConnectionState.Connected; StatusText = _vpnService.Status.Message; VpnIp = _vpnService.Status.VpnLocalIp; VpnAdapterName = ResolveInterfaceName(_vpnService.Status.VpnInterfaceIndex); _connectionStartTime = DateTime.Now; RaiseHealthStatusChanged(); // Start traffic routing for enabled apps var enabledApps = TunnelApps.Where(a => a.IsEnabled).ToList(); _trafficRouter.ClearTargetApps(); foreach (var app in enabledApps) { app.BytesSent = 0; app.BytesReceived = 0; _trafficRouter.AddTargetApp(app.ExecutableName); } // Load user's exclude list (domains/IPs to bypass tunnel) _trafficRouter.SetExcludedDestinations(ExcludedDestinations); _trafficRouter.SetIncludedDestinations(IncludedDestinations); _trafficRouter.Socks5Port = Socks5Port; _trafficRouter.EnableDnsOptimization = IsDnsOptimizationEnabled; _trafficRouter.EnableGameMode = IsGameModeEnabled; _trafficRouter.Start( _vpnService.Status.VpnInterfaceIndex, _vpnService.Status.VpnLocalIp, _vpnService.Status.VpnServerIp); // actual proxy/VPN server host, resolved by TrafficRouter _vpnHealthCheckCounter = 0; _timer.Start(); } else { ConnectionState = ConnectionState.Error; StatusText = _vpnService.Status.Message; } IsBusy = false; } private async Task DisconnectAsync() { IsBusy = true; ConnectionState = ConnectionState.Disconnecting; StatusText = "در حال قطع اتصال..."; _timer.Stop(); _pingCts?.Cancel(); IsPinging = false; // Save connection to history before stopping router SaveConnectionToHistory(); await _trafficRouter.StopAsync(); await _vpnService.DisconnectAsync(); ConnectionState = ConnectionState.Disconnected; StatusText = "قطع شد"; VpnIp = ""; VpnAdapterName = ""; _isFullRouteEnabled = false; OnPropertyChanged(nameof(IsFullRouteEnabled)); OnPropertyChanged(nameof(FullRouteStatusText)); RaiseHealthStatusChanged(); ConnectionDuration = "--:--:--"; TotalTraffic = "0 B"; AppTrafficTotal = "0 B"; OtherTunnelTraffic = "0 B"; DirectTraffic = "0 B"; foreach (var app in TunnelApps) { app.BytesSent = 0; app.BytesReceived = 0; } IsBusy = false; } /// /// Called from code-behind during window close to reliably disconnect. /// public async Task DisconnectAndCleanupAsync() { if (!IsConnected) return; await DisconnectAsync(); } /// /// Called when VPN interface is detected as down. Cleans up and notifies user. /// private async Task HandleVpnDroppedAsync() { if (!IsConnected) return; _timer.Stop(); _pingCts?.Cancel(); IsPinging = false; ConnectionState = ConnectionState.Disconnecting; StatusText = "اتصال VPN قطع شد..."; SaveConnectionToHistory(); await _trafficRouter.StopAsync(); // VPN is already gone — just clean up the profile try { await _vpnService.DisconnectAsync(); } catch { } ConnectionState = ConnectionState.Disconnected; StatusText = "اتصال VPN به‌طور غیرمنتظره قطع شد"; VpnIp = ""; VpnAdapterName = ""; _isFullRouteEnabled = false; OnPropertyChanged(nameof(IsFullRouteEnabled)); OnPropertyChanged(nameof(FullRouteStatusText)); RaiseHealthStatusChanged(); ConnectionDuration = "--:--:--"; TotalTraffic = "0 B"; AppTrafficTotal = "0 B"; OtherTunnelTraffic = "0 B"; DirectTraffic = "0 B"; foreach (var app in TunnelApps) { app.BytesSent = 0; app.BytesReceived = 0; } Logger.Warning("[VPN-MONITOR] Cleanup complete — UI reset to disconnected state"); // Bring main window to front so user notices the alert if (Application.Current.MainWindow is { } mainWin) { if (mainWin.WindowState == WindowState.Minimized) mainWin.WindowState = WindowState.Normal; mainWin.Activate(); } Helpers.DialogService.Warning( "اتصال VPN به‌طور غیرمنتظره قطع شد.\nلطفاً دوباره متصل شوید.", "قطع اتصال"); } private int _vpnHealthCheckCounter; private void UpdateTimerTick() { if (!IsConnected) return; // Check VPN interface health every 5 seconds if (++_vpnHealthCheckCounter >= 5) { _vpnHealthCheckCounter = 0; if (!_trafficRouter.IsVpnInterfaceUp()) { Logger.Warning("[VPN-MONITOR] VPN interface is down — triggering auto-disconnect"); _ = HandleVpnDroppedAsync(); return; } } ConnectionDuration = _vpnService.Status.Duration; // Update per-app traffic stats (for the apps-tab list). foreach (var app in TunnelApps) { var (sent, received) = _trafficRouter.GetTraffic(app.ExecutableName); app.BytesSent = sent; app.BytesReceived = received; } // Total tunnel usage: use the authoritative VPN-interface counter. // Every visible "usage" counter in the app is based on tunneled bytes; // direct/outside-tunnel bytes are kept only as a diagnostic signal. var (totalSent, totalReceived) = _trafficRouter.GetTotalVpnTraffic(); long vpnTotal = totalSent + totalReceived; TotalTraffic = FormatBytes(vpnTotal); var (trackedSent, trackedReceived) = _trafficRouter.GetTrackedAppsTraffic(); long trackedTotal = trackedSent + trackedReceived; AppTrafficTotal = FormatBytes(trackedTotal); OtherTunnelTraffic = FormatBytes(Math.Max(0, vpnTotal - trackedTotal)); var (directSent, directReceived) = _trafficRouter.GetDirectTraffic(); DirectTraffic = FormatBytes(directSent + directReceived); RaiseHealthStatusChanged(); } private void OnTrafficUpdated(string exeName, long sent, long received) { Application.Current?.Dispatcher.BeginInvoke(() => { var app = TunnelApps.FirstOrDefault(a => a.ExecutableName.Equals(exeName, StringComparison.OrdinalIgnoreCase)); if (app != null) { app.BytesSent = sent; app.BytesReceived = received; } }); } internal static string FormatBytes(long bytes) { return bytes switch { < 1024 => $"{bytes} B", < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", < 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024):F1} MB", _ => $"{bytes / (1024.0 * 1024 * 1024):F2} GB" }; } private static string ResolveInterfaceName(int interfaceIndex) { try { foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { var ipv4 = nic.GetIPProperties().GetIPv4Properties(); if (ipv4 != null && ipv4.Index == interfaceIndex) return nic.Name; } } catch { } return interfaceIndex > 0 ? $"IF {interfaceIndex}" : "-"; } #endregion #region Pre-connect server test private async Task TestServerPingAsync() { if (IsTestingServerPing) return; IsTestingServerPing = true; ServerPingResult = "در حال تست..."; try { if (CurrentTunnelType == TunnelType.L2tpIpsec) { var host = ServerAddress.Trim(); if (string.IsNullOrWhiteSpace(host)) { ServerPingResult = "آدرس سرور خالی است"; return; } using var ping = new Ping(); var sw = System.Diagnostics.Stopwatch.StartNew(); var reply = await ping.SendPingAsync(host, 3000); sw.Stop(); ServerPingResult = reply.Status == IPStatus.Success ? $"ICMP {reply.RoundtripTime} ms" : $"ICMP {reply.Status}"; return; } var rawConfig = SelectedV2RayConfig.Trim(); if (!TryExtractProxyEndpointDetails(rawConfig, out var endpoint, out var error)) { ServerPingResult = error; return; } using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var ms = await MeasureEndpointLatencyAsync(endpoint, cts.Token); var mode = endpoint.UseTls ? "TLS handshake" : "TCP connect"; ServerPingResult = $"{mode} {endpoint.Server}:{endpoint.Port} {ms} ms"; } catch (OperationCanceledException) { ServerPingResult = "timeout"; } catch (Exception ex) { ServerPingResult = $"خطا: {ex.Message}"; } finally { IsTestingServerPing = false; } } private readonly record struct ProxyEndpoint(string Server, int Port, bool UseTls, string? Sni); private static async Task MeasureEndpointLatencyAsync(ProxyEndpoint endpoint, CancellationToken ct) { using var tcp = new TcpClient(); tcp.NoDelay = true; var sw = System.Diagnostics.Stopwatch.StartNew(); await tcp.ConnectAsync(endpoint.Server, endpoint.Port, ct); if (endpoint.UseTls) { using var ssl = new SslStream(tcp.GetStream(), false, (_, _, _, _) => true); var options = new SslClientAuthenticationOptions { TargetHost = string.IsNullOrWhiteSpace(endpoint.Sni) ? endpoint.Server : endpoint.Sni, EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, CertificateRevocationCheckMode = System.Security.Cryptography.X509Certificates.X509RevocationMode.NoCheck }; await ssl.AuthenticateAsClientAsync(options, ct); } sw.Stop(); return sw.ElapsedMilliseconds; } private static bool TryExtractProxyEndpoint(string config, out string server, out int port, out string error) { if (TryExtractProxyEndpointDetails(config, out var endpoint, out error)) { server = endpoint.Server; port = endpoint.Port; return true; } server = ""; port = 443; return false; } private static bool TryExtractProxyEndpointDetails(string config, out ProxyEndpoint endpoint, out string error) { endpoint = default; error = ""; if (string.IsNullOrWhiteSpace(config)) { error = "کانفیگ خالی است"; return false; } try { if (config.StartsWith("vmess://", StringComparison.OrdinalIgnoreCase)) { var b64 = config["vmess://".Length..].Split('#')[0]; var json = TryBase64DecodeConfig(b64); var node = JsonNode.Parse(json)?.AsObject(); var server = node?["add"]?.GetValue() ?? ""; var port = int.TryParse(node?["port"]?.ToString(), out var p) ? p : 443; var useTls = string.Equals(node?["tls"]?.ToString(), "tls", StringComparison.OrdinalIgnoreCase); var sni = node?["sni"]?.GetValue() ?? node?["host"]?.GetValue(); endpoint = new ProxyEndpoint(server, port, useTls, sni); return ValidateEndpoint(server, port, out error); } if (config.StartsWith("vless://", StringComparison.OrdinalIgnoreCase) || config.StartsWith("trojan://", StringComparison.OrdinalIgnoreCase)) { var uri = new Uri(config.Split('#')[0]); var query = ParseQuery(uri.Query); var useTls = query.TryGetValue("security", out var security) && security.Contains("tls", StringComparison.OrdinalIgnoreCase); var sni = query.TryGetValue("sni", out var sniValue) ? sniValue : null; endpoint = new ProxyEndpoint(uri.Host, uri.Port > 0 ? uri.Port : 443, useTls, sni); return ValidateEndpoint(endpoint.Server, endpoint.Port, out error); } if (config.StartsWith("ss://", StringComparison.OrdinalIgnoreCase)) { if (!TryExtractShadowsocksEndpoint(config, out var server, out var port, out error)) return false; endpoint = new ProxyEndpoint(server, port, false, null); return true; } if (config.StartsWith("{")) { var root = JsonNode.Parse(config)?.AsObject(); if (root?["outbounds"] is JsonArray outbounds) { foreach (var item in outbounds.OfType()) { var server = item["server"]?.GetValue() ?? item["settings"]?["vnext"]?[0]?["address"]?.GetValue() ?? item["settings"]?["servers"]?[0]?["address"]?.GetValue() ?? ""; var port = item["server_port"]?.GetValue() ?? item["settings"]?["vnext"]?[0]?["port"]?.GetValue() ?? item["settings"]?["servers"]?[0]?["port"]?.GetValue() ?? 443; if (!string.IsNullOrWhiteSpace(server)) { var tlsNode = item["tls"]?.AsObject(); var streamSettings = item["streamSettings"]?.AsObject(); var useTls = tlsNode?["enabled"]?.GetValue() == true || string.Equals(streamSettings?["security"]?.ToString(), "tls", StringComparison.OrdinalIgnoreCase); var sni = tlsNode?["server_name"]?.GetValue() ?? streamSettings?["tlsSettings"]?["serverName"]?.GetValue(); endpoint = new ProxyEndpoint(server, port, useTls, sni); return ValidateEndpoint(server, port, out error); } } } } error = "endpoint سرور از کانفیگ تشخیص داده نشد"; return false; } catch (Exception ex) { error = $"پارس کانفیگ ناموفق بود: {ex.Message}"; return false; } } private static Dictionary ParseQuery(string query) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); query = query.TrimStart('?'); foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) { var pair = part.Split('=', 2); var key = Uri.UnescapeDataString(pair[0]); var value = pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : ""; result[key] = value; } return result; } private static bool TryExtractShadowsocksEndpoint(string config, out string server, out int port, out string error) { server = ""; port = 443; error = ""; var noFragment = config.Split('#')[0]; var uri = new Uri(noFragment); if (!string.IsNullOrWhiteSpace(uri.Host)) { server = uri.Host; port = uri.Port > 0 ? uri.Port : 443; return ValidateEndpoint(server, port, out error); } var encoded = noFragment["ss://".Length..]; var decoded = TryBase64DecodeConfig(encoded); var atIdx = decoded.LastIndexOf('@'); var hostPart = atIdx >= 0 ? decoded[(atIdx + 1)..] : decoded; var colonIdx = hostPart.LastIndexOf(':'); if (colonIdx < 0) { error = "آدرس ss:// نامعتبر است"; return false; } server = hostPart[..colonIdx]; port = int.TryParse(hostPart[(colonIdx + 1)..], out var p) ? p : 443; return ValidateEndpoint(server, port, out error); } private static bool ValidateEndpoint(string server, int port, out string error) { if (string.IsNullOrWhiteSpace(server)) { error = "سرور پیدا نشد"; return false; } if (port <= 0 || port > 65535) { error = "پورت نامعتبر است"; return false; } error = ""; return true; } private static string TryBase64DecodeConfig(string value) { value = value.Replace('-', '+').Replace('_', '/'); value = value.PadRight((value.Length + 3) / 4 * 4, '='); return Encoding.UTF8.GetString(Convert.FromBase64String(value)); } #endregion #region Ping private CancellationTokenSource? _pingCts; private void TogglePing() { if (IsPinging) { _pingCts?.Cancel(); IsPinging = false; return; } var raw = PingTarget?.Trim() ?? ""; // Accept IP or hostname:port — extract host for route installation var host = raw.Contains(':') ? raw.Split(':')[0] : raw; if (string.IsNullOrWhiteSpace(host)) { PingResult = "آدرس نامعتبر"; return; } IsPinging = true; PingResult = "..."; _pingCts = new CancellationTokenSource(); // For V2Ray mode, ping through sing-box's own mixed SOCKS5 inbound. // sing-box only replies CONNECT_OK after the real upstream TCP handshake // through the proxy chain completes → accurate end-to-end RTT. // For L2TP, the TunnelX SOCKS5 proxy (bound to VPN IP) gives accurate // RTT because L2TP routes packets through the real PPP adapter without // a fake local handshake. int pingProxyPort = _vpnService.Status.SingBoxMixedPort > 0 ? _vpnService.Status.SingBoxMixedPort : _trafficRouter.Socks5Port; _ = RunPingLoopAsync(host, raw, pingProxyPort, _pingCts.Token); } /// /// TCP-connect latency loop. Uses a SOCKS5 CONNECT through the appropriate /// proxy to measure true end-to-end round-trip: /// • V2Ray mode → sing-box mixed SOCKS5 inbound (port 2080). /// sing-box only replies CONNECT_OK after the real upstream TCP handshake /// through the proxy chain completes. The TUN path is NOT used here /// because the TUN's "system" stack completes the local TCP handshake /// immediately (1-2 ms) before the remote connection is established. /// • L2TP mode → TunnelX built-in SOCKS5 (port 1080), bound to VPN IP. /// Packets route through the real PPP adapter; no fake local handshake. /// Format of : "host" or "host:port" (default port 443). /// private async Task RunPingLoopAsync(string host, string target, int proxyPort, CancellationToken ct) { // Parse optional port from "host:port" int port = 443; if (target.Contains(':') && int.TryParse(target.Split(':')[^1], out var p)) port = p; int socks5Port = proxyPort; int sent = 0, success = 0; try { while (!ct.IsCancellationRequested && IsConnected) { sent++; try { long ms = await PingViaSocks5Async(host, port, socks5Port, ct); success++; PingResult = $"TCP {ms} ms ({success}/{sent})"; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { PingResult = $"✗ timeout ({success}/{sent})"; } catch (Exception ex) { PingResult = $"✗ {ex.Message} ({success}/{sent})"; } await Task.Delay(1000, ct); } } catch (OperationCanceledException) { } finally { IsPinging = false; if (sent > 0) PingResult += " [پایان]"; } } /// /// Measures the true end-to-end TCP round-trip through the SOCKS5 proxy. /// /// IMPORTANT: We CANNOT just time the SOCKS5 CONNECT reply — sing-box (and /// many modern SOCKS5 implementations) deliberately send the CONNECT success /// reply IMMEDIATELY, before dialing the upstream, as a latency optimization. /// On loopback this returns in 1-2 ms regardless of the real path. /// /// Instead we do CONNECT (untimed), then send a probe and time how long /// until the FIRST response byte from the upstream server arrives. That gives /// us exactly one round-trip through the entire proxy chain to the remote /// host. For port 443 we send a minimal TLS ClientHello (server replies with /// ServerHello after 1 RTT). For other ports we send an HTTP GET (server /// replies with response data or RST after 1 RTT). Either way, time-to-first- /// byte is the real RTT. /// private static async Task PingViaSocks5Async( string host, int port, int socks5Port, CancellationToken ct) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(5000); using var tcp = new System.Net.Sockets.TcpClient(); tcp.NoDelay = true; await tcp.ConnectAsync("127.0.0.1", socks5Port, cts.Token); var stream = tcp.GetStream(); // ── SOCKS5 greeting (untimed — local loopback only) ── await stream.WriteAsync(new byte[] { 0x05, 0x01, 0x00 }, cts.Token); var greet = new byte[2]; await ReadExactlyAsync(stream, greet, cts.Token); if (greet[0] != 0x05 || greet[1] != 0x00) throw new Exception("SOCKS5 handshake rejected"); // ── SOCKS5 CONNECT request (untimed — proxy may pre-reply) ── var hostBytes = System.Text.Encoding.ASCII.GetBytes(host); var req = new byte[7 + hostBytes.Length]; req[0] = 0x05; // VER req[1] = 0x01; // CMD = CONNECT req[2] = 0x00; // RSV req[3] = 0x03; // ATYP = DOMAINNAME req[4] = (byte)hostBytes.Length; hostBytes.CopyTo(req, 5); req[5 + hostBytes.Length] = (byte)(port >> 8); req[6 + hostBytes.Length] = (byte)(port & 0xFF); await stream.WriteAsync(req, cts.Token); // Read SOCKS5 CONNECT response header (4 fixed bytes + bound addr + port). var resp = new byte[4]; await ReadExactlyAsync(stream, resp, cts.Token); if (resp[1] != 0x00) throw new Exception($"SOCKS5 connect failed (code {resp[1]})"); switch (resp[3]) { case 0x01: await ReadExactlyAsync(stream, new byte[6], cts.Token); break; case 0x03: var lenBuf = new byte[1]; await ReadExactlyAsync(stream, lenBuf, cts.Token); await ReadExactlyAsync(stream, new byte[lenBuf[0] + 2], cts.Token); break; case 0x04: await ReadExactlyAsync(stream, new byte[18], cts.Token); break; } // ── Real round-trip: send probe + read first response byte ── // For port 443 we send a minimal TLS ClientHello so the remote server // replies with ServerHello in exactly 1 RTT. For other ports an HTTP // GET works (most servers reply with data or RST in 1 RTT). byte[] probe = port == 443 ? BuildTlsClientHello(host) : System.Text.Encoding.ASCII.GetBytes($"GET / HTTP/1.0\r\nHost: {host}\r\n\r\n"); var sw = System.Diagnostics.Stopwatch.StartNew(); await stream.WriteAsync(probe, cts.Token); // Wait for the first byte of any upstream response. The connection may // be RST/closed by the remote (returns 0 from Read) — that's still a // valid 1-RTT measurement. var oneByte = new byte[1]; try { int got = await stream.ReadAsync(oneByte, 0, 1, cts.Token); sw.Stop(); // Guard: replies arriving in <= 1 ms almost certainly came from the // local proxy (closed connection, refused, etc.) rather than the // upstream server. Treat as a failure to avoid showing fake numbers. if (sw.ElapsedMilliseconds <= 1 && got == 0) throw new Exception("upstream closed (no data)"); return sw.ElapsedMilliseconds; } catch (System.IO.IOException) when (sw.ElapsedMilliseconds > 1) { // Remote sent RST after a real round-trip — still a valid measurement. sw.Stop(); return sw.ElapsedMilliseconds; } } /// /// Builds a minimal TLS 1.2 ClientHello with the given SNI hostname. /// Any compliant TLS server replies with ServerHello after one full RTT, /// so the time between sending this and receiving the first response byte /// equals the network round-trip through the proxy chain to the server. /// private static byte[] BuildTlsClientHello(string sniHost) { var sni = System.Text.Encoding.ASCII.GetBytes(sniHost); // ── extensions ── // server_name (0x0000): list_len(2) + name_type(1) + host_len(2) + host var sniExt = new List { 0x00, 0x00 }; // ext type int sniListLen = 1 + 2 + sni.Length; int sniExtLen = 2 + sniListLen; sniExt.AddRange(new byte[] { (byte)(sniExtLen >> 8), (byte)sniExtLen }); sniExt.AddRange(new byte[] { (byte)(sniListLen >> 8), (byte)sniListLen }); sniExt.Add(0x00); // host_name type sniExt.AddRange(new byte[] { (byte)(sni.Length >> 8), (byte)sni.Length }); sniExt.AddRange(sni); // supported_versions (0x002b): TLS 1.3 + TLS 1.2 — accepted by all modern servers var verExt = new byte[] { 0x00, 0x2b, 0x00, 0x05, 0x04, 0x03, 0x04, 0x03, 0x03 }; // supported_groups (0x000a): x25519 var grpExt = new byte[] { 0x00, 0x0a, 0x00, 0x04, 0x00, 0x02, 0x00, 0x1d }; // signature_algorithms (0x000d): rsa_pss_rsae_sha256, ecdsa_secp256r1_sha256 var sigExt = new byte[] { 0x00, 0x0d, 0x00, 0x06, 0x00, 0x04, 0x08, 0x04, 0x04, 0x03 }; var extensions = new List(); extensions.AddRange(sniExt); extensions.AddRange(verExt); extensions.AddRange(grpExt); extensions.AddRange(sigExt); // ── ClientHello body ── var body = new List(); body.AddRange(new byte[] { 0x03, 0x03 }); // legacy_version = TLS 1.2 for (int i = 0; i < 32; i++) body.Add(0xAA); // random (fixed bytes are fine) body.Add(0x00); // session_id length body.AddRange(new byte[] { 0x00, 0x02, 0x13, 0x01 }); // cipher_suites: TLS_AES_128_GCM_SHA256 body.AddRange(new byte[] { 0x01, 0x00 }); // compression_methods: null body.AddRange(new byte[] { (byte)(extensions.Count >> 8), (byte)extensions.Count }); body.AddRange(extensions); // Handshake header: type(1) + length(3) var handshake = new List { 0x01 }; handshake.AddRange(new byte[] { 0x00, (byte)(body.Count >> 8), (byte)body.Count }); handshake.AddRange(body); // TLS record header: type(1) + version(2) + length(2) var record = new List { 0x16, 0x03, 0x01, (byte)(handshake.Count >> 8), (byte)handshake.Count }; record.AddRange(handshake); return record.ToArray(); } private static async Task ReadExactlyAsync( System.Net.Sockets.NetworkStream stream, byte[] buffer, CancellationToken ct) { int offset = 0; while (offset < buffer.Length) { int read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, ct); if (read == 0) throw new Exception("SOCKS5 connection closed unexpectedly"); offset += read; } } #endregion }