mirror of
https://github.com/MaxiFan/TunnelX.git
synced 2026-05-17 21:14:37 +03:00
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -21,9 +21,9 @@
|
||||
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
|
||||
<NeutralLanguage>fa-IR</NeutralLanguage>
|
||||
<!-- Version Management -->
|
||||
<Version>1.2.24</Version>
|
||||
<AssemblyVersion>1.2.24.0</AssemblyVersion>
|
||||
<FileVersion>1.2.24.0</FileVersion>
|
||||
<Version>1.2.26</Version>
|
||||
<AssemblyVersion>1.2.26.0</AssemblyVersion>
|
||||
<FileVersion>1.2.26.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||
|
||||
@@ -7,7 +7,8 @@ namespace AppTunnel.Models;
|
||||
public enum TunnelType
|
||||
{
|
||||
L2tpIpsec,
|
||||
V2Ray
|
||||
V2Ray,
|
||||
OpenVpn
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -28,6 +29,10 @@ public class ConnectionProfile : INotifyPropertyChanged
|
||||
private List<string> _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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> _recentOpenVpnOutput = new();
|
||||
|
||||
public ConnectionStatus Status { get; } = new();
|
||||
|
||||
public async Task<bool> 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<string> 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<string> 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("<connection>", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var block = new List<string> { raw };
|
||||
while (++i < lines.Length)
|
||||
{
|
||||
var blockLine = lines[i].TrimEnd('\r');
|
||||
block.Add(blockLine);
|
||||
if (blockLine.Trim().Equals("</connection>", 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<string> 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";
|
||||
}
|
||||
}
|
||||
@@ -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<ConnectionProfile>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -355,9 +355,10 @@ public partial class TrafficRouterService
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ public class VpnService
|
||||
{
|
||||
TunnelType.L2tpIpsec => new L2tpTunnelProvider(),
|
||||
TunnelType.V2Ray => TunnelProviderFactory.Create(config.V2RayConfig),
|
||||
TunnelType.OpenVpn => new OpenVpnTunnelProvider(),
|
||||
_ => throw new NotImplementedException($"نوع تانل ناشناخته: {config.TunnelType}")
|
||||
};
|
||||
|
||||
|
||||
@@ -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,22 +165,54 @@ 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();
|
||||
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
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 = _vpnService.Status.Message;
|
||||
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<long> MeasureEndpointLatencyAsync(ProxyEndpoint endpoint, CancellationToken ct)
|
||||
{
|
||||
@@ -423,6 +612,70 @@ public partial class MainViewModel
|
||||
return sw.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
private static async Task<long> 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<OpenVpnRemoteEndpoint> 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))
|
||||
|
||||
@@ -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";
|
||||
|
||||
/// <summary>App version read from a single app-wide source.</summary>
|
||||
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))
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
public event Action<string, string>? PasswordChanged;
|
||||
public event Action<string>? 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
|
||||
/// </summary>
|
||||
public void SaveCurrentState()
|
||||
{
|
||||
if (_isLoadingProfile) return;
|
||||
SaveStatusText = "در حال ذخیره...";
|
||||
_saveDebounceTimer.Stop();
|
||||
_saveDebounceTimer.Start(); // Restart timer - will save after 1 second of no changes
|
||||
@@ -140,6 +147,9 @@ public partial class MainViewModel
|
||||
}
|
||||
|
||||
private void LoadProfileIntoUi(ConnectionProfile profile)
|
||||
{
|
||||
_isLoadingProfile = true;
|
||||
try
|
||||
{
|
||||
ServerAddress = profile.ServerAddress;
|
||||
Username = profile.Username;
|
||||
@@ -164,11 +174,24 @@ public partial class MainViewModel
|
||||
// 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);
|
||||
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,
|
||||
|
||||
@@ -17,8 +17,65 @@
|
||||
<!-- ══ DISCONNECTED STATE: Show connection form ══ -->
|
||||
<StackPanel Visibility="{Binding IsConnected, Converter={StaticResource InverseBoolToVis}}">
|
||||
|
||||
<!-- OpenVPN connecting view -->
|
||||
<Border Background="#33221812"
|
||||
BorderBrush="{StaticResource WarningBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="18,16"
|
||||
Margin="0,0,0,10"
|
||||
Visibility="{Binding IsOpenVpnConnectionPending, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0"
|
||||
Width="42"
|
||||
Height="42"
|
||||
CornerRadius="21"
|
||||
Background="#22FFC107"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock Text="⏳"
|
||||
FontSize="20"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Text="در حال اتصال OpenVPN"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource WarningBrush}"/>
|
||||
<TextBlock Text="{Binding StatusText}"
|
||||
TextWrapping="Wrap"
|
||||
FontSize="11"
|
||||
LineHeight="18"
|
||||
Margin="0,5,0,0"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="تا قبل از بالا آمدن آداپتر، مسیرهای سیستم تغییر داده نمیشود. اگر اتصال طولانی شد، فایل .ovpn، نام کاربری/رمز یا نصب OpenVPN Community را بررسی کنید."
|
||||
TextWrapping="Wrap"
|
||||
FontSize="10"
|
||||
LineHeight="17"
|
||||
Margin="0,6,0,0"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="4"
|
||||
Style="{StaticResource DangerButton}"
|
||||
Content="لغو اتصال"
|
||||
Command="{Binding ConnectCommand}"
|
||||
VerticalAlignment="Top"
|
||||
Padding="14,7"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ── Profile Card ── -->
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<Border Style="{StaticResource CardPanel}"
|
||||
Visibility="{Binding IsOpenVpnConnectionPending, Converter={StaticResource InverseBoolToVis}}">
|
||||
<StackPanel>
|
||||
<!-- Header -->
|
||||
<Grid Margin="0,0,0,6">
|
||||
@@ -80,7 +137,8 @@
|
||||
</Border>
|
||||
|
||||
<!-- ── Server Settings Card ── -->
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<Border Style="{StaticResource CardPanel}"
|
||||
Visibility="{Binding IsOpenVpnConnectionPending, Converter={StaticResource InverseBoolToVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="🌐 تنظیمات سرور"
|
||||
Margin="0,0,0,4"/>
|
||||
@@ -95,6 +153,8 @@
|
||||
Tag="{x:Static models:TunnelType.L2tpIpsec}"/>
|
||||
<ComboBoxItem Content="V2Ray / Xray"
|
||||
Tag="{x:Static models:TunnelType.V2Ray}"/>
|
||||
<ComboBoxItem Content="OpenVPN"
|
||||
Tag="{x:Static models:TunnelType.OpenVpn}"/>
|
||||
</ComboBox>
|
||||
|
||||
<!-- L2TP fields (shown only when TunnelType = L2tpIpsec) -->
|
||||
@@ -202,6 +262,102 @@
|
||||
</StackPanel>
|
||||
<!-- End V2Ray fields -->
|
||||
|
||||
<!-- OpenVPN fields (shown only when TunnelType = OpenVpn) -->
|
||||
<StackPanel Visibility="{Binding CurrentTunnelType,
|
||||
Converter={StaticResource EnumToVis},
|
||||
ConverterParameter=OpenVpn}">
|
||||
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,0,0,8">
|
||||
<StackPanel>
|
||||
<TextBlock Text="TunnelX فایل .ovpn را با OpenVPN Community اجرا میکند و مسیر/DNS پیشفرض OpenVPN را کنترل میکند تا فقط برنامههای انتخابی از تونل عبور کنند."
|
||||
TextWrapping="Wrap"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBlock Text="OpenVPN Connect بهتنهایی کافی نیست؛ اگر Community نصب نباشد، از دکمه دانلود پایین استفاده کنید."
|
||||
TextWrapping="Wrap"
|
||||
FontSize="10"
|
||||
Margin="0,5,0,0"
|
||||
Foreground="{StaticResource WarningBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="فایل OpenVPN (.ovpn)"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0"
|
||||
Style="{StaticResource ModernTextBox}"
|
||||
Text="{Binding SelectedOpenVpnConfigPath}"
|
||||
IsReadOnly="True"
|
||||
FlowDirection="LeftToRight"/>
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="انتخاب فایل"
|
||||
Command="{Binding BrowseOpenVpnConfigCommand}"
|
||||
FontSize="10"
|
||||
Padding="12,6"/>
|
||||
<Button Grid.Column="4"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="حذف فایل"
|
||||
Command="{Binding ClearConfigCommand}"
|
||||
FontSize="10"
|
||||
Padding="12,6"/>
|
||||
</Grid>
|
||||
|
||||
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,6,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding OpenVpnPrerequisiteText}"
|
||||
Foreground="{Binding OpenVpnPrerequisiteColor, Converter={StaticResource StringToColor}}"
|
||||
FontSize="10"
|
||||
TextWrapping="Wrap"
|
||||
FlowDirection="RightToLeft"/>
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="دانلود OpenVPN"
|
||||
Command="{Binding OpenOpenVpnCommunityDownloadCommand}"
|
||||
FontSize="10"
|
||||
Padding="10,5"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="نام کاربری"/>
|
||||
<TextBox Style="{StaticResource ModernTextBox}"
|
||||
Text="{Binding OpenVpnUsername, UpdateSourceTrigger=PropertyChanged}"
|
||||
FlowDirection="LeftToRight"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="رمز عبور"/>
|
||||
<PasswordBox x:Name="OpenVpnPasswordField"
|
||||
Style="{StaticResource ModernPasswordBox}"
|
||||
FlowDirection="LeftToRight"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding ConfigValidationText}"
|
||||
FontSize="10"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
<!-- End OpenVPN fields -->
|
||||
|
||||
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
@@ -423,6 +579,8 @@
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding PingTarget, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource ModernTextBox}"
|
||||
@@ -433,6 +591,11 @@
|
||||
Command="{Binding TogglePingCommand}"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
FontSize="11" Padding="10,5"/>
|
||||
<Button Grid.Column="4" Content="{Binding ConnectedServerPingButtonText}"
|
||||
Command="{Binding TestConnectedServerPingCommand}"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
FontSize="11" Padding="10,5"
|
||||
ToolTip="دسترسی به سرور همین اتصال را تست میکند"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding PingResult}" FontSize="11"
|
||||
FontWeight="SemiBold" FlowDirection="LeftToRight"
|
||||
|
||||
@@ -19,22 +19,29 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
|
||||
// Wire up PasswordBox (can't bind directly in WPF)
|
||||
PasswordField.PasswordChanged += OnPasswordFieldChanged;
|
||||
PskField.PasswordChanged += OnPskFieldChanged;
|
||||
OpenVpnPasswordField.PasswordChanged += OnOpenVpnPasswordFieldChanged;
|
||||
|
||||
// When profile changes, update PasswordBox fields
|
||||
vm.PasswordChanged += OnViewModelPasswordChanged;
|
||||
vm.OpenVpnPasswordChanged += OnViewModelOpenVpnPasswordChanged;
|
||||
|
||||
// Load initial values
|
||||
PasswordField.Password = vm.Password;
|
||||
PskField.Password = vm.PreSharedKey;
|
||||
OpenVpnPasswordField.Password = vm.OpenVpnPassword;
|
||||
}
|
||||
|
||||
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
PasswordField.PasswordChanged -= OnPasswordFieldChanged;
|
||||
PskField.PasswordChanged -= OnPskFieldChanged;
|
||||
OpenVpnPasswordField.PasswordChanged -= OnOpenVpnPasswordFieldChanged;
|
||||
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.PasswordChanged -= OnViewModelPasswordChanged;
|
||||
vm.OpenVpnPasswordChanged -= OnViewModelOpenVpnPasswordChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPasswordFieldChanged(object sender, RoutedEventArgs e)
|
||||
@@ -55,6 +62,12 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenVpnPasswordFieldChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm && vm.OpenVpnPassword != OpenVpnPasswordField.Password)
|
||||
vm.OpenVpnPassword = OpenVpnPasswordField.Password;
|
||||
}
|
||||
|
||||
private void OnViewModelPasswordChanged(string password, string psk)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
@@ -64,6 +77,11 @@ public partial class ConnectionTabView : System.Windows.Controls.UserControl
|
||||
});
|
||||
}
|
||||
|
||||
private void OnViewModelOpenVpnPasswordChanged(string password)
|
||||
{
|
||||
Dispatcher.Invoke(() => OpenVpnPasswordField.Password = password);
|
||||
}
|
||||
|
||||
private void OnProfileNameChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
<StackPanel>
|
||||
<TextBlock Text="۱. پروفایل" FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="نوع اتصال را انتخاب کنید و کانفیگ L2TP یا V2Ray/Xray را وارد کنید."
|
||||
<TextBlock Text="نوع اتصال را انتخاب کنید: L2TP، V2Ray/Xray یا اوپنویپیان. برای اسپلیت اوپنویپیان، نسخه Community لازم است؛ فایل .ovpn را انتخاب کنید و نام کاربری/رمز را در TunnelX وارد کنید."
|
||||
FontSize="10" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,5,0,0"/>
|
||||
</StackPanel>
|
||||
@@ -196,6 +196,45 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Connection Types -->
|
||||
<Border Style="{StaticResource CardPanel}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="راهنمای نوع اتصال"/>
|
||||
<TextBlock Text="L2TP/IPsec"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="آدرس سرور، نام کاربری، رمز عبور و Pre-Shared Key را وارد کنید؛ TunnelX اتصال ویندوز را ایجاد و مسیرها را مدیریت میکند."
|
||||
FontSize="11"
|
||||
LineHeight="18"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
Margin="0,4,0,10"/>
|
||||
|
||||
<TextBlock Text="V2Ray / Xray"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="لینک یا کانفیگ V2Ray/Xray را در فیلد کانفیگ وارد کنید. TunnelX با sing-box/Xray تونل را بالا میآورد و برنامههای انتخابی را از آن عبور میدهد."
|
||||
FontSize="11"
|
||||
LineHeight="18"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
Margin="0,4,0,10"/>
|
||||
|
||||
<TextBlock Text="OpenVPN"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock Text="TunnelX فایل اوپنویپیان را همراه خود توزیع نمیکند. برای اسپلیتتانلینگ باید نسخه Community نصب باشد، چون TunnelX باید فایل openvpn.exe را با کانفیگ سازگار با اسپلیت اجرا کند. نسخه Connect معمولاً مسیر و DNS را خودش تغییر میدهد و برای جدا کردن ترافیک برنامهها مناسب نیست. در تب اتصال فایل .ovpn را انتخاب کنید و اگر سرور نیاز دارد، نام کاربری و رمز را در TunnelX وارد کنید."
|
||||
FontSize="11"
|
||||
LineHeight="18"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Modes -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.2.26 - 2026-05-17
|
||||
|
||||
- Added OpenVPN Community support as an external tunnel provider for split tunneling.
|
||||
- Added `.ovpn` file selection, OpenVPN username/password fields, install detection, and clearer Persian guidance in the connection and help screens.
|
||||
- Added split-compatible OpenVPN config preparation with route/DNS push filtering, credential file handling without UTF-8 BOM, remote candidate filtering, and faster retry behavior.
|
||||
- Fixed OpenVPN split routing by capturing the real connected remote, assigned tunnel IP, and route gateway before starting packet routing.
|
||||
- Added OpenVPN stale-process cleanup for TunnelX-started OpenVPN processes and prevented stale TAP adapters from being treated as a fresh connection.
|
||||
- Improved server testing and post-connect ping behavior for OpenVPN profiles.
|
||||
|
||||
## 1.2.24 - 2026-05-12
|
||||
|
||||
- Added README screenshots in English and Persian.
|
||||
|
||||
@@ -17,10 +17,17 @@
|
||||
- <span dir="ltr">Split tunneling</span> بر اساس برنامههای انتخابشده در ویندوز
|
||||
- حالت <span dir="ltr">Full-route</span> برای تونل کردن کل سیستم
|
||||
- پشتیبانی از جریانهای <span dir="ltr">V2Ray</span> بر پایه <span dir="ltr">Xray-core</span> و <span dir="ltr">sing-box</span>
|
||||
- پشتیبانی از <span dir="ltr">OpenVPN Community</span> با فایلهای <span dir="ltr">`.ovpn`</span> برای <span dir="ltr">Split tunneling</span> برنامههای انتخابشده
|
||||
- پروکسی <span dir="ltr">SOCKS5</span> محلی روی <span dir="ltr">`127.0.0.1`</span> برای ابزارهایی که تنظیم پروکسی داخلی دارند
|
||||
- تغییر مسیر <span dir="ltr">DNS</span>، مسدودسازی <span dir="ltr">IPv6</span>، محافظ نشت، عیبیابی <span dir="ltr">route</span> و تاریخچه مصرف تونل
|
||||
- رابط کاربری فارسیمحور برای ویندوز
|
||||
|
||||
## پشتیبانی از <span dir="ltr">OpenVPN</span>
|
||||
|
||||
<span dir="ltr">TunnelX</span> میتواند نسخه نصبشده <span dir="ltr">OpenVPN Community</span> و فایل انتخابی <span dir="ltr">`.ovpn`</span> کاربر را اجرا کند و سپس سیاست <span dir="ltr">Split tunneling</span> خودش را اعمال کند؛ یعنی فقط برنامهها و مقصدهای انتخابشده از تونل <span dir="ltr">OpenVPN</span> عبور میکنند.
|
||||
|
||||
<span dir="ltr">OpenVPN</span> همراه <span dir="ltr">TunnelX</span> توزیع نمیشود. برای این حالت باید <span dir="ltr">OpenVPN Community</span> را جداگانه نصب کنید، فایل <span dir="ltr">`.ovpn`</span> را در <span dir="ltr">TunnelX</span> انتخاب کنید و در صورت نیاز نام کاربری و رمز عبور <span dir="ltr">OpenVPN</span> را داخل برنامه وارد کنید. نصب بودن <span dir="ltr">OpenVPN Connect</span> بهتنهایی برای این حالت کافی نیست، چون آن برنامه مسیرها و <span dir="ltr">DNS</span> را با کلاینت خودش مدیریت میکند.
|
||||
|
||||
## تصاویر برنامه
|
||||
|
||||
| داشبورد اتصال | تنظیم پروفایل و سرور |
|
||||
|
||||
@@ -11,10 +11,17 @@ TunnelX is a free and open-source Windows split-tunneling client built by **MaxF
|
||||
- App-based split tunneling for selected Windows processes
|
||||
- Full-route mode for whole-system tunneling
|
||||
- Xray-core / sing-box based V2Ray workflows
|
||||
- OpenVPN Community support via user-provided `.ovpn` files for app-based split tunneling
|
||||
- Local SOCKS5 proxy for tools that need `127.0.0.1`
|
||||
- DNS redirect, IPv6 blocking, leak guard, route diagnostics, and traffic history
|
||||
- Persian-first Windows desktop UI
|
||||
|
||||
## OpenVPN
|
||||
|
||||
TunnelX can run an installed **OpenVPN Community** `openvpn.exe` with a user-selected `.ovpn` profile, then apply its own split-tunneling policy so only selected apps and included destinations use the OpenVPN tunnel.
|
||||
|
||||
OpenVPN is not bundled with TunnelX. Install OpenVPN Community separately, select the `.ovpn` file in TunnelX, and enter the OpenVPN username/password if the server requires credentials. OpenVPN Connect alone is not enough for this mode because it manages routes and DNS through its own client.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Connection dashboard | Profile and server setup |
|
||||
|
||||
Reference in New Issue
Block a user