From b311473df423a09e5401501a260b7460fefb6385 Mon Sep 17 00:00:00 2001 From: MaxFan Date: Sun, 17 May 2026 15:20:37 +0330 Subject: [PATCH] Add OpenVPN split-tunnel support Adds external OpenVPN Community integration with split-compatible routing, safer connection readiness checks, profile persistence, UI guidance, and release documentation for the new version. Co-authored-by: Cursor --- AppTunnel/AppTunnel.csproj | 6 +- AppTunnel/Models/ConnectionProfile.cs | 34 +- AppTunnel/Models/ConnectionStatus.cs | 2 + AppTunnel/Models/ServerConfig.cs | 5 + AppTunnel/Services/OpenVpnTunnelProvider.cs | 726 ++++++++++++++++++ AppTunnel/Services/ProfileService.cs | 19 +- .../Services/TrafficRouterService.Core.cs | 6 +- .../TrafficRouterService.RouteManagement.cs | 5 +- AppTunnel/Services/VpnService.cs | 5 +- .../ViewModels/MainViewModel.Connection.cs | 261 ++++++- AppTunnel/ViewModels/MainViewModel.Core.cs | 207 ++++- .../MainViewModel.ProfileManagement.cs | 81 +- AppTunnel/Views/ConnectionTabView.xaml | 169 +++- AppTunnel/Views/ConnectionTabView.xaml.cs | 18 + AppTunnel/Views/HelpTabView.xaml | 41 +- CHANGELOG.md | 9 + README.fa.md | 7 + README.md | 7 + 18 files changed, 1557 insertions(+), 51 deletions(-) create mode 100644 AppTunnel/Services/OpenVpnTunnelProvider.cs diff --git a/AppTunnel/AppTunnel.csproj b/AppTunnel/AppTunnel.csproj index 2b83b20..9fcc1bb 100644 --- a/AppTunnel/AppTunnel.csproj +++ b/AppTunnel/AppTunnel.csproj @@ -21,9 +21,9 @@ GPL-3.0-or-later fa-IR - 1.2.24 - 1.2.24.0 - 1.2.24.0 + 1.2.26 + 1.2.26.0 + 1.2.26.0 diff --git a/AppTunnel/Models/ConnectionProfile.cs b/AppTunnel/Models/ConnectionProfile.cs index d90fd85..cca3891 100644 --- a/AppTunnel/Models/ConnectionProfile.cs +++ b/AppTunnel/Models/ConnectionProfile.cs @@ -7,7 +7,8 @@ namespace AppTunnel.Models; public enum TunnelType { L2tpIpsec, - V2Ray + V2Ray, + OpenVpn } /// @@ -28,6 +29,10 @@ public class ConnectionProfile : INotifyPropertyChanged private List _excludedDestinations = new(); private TunnelType _tunnelType = TunnelType.L2tpIpsec; private string _v2RayConfig = ""; + private string _openVpnConfig = ""; + private string _openVpnConfigPath = ""; + private string _openVpnUsername = ""; + private string _openVpnPassword = ""; private int _mixedProxyPort = 1080; private bool _autoTuneMtu = true; private bool _enableDnsOptimization = true; @@ -110,6 +115,30 @@ public class ConnectionProfile : INotifyPropertyChanged set => SetField(ref _v2RayConfig, value); } + public string OpenVpnConfig + { + get => _openVpnConfig; + set => SetField(ref _openVpnConfig, value); + } + + public string OpenVpnConfigPath + { + get => _openVpnConfigPath; + set => SetField(ref _openVpnConfigPath, value); + } + + public string OpenVpnUsername + { + get => _openVpnUsername; + set => SetField(ref _openVpnUsername, value); + } + + public string OpenVpnPassword + { + get => _openVpnPassword; + set => SetField(ref _openVpnPassword, value); + } + [JsonPropertyName("socks5Port")] public int MixedProxyPort { @@ -147,6 +176,9 @@ public class ConnectionProfile : INotifyPropertyChanged ConnectionName = ConnectionName, TunnelType = TunnelType, V2RayConfig = V2RayConfig, + OpenVpnConfig = OpenVpnConfig, + OpenVpnUsername = OpenVpnUsername, + OpenVpnPassword = OpenVpnPassword, AutoTuneMtu = AutoTuneMtu, EnableDnsOptimization = EnableDnsOptimization, EnableGameMode = EnableGameMode diff --git a/AppTunnel/Models/ConnectionStatus.cs b/AppTunnel/Models/ConnectionStatus.cs index c00720f..2cc0a25 100644 --- a/AppTunnel/Models/ConnectionStatus.cs +++ b/AppTunnel/Models/ConnectionStatus.cs @@ -16,6 +16,8 @@ public class ConnectionStatus public DateTime? ConnectedSince { get; set; } public string VpnLocalIp { get; set; } = string.Empty; public string VpnServerIp { get; set; } = string.Empty; + public int VpnServerPort { get; set; } + public string VpnGatewayIp { get; set; } = string.Empty; public int VpnInterfaceIndex { get; set; } = -1; /// diff --git a/AppTunnel/Models/ServerConfig.cs b/AppTunnel/Models/ServerConfig.cs index cfed9ee..eb8b4dd 100644 --- a/AppTunnel/Models/ServerConfig.cs +++ b/AppTunnel/Models/ServerConfig.cs @@ -12,6 +12,11 @@ public class ServerConfig public string ConnectionName { get; set; } = "TunnelX"; public TunnelType TunnelType { get; set; } = TunnelType.L2tpIpsec; public string V2RayConfig { get; set; } = ""; + public string OpenVpnConfig { get; set; } = ""; + public string OpenVpnExePath { get; set; } = ""; + public string OpenVpnUsername { get; set; } = ""; + public string OpenVpnPassword { get; set; } = ""; + public string OpenVpnPrivateKeyPassword { get; set; } = ""; public bool AutoTuneMtu { get; set; } = true; public bool EnableDnsOptimization { get; set; } = true; public bool EnableGameMode { get; set; } = false; diff --git a/AppTunnel/Services/OpenVpnTunnelProvider.cs b/AppTunnel/Services/OpenVpnTunnelProvider.cs new file mode 100644 index 0000000..44561b6 --- /dev/null +++ b/AppTunnel/Services/OpenVpnTunnelProvider.cs @@ -0,0 +1,726 @@ +using System.Diagnostics; +using System.IO; +using System.Collections.Concurrent; +using System.Net; +using System.Net.NetworkInformation; +using System.Text; +using AppTunnel.Models; + +namespace AppTunnel.Services; + +/// +/// ITunnelProvider implementation for OpenVPN. +/// Launches user-installed OpenVPN Community with a split-compatible temporary +/// config and waits for its network adapter to come Up. TunnelX does not bundle +/// OpenVPN. +/// +public class OpenVpnTunnelProvider : ITunnelProvider +{ + private static readonly UTF8Encoding Utf8NoBom = new(false); + private static string OpenVpnWorkDir => Path.Combine(AppTunnel.App.AppDataDir, "openvpn"); + private static string TunnelXOpenVpnPidPath => Path.Combine(OpenVpnWorkDir, "tunnelx-openvpn.pid"); + private Process? _process; + private int _vpnInterfaceIndex = -1; + private string _routeGatewayIp = ""; + private string _connectedRemoteIp = ""; + private int _connectedRemotePort; + private string _assignedLocalIp = ""; + private readonly ConcurrentQueue _recentOpenVpnOutput = new(); + + public ConnectionStatus Status { get; } = new(); + + public async Task ConnectAsync(ServerConfig config, CancellationToken ct) + { + _vpnInterfaceIndex = -1; + _routeGatewayIp = ""; + _connectedRemoteIp = ""; + _connectedRemotePort = 0; + _assignedLocalIp = ""; + while (_recentOpenVpnOutput.TryDequeue(out _)) { } + Status.State = ConnectionState.Connecting; + Status.Message = "در حال اجرای OpenVPN در حالت Split..."; + Logger.Info("[OpenVPN] ConnectAsync started"); + + try + { + var openVpnExe = ResolveOpenVpnExecutable(config); + if (string.IsNullOrWhiteSpace(openVpnExe)) + { + Status.State = ConnectionState.Error; + Status.Message = IsOpenVpnConnectInstalled() + ? "فقط OpenVPN Connect پیدا شد. برای Split Tunneling باید OpenVPN Community (openvpn.exe) هم نصب باشد." + : "OpenVPN Community پیدا نشد. برای Split Tunneling باید openvpn.exe نصب باشد."; + Logger.Error("[OpenVPN] Executable not found. Searched:"); + foreach (var p in GetCandidatePaths()) + Logger.Error($" '{p}' → {(File.Exists(p) ? "FOUND" : "not found")}"); + foreach (var p in GetOpenVpnConnectPaths()) + Logger.Warning($"[OpenVPN] OpenVPN Connect check: '{p}' → {(Directory.Exists(p) || File.Exists(p) ? "FOUND (GUI only, not split-compatible)" : "not found")}"); + return false; + } + if (string.IsNullOrWhiteSpace(config.OpenVpnConfig)) + { + Status.State = ConnectionState.Error; + Status.Message = "کانفیگ OpenVPN (.ovpn) وارد نشده است."; + return false; + } + + await KillStaleTunnelXOpenVpnProcessAsync(); + + var preparedConfigPath = PrepareSplitCompatibleConfig(config.OpenVpnConfig, config.OpenVpnUsername, config.OpenVpnPassword); + var remoteHost = TryExtractRemoteHost(config.OpenVpnConfig); + LogRemoteCandidates(config.OpenVpnConfig); + Logger.Info($"[OpenVPN] Launching: {openVpnExe}"); + Logger.Info($"[OpenVPN] Prepared split config: {preparedConfigPath}"); + + _process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = openVpnExe, + Arguments = $"--config \"{preparedConfigPath}\" --verb 3", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }, + EnableRaisingEvents = true + }; + + _process.Start(); + WriteTunnelXOpenVpnPid(_process.Id); + _ = Task.Run(() => PumpOpenVpnOutputAsync(_process.StandardOutput, ct)); + _ = Task.Run(() => PumpOpenVpnOutputAsync(_process.StandardError, ct)); + Logger.Info($"[OpenVPN] Process started PID={_process.Id}"); + + Status.Message = "OpenVPN در حال اتصال است؛ مسیرهای پیش‌فرض آن برای Split Tunnel نادیده گرفته می‌شوند..."; + Logger.Info("[OpenVPN] Waiting up to 180s for VPN adapter to come Up..."); + + var deadline = DateTime.UtcNow.AddSeconds(180); + while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) + { + var idx = !string.IsNullOrWhiteSpace(_assignedLocalIp) + ? FindOpenVpnInterfaceIndex(_assignedLocalIp) + : -1; + if (idx > 0 && + !string.IsNullOrWhiteSpace(_routeGatewayIp) && + !string.IsNullOrWhiteSpace(_connectedRemoteIp) && + _connectedRemotePort > 0) + { + Logger.Info($"[OpenVPN] Adapter came Up: index={idx}"); + _vpnInterfaceIndex = idx; + break; + } + + var remaining = (int)(deadline - DateTime.UtcNow).TotalSeconds; + if (_process.HasExited) + { + Status.State = ConnectionState.Error; + Status.Message = $"OpenVPN زودتر از اتصال بسته شد (exit={_process.ExitCode})"; + return false; + } + + Status.Message = $"منتظر بالا آمدن آداپتر OpenVPN... ({remaining}s)"; + await Task.Delay(500, ct); + } + + if (_vpnInterfaceIndex <= 0) + { + LogRecentOpenVpnOutput(); + Logger.Error("[OpenVPN] Adapter not found after timeout. Current NICs:"); + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + Logger.Error($" name='{nic.Name}' desc='{nic.Description}' status={nic.OperationalStatus}"); + Status.State = ConnectionState.Error; + Status.Message = "آداپتور OpenVPN بالا نیامد. لاگ OpenVPN را بررسی کنید؛ ممکن است ریموت اول پاسخ ندهد یا احراز هویت/شبکه مشکل داشته باشد."; + await KillProcessAsync(); + return false; + } + + Status.State = ConnectionState.Connected; + Status.ConnectedSince = DateTime.Now; + Status.VpnInterfaceIndex = _vpnInterfaceIndex; + Status.VpnLocalIp = GetInterfaceIpv4(_vpnInterfaceIndex); + Status.VpnServerIp = !string.IsNullOrWhiteSpace(_connectedRemoteIp) + ? _connectedRemoteIp + : ResolveRemoteForRouting(remoteHost); + Status.VpnServerPort = _connectedRemotePort; + Status.VpnGatewayIp = _routeGatewayIp; + Status.Message = "OpenVPN متصل شد (Split Tunnel)"; + Logger.Info($"[OpenVPN] Connected. LocalIP={Status.VpnLocalIp} Gateway={Status.VpnGatewayIp} Remote={Status.VpnServerIp}:{Status.VpnServerPort}"); + + return true; + } + catch (OperationCanceledException) + { + Status.State = ConnectionState.Disconnected; + Status.Message = "اتصال لغو شد"; + await KillProcessAsync(); + return false; + } + catch (Exception ex) + { + Status.State = ConnectionState.Error; + Status.Message = $"خطا: {ex.Message}"; + Logger.Error("OpenVpnTunnelProvider.ConnectAsync failed", ex); + await KillProcessAsync(); + return false; + } + } + + public async Task DisconnectAsync() + { + Status.State = ConnectionState.Disconnecting; + Status.Message = "در حال قطع اتصال OpenVPN..."; + await KillProcessAsync(); + _vpnInterfaceIndex = -1; + Status.State = ConnectionState.Disconnected; + Status.ConnectedSince = null; + Status.VpnLocalIp = string.Empty; + Status.VpnServerIp = string.Empty; + Status.VpnServerPort = 0; + Status.VpnGatewayIp = string.Empty; + Status.VpnInterfaceIndex = -1; + Status.Message = "قطع شد"; + } + + public bool IsInterfaceUp() + { + if (_vpnInterfaceIndex < 0) return false; + try + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + var ipv4 = nic.GetIPProperties().GetIPv4Properties(); + if (ipv4 != null && ipv4.Index == _vpnInterfaceIndex) + return nic.OperationalStatus == OperationalStatus.Up; + } + } + catch { } + return false; + } + + private async Task KillProcessAsync() + { + var processId = _process?.Id; + try + { + if (_process is { HasExited: false }) + { + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync(); + } + } + catch { } + finally + { + try { _process?.Dispose(); } catch { } + _process = null; + DeleteTunnelXOpenVpnPid(processId); + } + } + + private static async Task KillStaleTunnelXOpenVpnProcessAsync() + { + try + { + if (!File.Exists(TunnelXOpenVpnPidPath)) + return; + + var raw = await File.ReadAllTextAsync(TunnelXOpenVpnPidPath); + if (!int.TryParse(raw.Trim(), out var pid)) + { + DeleteTunnelXOpenVpnPid(null); + return; + } + + using var process = Process.GetProcessById(pid); + if (process.HasExited) + { + DeleteTunnelXOpenVpnPid(pid); + return; + } + + if (!process.ProcessName.Equals("openvpn", StringComparison.OrdinalIgnoreCase)) + { + Logger.Warning($"[OpenVPN] Stale pid file ignored; PID {pid} is '{process.ProcessName}', not openvpn."); + DeleteTunnelXOpenVpnPid(pid); + return; + } + + Logger.Warning($"[OpenVPN] Cleaning up stale TunnelX OpenVPN process PID={pid}"); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + DeleteTunnelXOpenVpnPid(pid); + } + catch (ArgumentException) + { + DeleteTunnelXOpenVpnPid(null); + } + catch (Exception ex) + { + Logger.Warning($"[OpenVPN] Stale process cleanup failed: {ex.Message}"); + } + } + + private static void WriteTunnelXOpenVpnPid(int pid) + { + try + { + Directory.CreateDirectory(OpenVpnWorkDir); + File.WriteAllText(TunnelXOpenVpnPidPath, pid.ToString(), Utf8NoBom); + } + catch (Exception ex) + { + Logger.Warning($"[OpenVPN] Could not write pid file: {ex.Message}"); + } + } + + private static void DeleteTunnelXOpenVpnPid(int? processId) + { + try + { + if (!File.Exists(TunnelXOpenVpnPidPath)) + return; + + if (processId.HasValue) + { + var raw = File.ReadAllText(TunnelXOpenVpnPidPath).Trim(); + if (int.TryParse(raw, out var pid) && pid != processId.Value) + return; + } + + File.Delete(TunnelXOpenVpnPidPath); + } + catch { } + } + + private static string? ResolveOpenVpnExecutable(ServerConfig config) + { + foreach (var c in GetCandidatePaths()) + { + Logger.Debug($"[OpenVPN] Checking: '{c}' -> {(File.Exists(c) ? "FOUND" : "not found")}"); + if (File.Exists(c)) return c; + } + return null; + } + + public static string? FindOpenVpnExecutable() + { + foreach (var c in GetCandidatePaths()) + { + if (File.Exists(c)) return c; + } + return null; + } + + private static IEnumerable GetCandidatePaths() + { + var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + yield return Path.Combine(pf, "OpenVPN", "bin", "openvpn.exe"); + yield return Path.Combine(pfx86, "OpenVPN", "bin", "openvpn.exe"); + yield return Path.Combine(local, "Programs", "OpenVPN", "bin", "openvpn.exe"); + } + + private static IEnumerable GetOpenVpnConnectPaths() + { + var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + yield return Path.Combine(pf, "OpenVPN Connect"); + yield return Path.Combine(pfx86, "OpenVPN Connect"); + yield return Path.Combine(local, "Programs", "OpenVPN Connect"); + } + + private static bool IsOpenVpnConnectInstalled() => + GetOpenVpnConnectPaths().Any(p => Directory.Exists(p) || File.Exists(Path.Combine(p, "OpenVPNConnect.exe"))); + + private static string PrepareSplitCompatibleConfig(string originalConfig, string username, string password) + { + var dir = OpenVpnWorkDir; + Directory.CreateDirectory(dir); + + var path = Path.Combine(dir, "tunnelx-split.ovpn"); + var authPath = Path.Combine(dir, "tunnelx-auth.txt"); + var builder = new StringBuilder(); + var splitOptionsInserted = false; + var lines = originalConfig.Split('\n'); + + void AppendTunnelXOptions() + { + if (splitOptionsInserted) return; + splitOptionsInserted = true; + builder.AppendLine(); + builder.AppendLine("# Added by TunnelX for split tunneling:"); + builder.AppendLine("route-nopull"); + builder.AppendLine("pull-filter ignore redirect-gateway"); + builder.AppendLine("pull-filter ignore block-outside-dns"); + builder.AppendLine("pull-filter ignore dhcp-option"); + builder.AppendLine("connect-timeout 10"); + builder.AppendLine("server-poll-timeout 10"); + builder.AppendLine("connect-retry 2 5"); + builder.AppendLine("auth-nocache"); + if (!string.IsNullOrWhiteSpace(username)) + { + File.WriteAllText(authPath, $"{username.Trim()}{Environment.NewLine}{password}", Utf8NoBom); + builder.AppendLine($"auth-user-pass {QuoteOpenVpnPath(authPath)}"); + } + builder.AppendLine(); + } + + for (var i = 0; i < lines.Length; i++) + { + var raw = lines[i].TrimEnd('\r'); + var trimmed = raw.TrimStart(); + if (trimmed.StartsWith("auth-user-pass", StringComparison.OrdinalIgnoreCase)) + continue; + + if (trimmed.StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + var block = new List { raw }; + while (++i < lines.Length) + { + var blockLine = lines[i].TrimEnd('\r'); + block.Add(blockLine); + if (blockLine.Trim().Equals("", StringComparison.OrdinalIgnoreCase)) + break; + } + + var remote = ExtractRemoteFromLines(block); + if (remote.HasValue && ShouldSkipRemote(remote.Value.host)) + { + Logger.Warning($"[OpenVPN] Skipping unreachable/private remote block {remote.Value.host}:{remote.Value.port}"); + continue; + } + + AppendTunnelXOptions(); + foreach (var blockLine in block) + builder.AppendLine(blockLine); + continue; + } + + if (trimmed.StartsWith("remote ", StringComparison.OrdinalIgnoreCase)) + { + var remote = ExtractRemoteFromLines(new[] { raw }); + if (remote.HasValue && ShouldSkipRemote(remote.Value.host)) + { + Logger.Warning($"[OpenVPN] Skipping unreachable/private remote {remote.Value.host}:{remote.Value.port}"); + continue; + } + + AppendTunnelXOptions(); + } + + builder.AppendLine(raw); + } + + AppendTunnelXOptions(); + + File.WriteAllText(path, builder.ToString(), Utf8NoBom); + return path; + } + + private static bool IsOpenVpnExecutable(string path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + if (!File.Exists(path)) return false; + return string.Equals(Path.GetFileName(path), "openvpn.exe", StringComparison.OrdinalIgnoreCase); + } + + private static string QuoteOpenVpnPath(string path) => $"\"{path.Replace('\\', '/')}\""; + + private static (string host, string port, string proto)? ExtractRemoteFromLines(IEnumerable lines) + { + foreach (var line in lines) + { + var raw = line.Trim(); + if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#") || raw.StartsWith(";")) + continue; + if (!raw.StartsWith("remote ", StringComparison.OrdinalIgnoreCase)) + continue; + + var parts = raw.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + return (parts[1], parts.Length >= 3 ? parts[2] : "1194", parts.Length >= 4 ? parts[3] : ""); + } + return null; + } + + private static bool ShouldSkipRemote(string host) + { + if (IPAddress.TryParse(host, out var ip)) + return IsPrivateIpv4(ip); + + try + { + var addresses = Dns.GetHostAddresses(host) + .Where(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + .ToList(); + return addresses.Count > 0 && addresses.All(IsPrivateIpv4); + } + catch + { + return false; + } + } + + private async Task PumpOpenVpnOutputAsync(StreamReader reader, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (line == null) break; + if (!string.IsNullOrWhiteSpace(line)) + { + _recentOpenVpnOutput.Enqueue(line); + while (_recentOpenVpnOutput.Count > 40 && _recentOpenVpnOutput.TryDequeue(out _)) { } + TryCaptureRouteGateway(line); + TryCaptureConnectedRemote(line); + TryCaptureAssignedLocalIp(line); + Logger.Debug($"[OpenVPN] {line}"); + } + } + } + catch { } + } + + private void TryCaptureRouteGateway(string line) + { + const string token = "route-gateway "; + var idx = line.IndexOf(token, StringComparison.OrdinalIgnoreCase); + if (idx < 0) return; + + var start = idx + token.Length; + var end = start; + while (end < line.Length && !char.IsWhiteSpace(line[end]) && line[end] != ',') + end++; + + var gateway = line[start..end].Trim(); + if (IPAddress.TryParse(gateway, out var ip) && + ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + _routeGatewayIp = gateway; + Logger.Info($"[OpenVPN] Captured route-gateway {gateway}"); + } + } + + private void TryCaptureConnectedRemote(string line) + { + if (!line.Contains("[AF_INET]", StringComparison.OrdinalIgnoreCase)) + return; + + var isConnectedLine = + line.Contains("TCP connection established with", StringComparison.OrdinalIgnoreCase) || + line.Contains("Peer Connection Initiated with", StringComparison.OrdinalIgnoreCase) || + line.Contains("link remote:", StringComparison.OrdinalIgnoreCase); + if (!isConnectedLine) + return; + + var marker = "[AF_INET]"; + var idx = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx < 0) return; + + var start = idx + marker.Length; + var end = start; + while (end < line.Length && !char.IsWhiteSpace(line[end]) && line[end] != ',' && line[end] != ']') + end++; + + var endpoint = line[start..end].Trim(); + var colon = endpoint.LastIndexOf(':'); + if (colon <= 0 || colon == endpoint.Length - 1) + return; + + var host = endpoint[..colon]; + if (!int.TryParse(endpoint[(colon + 1)..], out var port)) + return; + if (!IPAddress.TryParse(host, out var ip) || + ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + return; + + _connectedRemoteIp = host; + _connectedRemotePort = port; + Logger.Info($"[OpenVPN] Captured connected remote {host}:{port}"); + } + + private void TryCaptureAssignedLocalIp(string line) + { + const string token = "ifconfig "; + var idx = line.IndexOf(token, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return; + + var start = idx + token.Length; + var end = start; + while (end < line.Length && !char.IsWhiteSpace(line[end]) && line[end] != ',') + end++; + + var localIp = line[start..end].Trim(); + if (IPAddress.TryParse(localIp, out var ip) && + ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + _assignedLocalIp = localIp; + Logger.Info($"[OpenVPN] Captured assigned local IP {localIp}"); + } + } + + private void LogRecentOpenVpnOutput() + { + var lines = _recentOpenVpnOutput.ToArray(); + if (lines.Length == 0) + { + Logger.Warning("[OpenVPN] No recent OpenVPN output captured before timeout."); + return; + } + + Logger.Warning("[OpenVPN] Recent OpenVPN output before timeout:"); + foreach (var line in lines.TakeLast(20)) + Logger.Warning($"[OpenVPN][recent] {line}"); + } + + private static void LogRemoteCandidates(string config) + { + var remotes = ExtractRemoteCandidates(config).ToList(); + Logger.Info($"[OpenVPN] Remote candidates found: {remotes.Count}"); + foreach (var remote in remotes.Take(20)) + { + Logger.Info($"[OpenVPN] remote {remote.host}:{remote.port} {remote.proto}"); + if (IPAddress.TryParse(remote.host, out var ip)) + { + if (IsPrivateIpv4(ip)) + Logger.Warning($"[OpenVPN] remote {remote.host} is private/local; it may not be reachable from this network."); + continue; + } + + try + { + var resolved = Dns.GetHostAddresses(remote.host) + .Where(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + .Select(a => a.ToString()) + .ToList(); + Logger.Info($"[OpenVPN] remote {remote.host} resolves to: {(resolved.Count == 0 ? "no IPv4" : string.Join(", ", resolved))}"); + foreach (var resolvedIp in resolved) + { + if (IPAddress.TryParse(resolvedIp, out var resolvedAddress) && IsPrivateIpv4(resolvedAddress)) + Logger.Warning($"[OpenVPN] remote {remote.host} resolved to private/local IP {resolvedIp}; OpenVPN may hang until trying the next remote."); + } + } + catch (Exception ex) + { + Logger.Warning($"[OpenVPN] DNS resolve failed for remote {remote.host}: {ex.Message}"); + } + } + } + + private static IEnumerable<(string host, string port, string proto)> ExtractRemoteCandidates(string config) + { + foreach (var line in config.Split('\n')) + { + var raw = line.Trim(); + if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#") || raw.StartsWith(";")) + continue; + if (!raw.StartsWith("remote ", StringComparison.OrdinalIgnoreCase)) + continue; + + var parts = raw.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + yield return (parts[1], parts.Length >= 3 ? parts[2] : "1194", parts.Length >= 4 ? parts[3] : ""); + } + } + + private static bool IsPrivateIpv4(IPAddress ip) + { + var b = ip.GetAddressBytes(); + return b.Length == 4 && + (b[0] == 10 || + (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || + (b[0] == 192 && b[1] == 168) || + b[0] == 127 || + (b[0] == 169 && b[1] == 254)); + } + + private static string TryExtractRemoteHost(string config) + { + foreach (var line in config.Split('\n')) + { + var raw = line.Trim(); + if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#") || raw.StartsWith(";")) + continue; + if (!raw.StartsWith("remote ", StringComparison.OrdinalIgnoreCase)) + continue; + + var parts = raw.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + return parts[1]; + } + return ""; + } + + private static string ResolveRemoteForRouting(string remoteHost) + { + if (string.IsNullOrWhiteSpace(remoteHost)) + return "0.0.0.0"; + + if (IPAddress.TryParse(remoteHost, out var ip)) + return ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? remoteHost : "0.0.0.0"; + + try + { + var ipv4 = Dns.GetHostAddresses(remoteHost) + .FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); + return ipv4?.ToString() ?? remoteHost; + } + catch + { + return remoteHost; + } + } + + private static int FindOpenVpnInterfaceIndex(string expectedLocalIp) + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up) continue; + + var match = + nic.Name.Contains("OpenVPN", StringComparison.OrdinalIgnoreCase) || + nic.Name.Contains("TAP", StringComparison.OrdinalIgnoreCase) || + nic.Description.Contains("OpenVPN", StringComparison.OrdinalIgnoreCase) || + nic.Description.Contains("TAP-Windows", StringComparison.OrdinalIgnoreCase) || + nic.Description.Contains("Wintun", StringComparison.OrdinalIgnoreCase) || + nic.Description.Contains("Data Channel Offload", StringComparison.OrdinalIgnoreCase); + + if (!match) continue; + + var hasExpectedIp = nic.GetIPProperties().UnicastAddresses.Any(a => + a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork && + string.Equals(a.Address.ToString(), expectedLocalIp, StringComparison.OrdinalIgnoreCase)); + if (!hasExpectedIp) continue; + + var ipv4 = nic.GetIPProperties().GetIPv4Properties(); + if (ipv4 != null) return ipv4.Index; + } + return -1; + } + + private static string GetInterfaceIpv4(int interfaceIndex) + { + try + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + var props = nic.GetIPProperties(); + var ipv4 = props.GetIPv4Properties(); + if (ipv4 == null || ipv4.Index != interfaceIndex) continue; + return props.UnicastAddresses + .FirstOrDefault(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + ?.Address.ToString() ?? "N/A"; + } + } + catch { } + return "N/A"; + } +} diff --git a/AppTunnel/Services/ProfileService.cs b/AppTunnel/Services/ProfileService.cs index bd02987..8e09d5d 100644 --- a/AppTunnel/Services/ProfileService.cs +++ b/AppTunnel/Services/ProfileService.cs @@ -118,14 +118,21 @@ public class ProfileService PreSharedKey = DecryptString(s.EncryptedPsk), TunnelType = s.TunnelType, V2RayConfig = s.V2RayConfig, + OpenVpnConfig = s.OpenVpnConfig, + OpenVpnConfigPath = !string.IsNullOrWhiteSpace(s.OpenVpnConfigPath) + ? s.OpenVpnConfigPath + : (s.OpenVpnExePath.EndsWith(".ovpn", StringComparison.OrdinalIgnoreCase) ? s.OpenVpnExePath : ""), + OpenVpnUsername = s.OpenVpnUsername, + OpenVpnPassword = DecryptString(s.EncryptedOpenVpnPassword), MixedProxyPort = s.Socks5Port > 0 ? s.Socks5Port : 1080, AutoTuneMtu = s.AutoTuneMtu, EnableDnsOptimization = s.EnableDnsOptimization, EnableGameMode = s.EnableGameMode }).ToList(); } - catch + catch (Exception ex) { + Logger.Warning($"[PROFILE] Failed to load profiles: {ex.Message}"); return new List(); } } @@ -149,6 +156,10 @@ public class ProfileService EncryptedPsk = EncryptString(p.PreSharedKey), TunnelType = p.TunnelType, V2RayConfig = p.V2RayConfig, + OpenVpnConfig = p.OpenVpnConfig, + OpenVpnConfigPath = p.OpenVpnConfigPath, + OpenVpnUsername = p.OpenVpnUsername, + EncryptedOpenVpnPassword = EncryptString(p.OpenVpnPassword), Socks5Port = p.MixedProxyPort, AutoTuneMtu = p.AutoTuneMtu, EnableDnsOptimization = p.EnableDnsOptimization, @@ -206,6 +217,12 @@ public class ProfileService public string EncryptedPsk { get; set; } = ""; public TunnelType TunnelType { get; set; } = TunnelType.L2tpIpsec; public string V2RayConfig { get; set; } = ""; + public string OpenVpnConfig { get; set; } = ""; + public string OpenVpnConfigPath { get; set; } = ""; + // Legacy field: early OpenVPN test builds accidentally stored .ovpn path here. + public string OpenVpnExePath { get; set; } = ""; + public string OpenVpnUsername { get; set; } = ""; + public string EncryptedOpenVpnPassword { get; set; } = ""; [JsonPropertyName("socks5Port")] public int Socks5Port { get; set; } = 1080; public bool AutoTuneMtu { get; set; } = true; diff --git a/AppTunnel/Services/TrafficRouterService.Core.cs b/AppTunnel/Services/TrafficRouterService.Core.cs index d854e24..c098213 100644 --- a/AppTunnel/Services/TrafficRouterService.Core.cs +++ b/AppTunnel/Services/TrafficRouterService.Core.cs @@ -49,6 +49,7 @@ public partial class TrafficRouterService : IDisposable private string _vpnLocalIp = ""; private string _vpnServerIp = ""; // resolved IPv4 — used in WinDivert filter strings private string _vpnServerHost = ""; // original hostname/IP from config — used for TCP health checks + private string _vpnGatewayIp = ""; // optional next hop for TAP/OpenVPN host routes private byte[]? _vpnLocalIpBytes; private volatile bool _isRunning; private bool _fullRouteEnabled; @@ -295,12 +296,13 @@ public partial class TrafficRouterService : IDisposable public long ActiveRouteCount => _addedRoutes.Count; public long RouteFailureCount => Interlocked.Read(ref _statRoutesFailed); - public void Start(int vpnInterfaceIndex, string vpnLocalIp, string vpnServerIp) + public void Start(int vpnInterfaceIndex, string vpnLocalIp, string vpnServerIp, string vpnGatewayIp = "") { if (_isRunning) return; _vpnInterfaceIndex = vpnInterfaceIndex; _vpnLocalIp = vpnLocalIp; + _vpnGatewayIp = vpnGatewayIp; _vpnLocalIpBytes = IPAddress.TryParse(vpnLocalIp, out var vpnAddr) ? vpnAddr.GetAddressBytes() : null; @@ -404,7 +406,7 @@ public partial class TrafficRouterService : IDisposable _flowLogCount = 0; _flowMatchLogCount = 0; - Logger.Info($"TrafficRouter starting: VPN Interface={vpnInterfaceIndex}, LocalIP={vpnLocalIp}, ServerIP={vpnServerIp}"); + Logger.Info($"TrafficRouter starting: VPN Interface={vpnInterfaceIndex}, LocalIP={vpnLocalIp}, Gateway={_vpnGatewayIp}, ServerIP={vpnServerIp}"); Logger.Info($"Target apps: {string.Join(", ", _targetExecutables.Keys)}"); if (PassthroughMode) Logger.Warning("DIAGNOSTIC PASSTHROUGH MODE ENABLED — packets will NOT be redirected. For testing only."); diff --git a/AppTunnel/Services/TrafficRouterService.RouteManagement.cs b/AppTunnel/Services/TrafficRouterService.RouteManagement.cs index a8cf7ca..c0a4226 100644 --- a/AppTunnel/Services/TrafficRouterService.RouteManagement.cs +++ b/AppTunnel/Services/TrafficRouterService.RouteManagement.cs @@ -355,9 +355,10 @@ public partial class TrafficRouterService /// private bool TryAddRouteViaCommandLine(IPAddress dstIp) { - var ok = TryRunRouteCommand($"add {dstIp} mask 255.255.255.255 0.0.0.0 IF {_vpnInterfaceIndex} METRIC 1", out var stderr); + var gateway = string.IsNullOrWhiteSpace(_vpnGatewayIp) ? "0.0.0.0" : _vpnGatewayIp; + var ok = TryRunRouteCommand($"add {dstIp} mask 255.255.255.255 {gateway} IF {_vpnInterfaceIndex} METRIC 1", out var stderr); if (!ok && Interlocked.Read(ref _statRoutesFailed) <= 10) - Logger.Warning($"[ROUTE!] route.exe add {dstIp} stderr='{stderr.Trim()}'"); + Logger.Warning($"[ROUTE!] route.exe add {dstIp} via {gateway} stderr='{stderr.Trim()}'"); return ok; } diff --git a/AppTunnel/Services/VpnService.cs b/AppTunnel/Services/VpnService.cs index 63d35e7..a06c5d0 100644 --- a/AppTunnel/Services/VpnService.cs +++ b/AppTunnel/Services/VpnService.cs @@ -21,8 +21,9 @@ public class VpnService _activeProvider = config.TunnelType switch { TunnelType.L2tpIpsec => new L2tpTunnelProvider(), - TunnelType.V2Ray => TunnelProviderFactory.Create(config.V2RayConfig), - _ => throw new NotImplementedException($"نوع تانل ناشناخته: {config.TunnelType}") + TunnelType.V2Ray => TunnelProviderFactory.Create(config.V2RayConfig), + TunnelType.OpenVpn => new OpenVpnTunnelProvider(), + _ => throw new NotImplementedException($"نوع تانل ناشناخته: {config.TunnelType}") }; // Wire up the tunnel-failure watchdog for V2Ray connections. diff --git a/AppTunnel/ViewModels/MainViewModel.Connection.cs b/AppTunnel/ViewModels/MainViewModel.Connection.cs index b02d16d..8e0c0b6 100644 --- a/AppTunnel/ViewModels/MainViewModel.Connection.cs +++ b/AppTunnel/ViewModels/MainViewModel.Connection.cs @@ -23,6 +23,7 @@ public partial class MainViewModel if (_connectionState == ConnectionState.Connecting) { // Cancel ongoing connection attempt + StatusText = "در حال لغو اتصال..."; _connectionCts?.Cancel(); return; } @@ -54,6 +55,22 @@ public partial class MainViewModel StatusText = "کانفیگ V2Ray را وارد کنید"; return; } + if (tunnelType == TunnelType.OpenVpn && string.IsNullOrWhiteSpace(_selectedProfile?.OpenVpnConfig)) + { + Logger.Warning("ConnectAsync: OpenVPN config is empty"); + StatusText = "کانفیگ OpenVPN (.ovpn) را وارد کنید"; + return; + } + if (tunnelType == TunnelType.OpenVpn && !IsOpenVpnCommunityInstalled) + { + RefreshOpenVpnInstallStatus(); + if (!IsOpenVpnCommunityInstalled) + { + Logger.Warning("ConnectAsync: OpenVPN Community openvpn.exe not found"); + StatusText = "OpenVPN Community نصب نیست؛ ابتدا از لینک رسمی نصب کنید"; + return; + } + } if (!ValidateMixedProxyPort(out var socksError)) { StatusText = socksError; @@ -75,7 +92,13 @@ public partial class MainViewModel IsBusy = true; ConnectionState = ConnectionState.Connecting; - StatusText = "در حال اتصال..."; + StatusText = tunnelType == TunnelType.OpenVpn + ? "در حال آماده‌سازی OpenVPN..." + : "در حال اتصال..."; + + // Give WPF one dispatcher turn to render the connecting view before + // provider startup does DNS/process/network work. + await Task.Yield(); var config = _selectedProfile?.ToServerConfig() ?? new ServerConfig { @@ -85,6 +108,9 @@ public partial class MainViewModel PreSharedKey = PreSharedKey, TunnelType = _currentTunnelType, V2RayConfig = _selectedV2RayConfig, + OpenVpnConfig = _selectedOpenVpnConfig, + OpenVpnUsername = OpenVpnUsername, + OpenVpnPassword = OpenVpnPassword, AutoTuneMtu = AutoTuneMtu, EnableDnsOptimization = IsDnsOptimizationEnabled, EnableGameMode = IsGameModeEnabled @@ -102,6 +128,7 @@ public partial class MainViewModel } catch (OperationCanceledException) { + await CleanupAfterFailedConnectionAsync(); ConnectionState = ConnectionState.Disconnected; StatusText = "اتصال لغو شد"; IsBusy = false; @@ -138,7 +165,8 @@ public partial class MainViewModel _trafficRouter.Start( _vpnService.Status.VpnInterfaceIndex, _vpnService.Status.VpnLocalIp, - _vpnService.Status.VpnServerIp); // actual proxy/VPN server host, resolved by TrafficRouter + _vpnService.Status.VpnServerIp, + _vpnService.Status.VpnGatewayIp); // actual proxy/VPN server host, resolved by TrafficRouter _vpnHealthCheckCounter = 0; _timer.Start(); @@ -147,13 +175,44 @@ public partial class MainViewModel } else { - ConnectionState = ConnectionState.Error; - StatusText = _vpnService.Status.Message; + var failedState = _vpnService.Status.State; + var failedMessage = _vpnService.Status.Message; + await CleanupAfterFailedConnectionAsync(); + if (failedState == ConnectionState.Disconnected) + { + ConnectionState = ConnectionState.Disconnected; + StatusText = failedMessage; + } + else + { + ConnectionState = ConnectionState.Error; + StatusText = failedMessage; + } } IsBusy = false; } + private async Task CleanupAfterFailedConnectionAsync() + { + _timer.Stop(); + _pingCts?.Cancel(); + IsPinging = false; + + try { await _trafficRouter.StopAsync(); } + catch (Exception ex) { Logger.Warning($"CleanupAfterFailedConnectionAsync router cleanup failed: {ex.Message}"); } + + try { await _vpnService.DisconnectAsync(); } + catch (Exception ex) { Logger.Warning($"CleanupAfterFailedConnectionAsync VPN cleanup failed: {ex.Message}"); } + + VpnIp = ""; + VpnAdapterName = ""; + _isFullRouteEnabled = false; + OnPropertyChanged(nameof(IsFullRouteEnabled)); + OnPropertyChanged(nameof(FullRouteStatusText)); + RaiseHealthStatusChanged(); + } + private async Task DisconnectAsync() { IsBusy = true; @@ -372,6 +431,44 @@ public partial class MainViewModel return; } + if (CurrentTunnelType == TunnelType.OpenVpn) + { + var openVpnEndpoints = ExtractOpenVpnRemoteEndpoints(SelectedOpenVpnConfig).ToList(); + if (openVpnEndpoints.Count == 0) + { + ServerPingResult = "remote سرور در فایل .ovpn پیدا نشد"; + return; + } + + var tcpEndpoints = openVpnEndpoints + .Where(e => !e.Protocol.Contains("udp", StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (tcpEndpoints.Count == 0) + { + ServerPingResult = "کانفیگ UDP است؛ تست دقیق قبل از اتصال ممکن نیست"; + return; + } + + Exception? lastError = null; + foreach (var endpointToTest in tcpEndpoints) + { + try + { + using var ctsOpenVpn = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msOpenVpn = await MeasureTcpConnectLatencyAsync(endpointToTest.Host, endpointToTest.Port, ctsOpenVpn.Token); + ServerPingResult = $"TCP connect {endpointToTest.Host}:{endpointToTest.Port} {msOpenVpn} ms"; + return; + } + catch (Exception ex) when (ex is OperationCanceledException or SocketException or TimeoutException) + { + lastError = ex; + } + } + + ServerPingResult = $"هیچ remote قابل‌دسترسی نبود ({lastError?.Message ?? "timeout"})"; + return; + } + var rawConfig = SelectedV2RayConfig.Trim(); if (!TryExtractProxyEndpointDetails(rawConfig, out var endpoint, out var error)) { @@ -398,7 +495,99 @@ public partial class MainViewModel } } + private async Task TestConnectedServerPingAsync() + { + if (IsTestingConnectedServerPing) return; + + if (IsPinging) + { + _pingCts?.Cancel(); + IsPinging = false; + } + + IsTestingConnectedServerPing = true; + PingResult = "در حال پینگ سرور..."; + + try + { + if (CurrentTunnelType == TunnelType.OpenVpn) + { + var connectedHost = _vpnService.Status.VpnServerIp; + var connectedPort = _vpnService.Status.VpnServerPort; + if (!string.IsNullOrWhiteSpace(connectedHost) && connectedPort > 0) + { + using var ctsConnectedOpenVpn = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var connectedMs = await MeasureTcpConnectLatencyAsync(connectedHost, connectedPort, ctsConnectedOpenVpn.Token); + PingResult = $"سرور OpenVPN: TCP {connectedHost}:{connectedPort} {connectedMs} ms"; + return; + } + + if (!TryExtractOpenVpnRemoteEndpoint(SelectedOpenVpnConfig, out var openVpnEndpoint, out var openVpnError)) + { + PingResult = openVpnError; + return; + } + + if (openVpnEndpoint.Protocol.Contains("udp", StringComparison.OrdinalIgnoreCase)) + { + using var ping = new Ping(); + var reply = await ping.SendPingAsync(openVpnEndpoint.Host, 3000); + PingResult = reply.Status == IPStatus.Success + ? $"سرور OpenVPN: ICMP {reply.RoundtripTime} ms" + : $"سرور OpenVPN: ICMP {reply.Status}"; + return; + } + + using var ctsOpenVpn = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msOpenVpn = await MeasureTcpConnectLatencyAsync(openVpnEndpoint.Host, openVpnEndpoint.Port, ctsOpenVpn.Token); + PingResult = $"سرور OpenVPN: TCP {openVpnEndpoint.Host}:{openVpnEndpoint.Port} {msOpenVpn} ms"; + return; + } + + if (CurrentTunnelType == TunnelType.L2tpIpsec) + { + var host = ServerAddress.Trim(); + if (string.IsNullOrWhiteSpace(host)) + { + PingResult = "آدرس سرور خالی است"; + return; + } + + using var ping = new Ping(); + var reply = await ping.SendPingAsync(host, 3000); + PingResult = reply.Status == IPStatus.Success + ? $"سرور L2TP: ICMP {reply.RoundtripTime} ms" + : $"سرور L2TP: ICMP {reply.Status}"; + return; + } + + if (!TryExtractProxyEndpointDetails(SelectedV2RayConfig.Trim(), out var endpoint, out var error)) + { + PingResult = 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"; + PingResult = $"سرور: {mode} {endpoint.Server}:{endpoint.Port} {ms} ms"; + } + catch (OperationCanceledException) + { + PingResult = "پینگ سرور timeout شد"; + } + catch (Exception ex) + { + PingResult = $"خطا: {ex.Message}"; + } + finally + { + IsTestingConnectedServerPing = false; + } + } + private readonly record struct ProxyEndpoint(string Server, int Port, bool UseTls, string? Sni); + private readonly record struct OpenVpnRemoteEndpoint(string Host, int Port, string Protocol); private static async Task MeasureEndpointLatencyAsync(ProxyEndpoint endpoint, CancellationToken ct) { @@ -423,6 +612,70 @@ public partial class MainViewModel return sw.ElapsedMilliseconds; } + private static async Task MeasureTcpConnectLatencyAsync(string host, int port, CancellationToken ct) + { + using var tcp = new TcpClient(); + tcp.NoDelay = true; + var sw = System.Diagnostics.Stopwatch.StartNew(); + await tcp.ConnectAsync(host, port, ct); + sw.Stop(); + return sw.ElapsedMilliseconds; + } + + private static bool TryExtractOpenVpnRemoteEndpoint( + string config, + out OpenVpnRemoteEndpoint endpoint, + out string error) + { + endpoint = default; + error = ""; + + if (string.IsNullOrWhiteSpace(config)) + { + error = "فایل .ovpn انتخاب نشده است"; + return false; + } + + foreach (var endpointToTest in ExtractOpenVpnRemoteEndpoints(config)) + { + endpoint = endpointToTest; + return true; + } + + error = "remote سرور در فایل .ovpn پیدا نشد"; + return false; + } + + private static IEnumerable ExtractOpenVpnRemoteEndpoints(string config) + { + if (string.IsNullOrWhiteSpace(config)) + yield break; + + foreach (var line in config.Split('\n')) + { + var raw = line.Trim(); + if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#") || raw.StartsWith(";")) + continue; + if (!raw.StartsWith("remote ", StringComparison.OrdinalIgnoreCase)) + continue; + + var parts = raw.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + continue; + + var host = parts[1]; + var port = parts.Length >= 3 && int.TryParse(parts[2], out var parsedPort) + ? parsedPort + : 1194; + var protocol = parts.Length >= 4 ? parts[3] : ""; + + if (string.IsNullOrWhiteSpace(host) || port <= 0 || port > 65535) + continue; + + yield return new OpenVpnRemoteEndpoint(host, port, protocol); + } + } + private static bool TryExtractProxyEndpoint(string config, out string server, out int port, out string error) { if (TryExtractProxyEndpointDetails(config, out var endpoint, out error)) diff --git a/AppTunnel/ViewModels/MainViewModel.Core.cs b/AppTunnel/ViewModels/MainViewModel.Core.cs index baed8a3..618c2a3 100644 --- a/AppTunnel/ViewModels/MainViewModel.Core.cs +++ b/AppTunnel/ViewModels/MainViewModel.Core.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.Net.NetworkInformation; using System.Runtime.CompilerServices; using System.Text.Json.Nodes; @@ -25,6 +26,7 @@ public partial class MainViewModel : INotifyPropertyChanged private CancellationTokenSource? _connectionCts; private DateTime _connectionStartTime; private ProfileService.AppSettings _appSettings = new(); + private bool _isLoadingProfile; public MainViewModel() { @@ -37,7 +39,7 @@ public partial class MainViewModel : INotifyPropertyChanged Application.Current?.Dispatcher.Invoke(() => StatusText = $"خطا: {t.Exception?.InnerException?.Message}"); }, TaskScheduler.Default); - }, _ => !IsBusy); + }, _ => !IsBusy || ConnectionState == ConnectionState.Connecting); AddAppCommand = new RelayCommand(_ => AddCustomApp()); RemoveAppCommand = new RelayCommand(RemoveApp); ToggleAppCommand = new RelayCommand(ToggleApp); @@ -61,9 +63,12 @@ public partial class MainViewModel : INotifyPropertyChanged // Ping command TogglePingCommand = new RelayCommand(_ => TogglePing(), _ => IsConnected); + TestConnectedServerPingCommand = new RelayCommand(_ => _ = TestConnectedServerPingAsync(), _ => IsConnected && !IsTestingConnectedServerPing); TestServerPingCommand = new RelayCommand(_ => _ = TestServerPingAsync(), _ => !IsConnected && !IsTestingServerPing); - PasteConfigCommand = new RelayCommand(_ => PasteConfigFromClipboard(), _ => !IsConnected && CurrentTunnelType == TunnelType.V2Ray); - ClearConfigCommand = new RelayCommand(_ => SelectedV2RayConfig = "", _ => !IsConnected && CurrentTunnelType == TunnelType.V2Ray); + PasteConfigCommand = new RelayCommand(_ => PasteConfigFromClipboard(), _ => !IsConnected && (CurrentTunnelType == TunnelType.V2Ray || CurrentTunnelType == TunnelType.OpenVpn)); + ClearConfigCommand = new RelayCommand(_ => ClearCurrentConfig(), _ => !IsConnected && (CurrentTunnelType == TunnelType.V2Ray || CurrentTunnelType == TunnelType.OpenVpn)); + BrowseOpenVpnConfigCommand = new RelayCommand(_ => BrowseForOpenVpnConfig(), _ => !IsConnected && CurrentTunnelType == TunnelType.OpenVpn); + OpenOpenVpnCommunityDownloadCommand = new RelayCommand(_ => OpenExternalLink(OpenVpnCommunityDownloadUrl)); OpenGitHubCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.GitHubUrl)); OpenDonateCommand = new RelayCommand(_ => OpenExternalLink(AppInfo.PayPalDonateUrl)); CopyDonationInfoCommand = new RelayCommand(_ => CopyDonationInfoToClipboard()); @@ -93,6 +98,7 @@ public partial class MainViewModel : INotifyPropertyChanged LoadIncludes(); LoadHistory(); LoadAppSettings(); + RefreshOpenVpnInstallStatus(); _ = CheckForUpdatesAsync(true); // Auto-connect to last active profile if enabled @@ -283,7 +289,13 @@ public partial class MainViewModel : INotifyPropertyChanged public bool IsBusy { get => _isBusy; - set { _isBusy = value; OnPropertyChanged(); OnPropertyChanged(nameof(ConnectButtonText)); } + set + { + _isBusy = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ConnectButtonText)); + CommandManager.InvalidateRequerySuggested(); + } } private ConnectionState _connectionState = ConnectionState.Disconnected; @@ -298,11 +310,49 @@ public partial class MainViewModel : INotifyPropertyChanged OnPropertyChanged(nameof(ConnectButtonText)); OnPropertyChanged(nameof(StatusColor)); OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(IsOpenVpnConnectionPending)); RaiseHealthStatusChanged(); + CommandManager.InvalidateRequerySuggested(); } } public bool IsConnected => _connectionState == ConnectionState.Connected; + public bool IsOpenVpnConnectionPending => + _connectionState == ConnectionState.Connecting && CurrentTunnelType == TunnelType.OpenVpn; + public const string OpenVpnCommunityDownloadUrl = "https://openvpn.net/community-downloads/"; + + private bool _isOpenVpnCommunityInstalled; + public bool IsOpenVpnCommunityInstalled + { + get => _isOpenVpnCommunityInstalled; + private set + { + if (_isOpenVpnCommunityInstalled == value) return; + _isOpenVpnCommunityInstalled = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(OpenVpnPrerequisiteText)); + OnPropertyChanged(nameof(OpenVpnPrerequisiteColor)); + } + } + + private string _openVpnDetectedPath = ""; + public string OpenVpnDetectedPath + { + get => _openVpnDetectedPath; + private set + { + if (_openVpnDetectedPath == value) return; + _openVpnDetectedPath = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(OpenVpnPrerequisiteText)); + } + } + + public string OpenVpnPrerequisiteText => IsOpenVpnCommunityInstalled + ? $"پیش‌نیاز آماده است: نسخه Community اوپن‌وی‌پی‌ان پیدا شد: {OpenVpnDetectedPath}" + : "اخطار: نسخه Community اوپن‌وی‌پی‌ان نصب نیست. برای استفاده از اسپلیت‌تانلینگ با این نوع اتصال، ابتدا آن را از لینک رسمی نصب کنید."; + + public string OpenVpnPrerequisiteColor => IsOpenVpnCommunityInstalled ? "#6CCB5F" : "#E0A020"; /// App version read from a single app-wide source. public string AppVersion => AppInfo.VersionText; @@ -405,8 +455,12 @@ public partial class MainViewModel : INotifyPropertyChanged if (_currentTunnelType == value) return; _currentTunnelType = value; OnPropertyChanged(); + OnPropertyChanged(nameof(IsOpenVpnConnectionPending)); + OnPropertyChanged(nameof(ConnectedServerPingButtonText)); if (_selectedProfile != null) _selectedProfile.TunnelType = value; + if (value == TunnelType.OpenVpn) + RefreshOpenVpnInstallStatus(); UpdateConfigDiagnostics(); RaiseHealthStatusChanged(); SaveCurrentState(); @@ -433,6 +487,69 @@ public partial class MainViewModel : INotifyPropertyChanged } } + private string _selectedOpenVpnConfig = ""; + public string SelectedOpenVpnConfig + { + get => _selectedOpenVpnConfig; + set + { + if (_selectedOpenVpnConfig == value) return; + _selectedOpenVpnConfig = value; + if (_selectedProfile != null) + _selectedProfile.OpenVpnConfig = value; + OnPropertyChanged(); + UpdateConfigDiagnostics(); + RaiseHealthStatusChanged(); + SaveCurrentState(); + CommandManager.InvalidateRequerySuggested(); + } + } + + private string _selectedOpenVpnConfigPath = ""; + public string SelectedOpenVpnConfigPath + { + get => _selectedOpenVpnConfigPath; + set + { + if (_selectedOpenVpnConfigPath == value) return; + _selectedOpenVpnConfigPath = value; + if (_selectedProfile != null) + _selectedProfile.OpenVpnConfigPath = value; + OnPropertyChanged(); + SaveCurrentState(); + } + } + + private string _openVpnUsername = ""; + public string OpenVpnUsername + { + get => _openVpnUsername; + set + { + if (_openVpnUsername == value) return; + _openVpnUsername = value; + if (_selectedProfile != null) + _selectedProfile.OpenVpnUsername = value; + OnPropertyChanged(); + SaveCurrentState(); + } + } + + private string _openVpnPassword = ""; + public string OpenVpnPassword + { + get => _openVpnPassword; + set + { + if (_openVpnPassword == value) return; + _openVpnPassword = value; + if (_selectedProfile != null) + _selectedProfile.OpenVpnPassword = value; + OnPropertyChanged(); + SaveCurrentState(); + } + } + private string _configCoreHint = ""; public string ConfigCoreHint { @@ -549,6 +666,7 @@ public partial class MainViewModel : INotifyPropertyChanged TunnelType.L2tpIpsec => "L2TP", TunnelType.V2Ray when TunnelProviderFactory.RequiresXray(SelectedV2RayConfig) => "Xray", TunnelType.V2Ray => "sing-box", + TunnelType.OpenVpn => "OpenVPN", _ => "-" }; @@ -596,6 +714,21 @@ public partial class MainViewModel : INotifyPropertyChanged public string PingButtonText => _isPinging ? "⏹ توقف" : "▶ شروع"; + private bool _isTestingConnectedServerPing; + public bool IsTestingConnectedServerPing + { + get => _isTestingConnectedServerPing; + set + { + _isTestingConnectedServerPing = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ConnectedServerPingButtonText)); + CommandManager.InvalidateRequerySuggested(); + } + } + + public string ConnectedServerPingButtonText => IsTestingConnectedServerPing ? "در حال پینگ..." : "پینگ سرور"; + private string _pingResult = ""; public string PingResult { @@ -728,9 +861,12 @@ public partial class MainViewModel : INotifyPropertyChanged public ICommand AddIncludeCommand { get; } public ICommand RemoveIncludeCommand { get; } public ICommand TogglePingCommand { get; } + public ICommand TestConnectedServerPingCommand { get; } public ICommand TestServerPingCommand { get; } public ICommand PasteConfigCommand { get; } public ICommand ClearConfigCommand { get; } + public ICommand BrowseOpenVpnConfigCommand { get; } + public ICommand OpenOpenVpnCommunityDownloadCommand { get; } public ICommand OpenGitHubCommand { get; } public ICommand OpenDonateCommand { get; } public ICommand CopyDonationInfoCommand { get; } @@ -835,7 +971,13 @@ public partial class MainViewModel : INotifyPropertyChanged try { if (System.Windows.Clipboard.ContainsText()) - SelectedV2RayConfig = System.Windows.Clipboard.GetText().Trim(); + { + var text = System.Windows.Clipboard.GetText().Trim(); + if (CurrentTunnelType == TunnelType.OpenVpn) + SelectedOpenVpnConfig = text; + else + SelectedV2RayConfig = text; + } } catch (Exception ex) { @@ -843,6 +985,48 @@ public partial class MainViewModel : INotifyPropertyChanged } } + private void ClearCurrentConfig() + { + if (CurrentTunnelType == TunnelType.OpenVpn) + { + SelectedOpenVpnConfig = ""; + SelectedOpenVpnConfigPath = ""; + } + else + SelectedV2RayConfig = ""; + } + + private void BrowseForOpenVpnConfig() + { + var dialog = new Microsoft.Win32.OpenFileDialog + { + Title = "انتخاب فایل OpenVPN", + Filter = "OpenVPN config (*.ovpn)|*.ovpn|All files (*.*)|*.*", + CheckFileExists = true, + Multiselect = false + }; + + if (dialog.ShowDialog() != true) return; + + try + { + SelectedOpenVpnConfigPath = dialog.FileName; + SelectedOpenVpnConfig = File.ReadAllText(dialog.FileName); + } + catch (Exception ex) + { + ConfigValidationText = $"خواندن فایل OpenVPN ناموفق بود: {ex.Message}"; + } + } + + private void RefreshOpenVpnInstallStatus() + { + var path = OpenVpnTunnelProvider.FindOpenVpnExecutable(); + IsOpenVpnCommunityInstalled = !string.IsNullOrWhiteSpace(path); + OpenVpnDetectedPath = path ?? ""; + UpdateConfigDiagnostics(); + } + private void UpdateConfigDiagnostics() { if (CurrentTunnelType == TunnelType.L2tpIpsec) @@ -854,6 +1038,19 @@ public partial class MainViewModel : INotifyPropertyChanged return; } + if (CurrentTunnelType == TunnelType.OpenVpn) + { + ConfigCoreHint = "OpenVPN"; + ConfigValidationText = !IsOpenVpnCommunityInstalled + ? "OpenVPN Community نصب نیست؛ ابتدا آن را از لینک رسمی نصب کنید" + : string.IsNullOrWhiteSpace(SelectedOpenVpnConfig) + ? "فایل .ovpn را انتخاب کنید؛ TunnelX آن را در حالت split-compatible اجرا می‌کند" + : string.IsNullOrWhiteSpace(OpenVpnUsername) + ? "کانفیگ انتخاب شد؛ اگر سرور احراز هویت دارد نام کاربری را وارد کنید" + : "کانفیگ و نام کاربری OpenVPN آماده است"; + return; + } + var config = SelectedV2RayConfig.Trim(); if (string.IsNullOrWhiteSpace(config)) { diff --git a/AppTunnel/ViewModels/MainViewModel.ProfileManagement.cs b/AppTunnel/ViewModels/MainViewModel.ProfileManagement.cs index 24c5310..500aeb8 100644 --- a/AppTunnel/ViewModels/MainViewModel.ProfileManagement.cs +++ b/AppTunnel/ViewModels/MainViewModel.ProfileManagement.cs @@ -17,6 +17,7 @@ public partial class MainViewModel set { if (_selectedProfile == value) return; + _saveDebounceTimer.Stop(); SaveCurrentProfileState(); _selectedProfile = value; OnPropertyChanged(); @@ -33,6 +34,7 @@ public partial class MainViewModel /// Event to notify code-behind to update PasswordBox controls. /// public event Action? PasswordChanged; + public event Action? OpenVpnPasswordChanged; private void LoadProfiles() { @@ -111,6 +113,10 @@ public partial class MainViewModel _selectedProfile.PreSharedKey = PreSharedKey; _selectedProfile.TunnelType = _currentTunnelType; _selectedProfile.V2RayConfig = SelectedV2RayConfig; + _selectedProfile.OpenVpnConfig = SelectedOpenVpnConfig; + _selectedProfile.OpenVpnConfigPath = SelectedOpenVpnConfigPath; + _selectedProfile.OpenVpnUsername = OpenVpnUsername; + _selectedProfile.OpenVpnPassword = OpenVpnPassword; _selectedProfile.MixedProxyPort = MixedProxyPort; _selectedProfile.AutoTuneMtu = AutoTuneMtu; _selectedProfile.EnableDnsOptimization = IsDnsOptimizationEnabled; @@ -123,6 +129,7 @@ public partial class MainViewModel /// public void SaveCurrentState() { + if (_isLoadingProfile) return; SaveStatusText = "در حال ذخیره..."; _saveDebounceTimer.Stop(); _saveDebounceTimer.Start(); // Restart timer - will save after 1 second of no changes @@ -141,34 +148,50 @@ public partial class MainViewModel private void LoadProfileIntoUi(ConnectionProfile profile) { - ServerAddress = profile.ServerAddress; - Username = profile.Username; - Password = profile.Password; - PreSharedKey = profile.PreSharedKey; - _mixedProxyPort = profile.MixedProxyPort > 0 ? profile.MixedProxyPort : 1080; - _trafficRouter.Socks5Port = _mixedProxyPort; - OnPropertyChanged(nameof(MixedProxyPort)); - OnPropertyChanged(nameof(MixedProxyPortText)); - OnPropertyChanged(nameof(MixedProxyInfo)); - UpdateMixedProxyPortStatus(); - _autoTuneMtu = profile.AutoTuneMtu; - _isDnsOptimizationEnabled = profile.EnableDnsOptimization; - _isGameModeEnabled = profile.EnableGameMode; - _trafficRouter.EnableDnsOptimization = _isDnsOptimizationEnabled; - _trafficRouter.EnableGameMode = _isGameModeEnabled; - OnPropertyChanged(nameof(AutoTuneMtu)); - OnPropertyChanged(nameof(IsDnsOptimizationEnabled)); - OnPropertyChanged(nameof(IsGameModeEnabled)); - OnPropertyChanged(nameof(GameModeStatusText)); - // Use the field directly to avoid writing back to the old profile - // while the new profile is being loaded. - _currentTunnelType = profile.TunnelType; - _selectedV2RayConfig = profile.V2RayConfig; - OnPropertyChanged(nameof(CurrentTunnelType)); - OnPropertyChanged(nameof(SelectedV2RayConfig)); - UpdateConfigDiagnostics(); + _isLoadingProfile = true; + try + { + ServerAddress = profile.ServerAddress; + Username = profile.Username; + Password = profile.Password; + PreSharedKey = profile.PreSharedKey; + _mixedProxyPort = profile.MixedProxyPort > 0 ? profile.MixedProxyPort : 1080; + _trafficRouter.Socks5Port = _mixedProxyPort; + OnPropertyChanged(nameof(MixedProxyPort)); + OnPropertyChanged(nameof(MixedProxyPortText)); + OnPropertyChanged(nameof(MixedProxyInfo)); + UpdateMixedProxyPortStatus(); + _autoTuneMtu = profile.AutoTuneMtu; + _isDnsOptimizationEnabled = profile.EnableDnsOptimization; + _isGameModeEnabled = profile.EnableGameMode; + _trafficRouter.EnableDnsOptimization = _isDnsOptimizationEnabled; + _trafficRouter.EnableGameMode = _isGameModeEnabled; + OnPropertyChanged(nameof(AutoTuneMtu)); + OnPropertyChanged(nameof(IsDnsOptimizationEnabled)); + OnPropertyChanged(nameof(IsGameModeEnabled)); + OnPropertyChanged(nameof(GameModeStatusText)); + // Use the field directly to avoid writing back to the old profile + // while the new profile is being loaded. + _currentTunnelType = profile.TunnelType; + _selectedV2RayConfig = profile.V2RayConfig; + _selectedOpenVpnConfig = profile.OpenVpnConfig; + _selectedOpenVpnConfigPath = profile.OpenVpnConfigPath; + _openVpnUsername = profile.OpenVpnUsername; + _openVpnPassword = profile.OpenVpnPassword; + OnPropertyChanged(nameof(CurrentTunnelType)); + OnPropertyChanged(nameof(SelectedV2RayConfig)); + OnPropertyChanged(nameof(SelectedOpenVpnConfig)); + OnPropertyChanged(nameof(SelectedOpenVpnConfigPath)); + OnPropertyChanged(nameof(OpenVpnUsername)); + UpdateConfigDiagnostics(); - PasswordChanged?.Invoke(profile.Password, profile.PreSharedKey); + PasswordChanged?.Invoke(profile.Password, profile.PreSharedKey); + OpenVpnPasswordChanged?.Invoke(profile.OpenVpnPassword); + } + finally + { + _isLoadingProfile = false; + } } private void CreateNewProfile() @@ -196,6 +219,10 @@ public partial class MainViewModel PreSharedKey = _selectedProfile.PreSharedKey, TunnelType = _selectedProfile.TunnelType, V2RayConfig = _selectedProfile.V2RayConfig, + OpenVpnConfig = _selectedProfile.OpenVpnConfig, + OpenVpnConfigPath = _selectedProfile.OpenVpnConfigPath, + OpenVpnUsername = _selectedProfile.OpenVpnUsername, + OpenVpnPassword = _selectedProfile.OpenVpnPassword, MixedProxyPort = _selectedProfile.MixedProxyPort, AutoTuneMtu = _selectedProfile.AutoTuneMtu, EnableDnsOptimization = _selectedProfile.EnableDnsOptimization, diff --git a/AppTunnel/Views/ConnectionTabView.xaml b/AppTunnel/Views/ConnectionTabView.xaml index ab2f25d..a2f9ccf 100644 --- a/AppTunnel/Views/ConnectionTabView.xaml +++ b/AppTunnel/Views/ConnectionTabView.xaml @@ -17,8 +17,65 @@ + + + + + + + + + + + + + + + + + + + +