diff --git a/AppTunnel/Models/ConnectionProfile.cs b/AppTunnel/Models/ConnectionProfile.cs index 70e0124..d90fd85 100644 --- a/AppTunnel/Models/ConnectionProfile.cs +++ b/AppTunnel/Models/ConnectionProfile.cs @@ -28,7 +28,7 @@ public class ConnectionProfile : INotifyPropertyChanged private List _excludedDestinations = new(); private TunnelType _tunnelType = TunnelType.L2tpIpsec; private string _v2RayConfig = ""; - private int _socks5Port = 1080; + private int _mixedProxyPort = 1080; private bool _autoTuneMtu = true; private bool _enableDnsOptimization = true; private bool _enableGameMode = false; @@ -110,10 +110,11 @@ public class ConnectionProfile : INotifyPropertyChanged set => SetField(ref _v2RayConfig, value); } - public int Socks5Port + [JsonPropertyName("socks5Port")] + public int MixedProxyPort { - get => _socks5Port; - set => SetField(ref _socks5Port, value); + get => _mixedProxyPort; + set => SetField(ref _mixedProxyPort, value); } public bool AutoTuneMtu diff --git a/AppTunnel/Models/ServerConfig.cs b/AppTunnel/Models/ServerConfig.cs index 1460f2b..cfed9ee 100644 --- a/AppTunnel/Models/ServerConfig.cs +++ b/AppTunnel/Models/ServerConfig.cs @@ -1,7 +1,7 @@ namespace AppTunnel.Models; /// -/// L2TP/IPsec server connection configuration. +/// Server connection configuration (L2TP/IPsec, V2Ray). /// public class ServerConfig { diff --git a/AppTunnel/Services/MixedProxyServer.cs b/AppTunnel/Services/MixedProxyServer.cs new file mode 100644 index 0000000..d4b0a80 --- /dev/null +++ b/AppTunnel/Services/MixedProxyServer.cs @@ -0,0 +1,480 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace AppTunnel.Services; + +/// +/// Mixed SOCKS5/HTTP CONNECT proxy that forces every outgoing connection to +/// use the VPN adapter by binding the outbound socket to the VPN local IP. +/// Protocol auto-detection on the first byte: 0x05 → SOCKS5, 'C' → HTTP CONNECT. +/// +internal sealed class MixedProxyServer +{ + private readonly int _listenPort; + private TcpListener? _listener; + private CancellationTokenSource? _cts; + private IPEndPoint? _bindEndpoint; + private long _connCount; + private long _connActive; + private Action? _ensureRoute; + + public MixedProxyServer(int listenPort = 1080) + { + _listenPort = listenPort; + } + + public bool IsRunning => _listener != null; + public long TotalConnections => Interlocked.Read(ref _connCount); + public long ActiveConnections => Interlocked.Read(ref _connActive); + + public void Start(string vpnLocalIp, Action? ensureRoute = null) + { + if (_listener != null) return; + if (!IPAddress.TryParse(vpnLocalIp, out var bindIp)) + { + Logger.Error($"[MIXED] Invalid VPN local IP '{vpnLocalIp}', server not started"); + return; + } + _bindEndpoint = new IPEndPoint(bindIp, 0); + _ensureRoute = ensureRoute; + + try + { + _listener = new TcpListener(IPAddress.Loopback, _listenPort); + _listener.Start(); + _cts = new CancellationTokenSource(); + Logger.Info($"[MIXED] Listening on 127.0.0.1:{_listenPort}, outbound bind={vpnLocalIp}"); + _ = Task.Run(() => AcceptLoop(_cts.Token)); + } + catch (Exception ex) + { + Logger.Error($"[MIXED] Failed to start listener on port {_listenPort}: {ex.Message}"); + _listener = null; + } + } + + public void Stop() + { + try { _cts?.Cancel(); } catch { } + try { _listener?.Stop(); } catch { } + _listener = null; + _cts = null; + } + + private async Task AcceptLoop(CancellationToken ct) + { + var listener = _listener!; + while (!ct.IsCancellationRequested) + { + TcpClient client; + try + { + client = await listener.AcceptTcpClientAsync(ct); + } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (Exception ex) + { + Logger.Warning($"[MIXED] Accept error: {ex.Message}"); + continue; + } + _ = Task.Run(() => HandleClientAsync(client, ct)); + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken ct) + { + long connId = Interlocked.Increment(ref _connCount); + Interlocked.Increment(ref _connActive); + try + { + client.NoDelay = true; + using var stream = client.GetStream(); + stream.ReadTimeout = 15000; + stream.WriteTimeout = 15000; + + // Peek first byte to decide protocol + var firstByte = new byte[1]; + int peeked = await stream.ReadAsync(firstByte.AsMemory(0, 1), ct); + if (peeked == 0) return; + + if (firstByte[0] == 0x05) + { + await HandleSocks5Async(stream, firstByte, connId, ct); + } + else if (firstByte[0] == (byte)'C' || firstByte[0] == (byte)'c') + { + await HandleHttpConnectAsync(stream, firstByte, connId, ct); + } + else + { + Logger.Warning($"[MIXED #{connId}] Unknown first byte 0x{firstByte[0]:X2}, closing"); + try { client.Close(); } catch { } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Logger.Warning($"[MIXED #{connId}] error: {ex.Message}"); + } + finally + { + try { client.Dispose(); } catch { } + Interlocked.Decrement(ref _connActive); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // SOCKS5 handler (existing logic preserved) + // ───────────────────────────────────────────────────────────────────────── + private async Task HandleSocks5Async(NetworkStream stream, byte[] firstByte, long connId, CancellationToken ct) + { + TcpClient? upstream = null; + try + { + // We already read the first byte (0x05). Now read NMETHODS + methods. + var hdr = new byte[1]; + if (!await ReadExactAsync(stream, hdr, 0, 1, ct)) return; + int nMethods = hdr[0]; + if (nMethods <= 0 || nMethods > 32) return; + var methods = new byte[nMethods]; + if (!await ReadExactAsync(stream, methods, 0, nMethods, ct)) return; + + // No-auth only + if (!methods.Contains((byte)0x00)) + { + await stream.WriteAsync(new byte[] { 0x05, 0xFF }, ct); + return; + } + await stream.WriteAsync(new byte[] { 0x05, 0x00 }, ct); + + // Request + var req = new byte[4]; + if (!await ReadExactAsync(stream, req, 0, 4, ct)) return; + if (req[0] != 0x05 || req[1] != 0x01) + { + await WriteSocks5ReplyAsync(stream, 0x07, ct); + return; + } + + byte atyp = req[3]; + string host; + IPAddress? remoteIp = null; + switch (atyp) + { + case 0x01: + { + var addr = new byte[4]; + if (!await ReadExactAsync(stream, addr, 0, 4, ct)) return; + remoteIp = new IPAddress(addr); + host = remoteIp.ToString(); + break; + } + case 0x03: + { + var lenBuf = new byte[1]; + if (!await ReadExactAsync(stream, lenBuf, 0, 1, ct)) return; + int dlen = lenBuf[0]; + var dbuf = new byte[dlen]; + if (!await ReadExactAsync(stream, dbuf, 0, dlen, ct)) return; + host = Encoding.ASCII.GetString(dbuf); + break; + } + case 0x04: + { + var addr = new byte[16]; + if (!await ReadExactAsync(stream, addr, 0, 16, ct)) return; + remoteIp = new IPAddress(addr); + host = remoteIp.ToString(); + break; + } + default: + await WriteSocks5ReplyAsync(stream, 0x08, ct); + return; + } + + var portBuf = new byte[2]; + if (!await ReadExactAsync(stream, portBuf, 0, 2, ct)) return; + int port = (portBuf[0] << 8) | portBuf[1]; + + if (remoteIp == null) + { + try + { + remoteIp = await DnsResolverCache.ResolveFirstIpv4Async(host, ct); + if (remoteIp == null) + { + Logger.Warning($"[SOCKS5 #{connId}] DNS for '{host}' returned no IPv4"); + await WriteSocks5ReplyAsync(stream, 0x04, ct); + return; + } + } + catch (Exception dnsEx) + { + Logger.Warning($"[SOCKS5 #{connId}] DNS resolve '{host}' failed: {dnsEx.Message}"); + await WriteSocks5ReplyAsync(stream, 0x04, ct); + return; + } + } + + try { _ensureRoute?.Invoke(remoteIp); } catch { } + + upstream = await DialUpstreamAsync(remoteIp, port, connId, ct); + if (upstream == null) + { + await WriteSocks5ReplyAsync(stream, 0x05, ct); + return; + } + + Logger.Info($"[SOCKS5 #{connId}] CONNECT → {host}:{port} (via {_bindEndpoint!.Address})"); + await WriteSocks5ReplyAsync(stream, 0x00, ct); + await RelayAsync(stream, upstream.GetStream(), ct); + } + finally + { + try { upstream?.Dispose(); } catch { } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // HTTP CONNECT handler + // ───────────────────────────────────────────────────────────────────────── + private async Task HandleHttpConnectAsync(NetworkStream stream, byte[] firstByte, long connId, CancellationToken ct) + { + TcpClient? upstream = null; + try + { + // firstByte[0] is 'C' or 'c'. Read the rest of the first line. + var sb = new StringBuilder(Encoding.ASCII.GetString(firstByte)); + var lineBuf = new byte[1]; + // Read until \r\n + while (!ct.IsCancellationRequested) + { + int n = await stream.ReadAsync(lineBuf.AsMemory(0, 1), ct); + if (n == 0) return; + char c = (char)lineBuf[0]; + if (c == '\n') + { + string line = sb.ToString().TrimEnd('\r'); + if (string.IsNullOrEmpty(line)) continue; // skip empty lines before request + break; + } + sb.Append(c); + if (sb.Length > 4096) return; // line too long + } + + string firstLine = sb.ToString().TrimEnd('\r'); + var parts = firstLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !parts[0].Equals("CONNECT", StringComparison.OrdinalIgnoreCase)) + { + await WriteHttpErrorAsync(stream, "400 Bad Request", ct); + return; + } + var target = parts[1]; + if (!parts[2].StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase)) + { + await WriteHttpErrorAsync(stream, "400 Bad Request", ct); + return; + } + + // Read remaining headers until empty line + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + var headerSb = new StringBuilder(); + int emptyLineCount = 0; + while (!ct.IsCancellationRequested) + { + int n = await stream.ReadAsync(lineBuf.AsMemory(0, 1), ct); + if (n == 0) return; + char c = (char)lineBuf[0]; + if (c == '\n') + { + string hline = headerSb.ToString().TrimEnd('\r'); + if (string.IsNullOrEmpty(hline)) + { + emptyLineCount++; + if (emptyLineCount >= 1) break; + } + else + { + emptyLineCount = 0; + var colonIdx = hline.IndexOf(':'); + if (colonIdx > 0) + { + var key = hline[..colonIdx].Trim(); + var val = hline[(colonIdx + 1)..].Trim(); + headers[key] = val; + } + } + headerSb.Clear(); + if (headerSb.Length > 8192) return; // headers too long + } + else + { + headerSb.Append(c); + } + } + + // Parse target host:port + string host; + int port; + var targetParts = target.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (targetParts.Length == 2 && int.TryParse(targetParts[1], out port)) + { + host = targetParts[0]; + } + else + { + host = target; + port = 443; + } + + IPAddress? remoteIp = null; + if (IPAddress.TryParse(host, out var parsedIp)) + { + remoteIp = parsedIp; + } + else + { + try + { + remoteIp = await DnsResolverCache.ResolveFirstIpv4Async(host, ct); + if (remoteIp == null) + { + Logger.Warning($"[HTTP #{connId}] DNS for '{host}' returned no IPv4"); + await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct); + return; + } + } + catch (Exception dnsEx) + { + Logger.Warning($"[HTTP #{connId}] DNS resolve '{host}' failed: {dnsEx.Message}"); + await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct); + return; + } + } + + try { _ensureRoute?.Invoke(remoteIp); } catch { } + + upstream = await DialUpstreamAsync(remoteIp, port, connId, ct); + if (upstream == null) + { + await WriteHttpErrorAsync(stream, "502 Bad Gateway", ct); + return; + } + + Logger.Info($"[HTTP #{connId}] CONNECT → {host}:{port} (via {_bindEndpoint!.Address})"); + await WriteHttpAsync(stream, "HTTP/1.1 200 Connection established\r\n\r\n", ct); + await RelayAsync(stream, upstream.GetStream(), ct); + } + finally + { + try { upstream?.Dispose(); } catch { } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Shared helpers + // ───────────────────────────────────────────────────────────────────────── + private async Task DialUpstreamAsync(IPAddress remoteIp, int port, long connId, CancellationToken ct) + { + const int maxAttempts = 2; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + var client = new TcpClient(AddressFamily.InterNetwork); + client.NoDelay = true; + try + { + client.Client.Bind(_bindEndpoint!); + } + catch (Exception bex) + { + Logger.Warning($"[MIXED #{connId}] bind to VPN IP failed: {bex.Message}"); + client.Dispose(); + return null; + } + + try + { + using var dialCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + dialCts.CancelAfter(TimeSpan.FromSeconds(15)); + await client.ConnectAsync(remoteIp, port, dialCts.Token); + return client; + } + catch (Exception dex) when ( + attempt < maxAttempts && + dex is SocketException sex && + (sex.SocketErrorCode == SocketError.NetworkUnreachable || + sex.SocketErrorCode == SocketError.HostUnreachable)) + { + Logger.Info($"[MIXED #{connId}] connect {remoteIp}:{port} unreachable, retrying..."); + client.Dispose(); + await Task.Delay(600, ct); + } + catch (Exception dex) + { + Logger.Warning($"[MIXED #{connId}] connect {remoteIp}:{port} failed: {dex.Message}"); + client.Dispose(); + return null; + } + } + return null; + } + + private static async Task RelayAsync(NetworkStream clientStream, NetworkStream upstreamStream, CancellationToken ct) + { + var t1 = PumpAsync(clientStream, upstreamStream, ct); + var t2 = PumpAsync(upstreamStream, clientStream, ct); + await Task.WhenAny(t1, t2); + try { clientStream.Socket?.Shutdown(SocketShutdown.Both); } catch { } + try { upstreamStream.Socket?.Shutdown(SocketShutdown.Both); } catch { } + await Task.WhenAll(t1, t2); + } + + private static async Task WriteSocks5ReplyAsync(NetworkStream s, byte rep, CancellationToken ct) + { + var buf = new byte[] { 0x05, rep, 0x00, 0x01, 0, 0, 0, 0, 0, 0 }; + try { await s.WriteAsync(buf, ct); } catch { } + } + + private static async Task WriteHttpErrorAsync(NetworkStream s, string status, CancellationToken ct, string extraHeaders = "") + { + var msg = $"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n{extraHeaders}\r\n"; + try { await s.WriteAsync(Encoding.UTF8.GetBytes(msg), ct); } catch { } + } + + private static async Task WriteHttpAsync(NetworkStream s, string text, CancellationToken ct) + { + try { await s.WriteAsync(Encoding.UTF8.GetBytes(text), ct); } catch { } + } + + private static async Task ReadExactAsync(NetworkStream s, byte[] buf, int offset, int count, CancellationToken ct) + { + int read = 0; + while (read < count) + { + int n = await s.ReadAsync(buf.AsMemory(offset + read, count - read), ct); + if (n <= 0) return false; + read += n; + } + return true; + } + + private static async Task PumpAsync(NetworkStream src, NetworkStream dst, CancellationToken ct) + { + var buf = new byte[16384]; + try + { + while (!ct.IsCancellationRequested) + { + int n = await src.ReadAsync(buf, ct); + if (n <= 0) break; + await dst.WriteAsync(buf.AsMemory(0, n), ct); + } + } + catch { } + } +} diff --git a/AppTunnel/Services/ProfileService.cs b/AppTunnel/Services/ProfileService.cs index 1c146ce..bd02987 100644 --- a/AppTunnel/Services/ProfileService.cs +++ b/AppTunnel/Services/ProfileService.cs @@ -2,6 +2,7 @@ using System.IO; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using AppTunnel.Models; namespace AppTunnel.Services; @@ -117,7 +118,7 @@ public class ProfileService PreSharedKey = DecryptString(s.EncryptedPsk), TunnelType = s.TunnelType, V2RayConfig = s.V2RayConfig, - Socks5Port = s.Socks5Port > 0 ? s.Socks5Port : 1080, + MixedProxyPort = s.Socks5Port > 0 ? s.Socks5Port : 1080, AutoTuneMtu = s.AutoTuneMtu, EnableDnsOptimization = s.EnableDnsOptimization, EnableGameMode = s.EnableGameMode @@ -148,7 +149,7 @@ public class ProfileService EncryptedPsk = EncryptString(p.PreSharedKey), TunnelType = p.TunnelType, V2RayConfig = p.V2RayConfig, - Socks5Port = p.Socks5Port, + Socks5Port = p.MixedProxyPort, AutoTuneMtu = p.AutoTuneMtu, EnableDnsOptimization = p.EnableDnsOptimization, EnableGameMode = p.EnableGameMode @@ -205,6 +206,7 @@ public class ProfileService public string EncryptedPsk { get; set; } = ""; public TunnelType TunnelType { get; set; } = TunnelType.L2tpIpsec; public string V2RayConfig { get; set; } = ""; + [JsonPropertyName("socks5Port")] public int Socks5Port { get; set; } = 1080; public bool AutoTuneMtu { get; set; } = true; public bool EnableDnsOptimization { get; set; } = true; diff --git a/AppTunnel/Services/ProxyUrlParser.cs b/AppTunnel/Services/ProxyUrlParser.cs new file mode 100644 index 0000000..d4cc529 --- /dev/null +++ b/AppTunnel/Services/ProxyUrlParser.cs @@ -0,0 +1,71 @@ +using System; +using System.Net; + +namespace AppTunnel.Services; + +/// +/// Parses proxy URLs like socks5://user:pass@host:port or http://host:port. +/// +public record ParsedProxyUrl( + string Scheme, + string Host, + int Port, + string? Username, + string? Password); + +public static class ProxyUrlParser +{ + public static ParsedProxyUrl? Parse(string url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + + try + { + var uri = new Uri(url); + var scheme = uri.Scheme.ToLowerInvariant(); + if (scheme != "socks5" && scheme != "http") return null; + + var host = uri.Host; + var port = uri.Port > 0 ? uri.Port : (scheme == "http" ? 8080 : 1080); + + string? user = null, pass = null; + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var parts = uri.UserInfo.Split(':', 2); + user = Uri.UnescapeDataString(parts[0]); + if (parts.Length == 2) + pass = Uri.UnescapeDataString(parts[1]); + } + + return new ParsedProxyUrl(scheme, host, port, user, pass); + } + catch + { + return null; + } + } + + /// + /// Strips the [::ffff:] prefix and port from an IPEndPoint string, returning the IPv4 address. + /// + public static string? ExtractIPv4(string? endPoint) + { + if (string.IsNullOrWhiteSpace(endPoint)) return null; + var s = endPoint.Trim(); + // Strip [::ffff:] prefix + const string v6Prefix = "[::ffff:"; + if (s.StartsWith(v6Prefix, StringComparison.OrdinalIgnoreCase)) + { + // [::ffff:1.2.3.4]:port → 1.2.3.4 + var inner = s[v6Prefix.Length..]; + var close = inner.IndexOf(']'); + if (close < 0) return null; + inner = inner[..close]; + return IPAddress.TryParse(inner, out _) ? inner : null; + } + // Plain "1.2.3.4:port" or just "1.2.3.4" + var colon = s.IndexOf(':'); + if (colon > 0) s = s[..colon]; + return IPAddress.TryParse(s, out _) ? s : null; + } +} diff --git a/AppTunnel/Services/TrafficRouterService.Core.cs b/AppTunnel/Services/TrafficRouterService.Core.cs index 69c68e9..d854e24 100644 --- a/AppTunnel/Services/TrafficRouterService.Core.cs +++ b/AppTunnel/Services/TrafficRouterService.Core.cs @@ -7,14 +7,6 @@ namespace AppTunnel.Services; /// /// Routes traffic from selected applications through the VPN tunnel using WinDivert. -/// -/// How it works: -/// 1. WinDivert captures all outbound TCP/UDP packets -/// 2. For each packet, we look up the owning process via GetExtendedTcpTable/GetExtendedUdpTable -/// 3. If the process is in our target list, we set the outbound interface to the VPN adapter -/// 4. The packet is re-injected with the modified interface -/// -/// This approach is used by many commercial VPN apps for split tunneling. /// public partial class TrafficRouterService : IDisposable { @@ -155,7 +147,7 @@ public partial class TrafficRouterService : IDisposable /// public bool EnableSocks5 { get; set; } = true; - /// Listener port for the built-in SOCKS5 proxy. + /// Listener port for the built-in mixed proxy (SOCKS5 + HTTP). public int Socks5Port { get; set; } = 1080; /// @@ -184,7 +176,7 @@ public partial class TrafficRouterService : IDisposable private uint _dnsRedirectIpNbo = BitConverter.ToUInt32(new byte[] { 8, 8, 8, 8 }, 0); private byte[] _dnsRedirectIpBytes = new byte[] { 8, 8, 8, 8 }; - private Socks5Server? _socks5; + private MixedProxyServer? _mixedProxy; #pragma warning disable CS0067 public event Action? TrafficUpdated; @@ -515,13 +507,11 @@ public partial class TrafficRouterService : IDisposable _networkOutTask = Task.Run(() => NetworkOutboundLoop(_cts.Token)); _networkInTask = Task.Run(() => NetworkInboundLoop(_cts.Token)); - // Optional SOCKS5 proxy: apps with native SOCKS5 support (Telegram, - // Firefox, ...) can point at 127.0.0.1:1080 and get guaranteed VPN - // egress without relying on host routes. + // Optional mixed SOCKS5/HTTP proxy if (EnableSocks5) { - _socks5 = new Socks5Server(Socks5Port); - _socks5.Start(vpnLocalIp, EnsureHostRouteForSocks5); + _mixedProxy = new MixedProxyServer(Socks5Port); + _mixedProxy.Start(vpnLocalIp, EnsureHostRouteForSocks5); } } @@ -760,8 +750,8 @@ public partial class TrafficRouterService : IDisposable _statsTimer?.Dispose(); _statsTimer = null; - try { _socks5?.Stop(); } catch { } - _socks5 = null; + try { _mixedProxy?.Stop(); } catch { } + _mixedProxy = null; // Cancel all pending delayed route removals. foreach (var kvp in _pendingRouteRemoval) diff --git a/AppTunnel/Services/V2RayTunnelProvider.cs b/AppTunnel/Services/V2RayTunnelProvider.cs index 3e485dd..d202dfd 100644 --- a/AppTunnel/Services/V2RayTunnelProvider.cs +++ b/AppTunnel/Services/V2RayTunnelProvider.cs @@ -397,6 +397,15 @@ public class V2RayTunnelProvider : ITunnelProvider { (outbound, outboundTag) = ParseShadowsocks(userConfig); } + else if (userConfig.StartsWith("socks5://") || + userConfig.StartsWith("socks://")) + { + (outbound, outboundTag) = ParseSocks5(userConfig); + } + else if (userConfig.StartsWith("http://")) + { + (outbound, outboundTag) = ParseHttp(userConfig); + } else { throw new InvalidOperationException( @@ -748,6 +757,75 @@ public class V2RayTunnelProvider : ITunnelProvider return (outbound, tag); } + private static (JsonObject outbound, string tag) ParseSocks5(string uri) + { + var u = new Uri(uri); + var tag = Uri.UnescapeDataString(u.Fragment.TrimStart('#').Trim()); + if (string.IsNullOrEmpty(tag)) tag = "socks5-out"; + + var outbound = new JsonObject + { + ["type"] = "socks", + ["tag"] = tag, + ["server"] = u.Host, + ["server_port"] = u.Port > 0 ? u.Port : 1080, + ["version"] = "5" + }; + + if (!string.IsNullOrEmpty(u.UserInfo)) + { + var userInfo = Uri.UnescapeDataString(u.UserInfo); + var colonIdx = userInfo.IndexOf(':'); + if (colonIdx >= 0) + { + outbound["users"] = new JsonArray + { + new JsonObject + { + ["username"] = userInfo[..colonIdx], + ["password"] = userInfo[(colonIdx + 1)..] + } + }; + } + } + + return (outbound, tag); + } + + private static (JsonObject outbound, string tag) ParseHttp(string uri) + { + var u = new Uri(uri); + var tag = Uri.UnescapeDataString(u.Fragment.TrimStart('#').Trim()); + if (string.IsNullOrEmpty(tag)) tag = "http-out"; + + var outbound = new JsonObject + { + ["type"] = "http", + ["tag"] = tag, + ["server"] = u.Host, + ["server_port"] = u.Port > 0 ? u.Port : 3128 + }; + + if (!string.IsNullOrEmpty(u.UserInfo)) + { + var userInfo = Uri.UnescapeDataString(u.UserInfo); + var colonIdx = userInfo.IndexOf(':'); + if (colonIdx >= 0) + { + outbound["users"] = new JsonArray + { + new JsonObject + { + ["username"] = userInfo[..colonIdx], + ["password"] = userInfo[(colonIdx + 1)..] + } + }; + } + } + + return (outbound, tag); + } + // ========================================================================= // Helpers // ========================================================================= diff --git a/AppTunnel/Services/VpnService.cs b/AppTunnel/Services/VpnService.cs index c47fe9c..63d35e7 100644 --- a/AppTunnel/Services/VpnService.cs +++ b/AppTunnel/Services/VpnService.cs @@ -11,10 +11,6 @@ public class VpnService private ITunnelProvider? _activeProvider; private readonly ConnectionStatus _defaultStatus = new(); - /// - /// Invoked when the active V2Ray tunnel collapses unexpectedly. - /// Set this before calling ConnectAsync; it is forwarded to V2RayTunnelProvider. - /// public Action? OnTunnelFailed { get; set; } /// Live status, forwarded from the active provider. @@ -42,9 +38,5 @@ public class VpnService await _activeProvider.DisconnectAsync(); } - /// - /// Returns true when the active provider's network interface is still operational. - /// Mirrors the health-check used by the connection monitor. - /// public bool IsInterfaceUp() => _activeProvider?.IsInterfaceUp() ?? false; } diff --git a/AppTunnel/ViewModels/MainViewModel.Connection.cs b/AppTunnel/ViewModels/MainViewModel.Connection.cs index b5b2686..b02d16d 100644 --- a/AppTunnel/ViewModels/MainViewModel.Connection.cs +++ b/AppTunnel/ViewModels/MainViewModel.Connection.cs @@ -54,10 +54,10 @@ public partial class MainViewModel StatusText = "کانفیگ V2Ray را وارد کنید"; return; } - if (!ValidateSocks5Port(out var socksError)) + if (!ValidateMixedProxyPort(out var socksError)) { StatusText = socksError; - Socks5PortStatusText = socksError; + MixedProxyPortStatusText = socksError; return; } @@ -83,6 +83,8 @@ public partial class MainViewModel Username = Username.Trim(), Password = Password, PreSharedKey = PreSharedKey, + TunnelType = _currentTunnelType, + V2RayConfig = _selectedV2RayConfig, AutoTuneMtu = AutoTuneMtu, EnableDnsOptimization = IsDnsOptimizationEnabled, EnableGameMode = IsGameModeEnabled @@ -129,7 +131,7 @@ public partial class MainViewModel // Load user's exclude list (domains/IPs to bypass tunnel) _trafficRouter.SetExcludedDestinations(ExcludedDestinations); _trafficRouter.SetIncludedDestinations(IncludedDestinations); - _trafficRouter.Socks5Port = Socks5Port; + _trafficRouter.Socks5Port = MixedProxyPort; _trafficRouter.EnableDnsOptimization = IsDnsOptimizationEnabled; _trafficRouter.EnableGameMode = IsGameModeEnabled; @@ -377,10 +379,10 @@ public partial class MainViewModel return; } - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var ms = await MeasureEndpointLatencyAsync(endpoint, cts.Token); + using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var ms2 = await MeasureEndpointLatencyAsync(endpoint, cts2.Token); var mode = endpoint.UseTls ? "TLS handshake" : "TCP connect"; - ServerPingResult = $"{mode} {endpoint.Server}:{endpoint.Port} {ms} ms"; + ServerPingResult = $"{mode} {endpoint.Server}:{endpoint.Port} {ms2} ms"; } catch (OperationCanceledException) { @@ -481,6 +483,16 @@ public partial class MainViewModel return true; } + // SOCKS5 / HTTP proxy URIs + if (config.StartsWith("socks5://", StringComparison.OrdinalIgnoreCase) || + config.StartsWith("socks://", StringComparison.OrdinalIgnoreCase) || + config.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + var uri = new Uri(config.Split('#')[0]); + endpoint = new ProxyEndpoint(uri.Host, uri.Port > 0 ? uri.Port : 1080, false, null); + return ValidateEndpoint(endpoint.Server, endpoint.Port, out error); + } + if (config.StartsWith("{")) { var root = JsonNode.Parse(config)?.AsObject(); diff --git a/AppTunnel/ViewModels/MainViewModel.Core.cs b/AppTunnel/ViewModels/MainViewModel.Core.cs index 961e621..baed8a3 100644 --- a/AppTunnel/ViewModels/MainViewModel.Core.cs +++ b/AppTunnel/ViewModels/MainViewModel.Core.cs @@ -142,55 +142,55 @@ public partial class MainViewModel : INotifyPropertyChanged set { _preSharedKey = value; OnPropertyChanged(); } } - private int _socks5Port = 1080; - public int Socks5Port + private int _mixedProxyPort = 1080; + public int MixedProxyPort { - get => _socks5Port; + get => _mixedProxyPort; set { var normalized = value; - if (_socks5Port == normalized) return; - _socks5Port = normalized; + if (_mixedProxyPort == normalized) return; + _mixedProxyPort = normalized; _trafficRouter.Socks5Port = normalized; OnPropertyChanged(); - OnPropertyChanged(nameof(Socks5PortText)); - OnPropertyChanged(nameof(Socks5Info)); - UpdateSocks5PortStatus(); + OnPropertyChanged(nameof(MixedProxyPortText)); + OnPropertyChanged(nameof(MixedProxyInfo)); + UpdateMixedProxyPortStatus(); SaveCurrentState(); } } - public string Socks5PortText + public string MixedProxyPortText { - get => _socks5Port.ToString(); + get => _mixedProxyPort.ToString(); set { if (int.TryParse((value ?? "").Trim(), out var port)) { - if (_socks5Port != port) + if (_mixedProxyPort != port) { - _socks5Port = port; + _mixedProxyPort = port; _trafficRouter.Socks5Port = port; OnPropertyChanged(); - OnPropertyChanged(nameof(Socks5Port)); - OnPropertyChanged(nameof(Socks5Info)); - UpdateSocks5PortStatus(); + OnPropertyChanged(nameof(MixedProxyPort)); + OnPropertyChanged(nameof(MixedProxyInfo)); + UpdateMixedProxyPortStatus(); SaveCurrentState(); } return; } - Socks5PortStatusText = string.IsNullOrWhiteSpace(value) + MixedProxyPortStatusText = string.IsNullOrWhiteSpace(value) ? "پورت SOCKS5 را وارد کنید" : "فقط عدد مجاز است"; } } - private string _socks5PortStatusText = ""; - public string Socks5PortStatusText + private string _mixedProxyPortStatusText = ""; + public string MixedProxyPortStatusText { - get => _socks5PortStatusText; - set { _socks5PortStatusText = value; OnPropertyChanged(); } + get => _mixedProxyPortStatusText; + set { _mixedProxyPortStatusText = value; OnPropertyChanged(); } } private bool _autoTuneMtu = true; @@ -627,7 +627,7 @@ public partial class MainViewModel : INotifyPropertyChanged public int EnabledAppsCount => TunnelApps.Count(a => a.IsEnabled); - public string Socks5Info => $"127.0.0.1:{_trafficRouter.Socks5Port}"; + public string MixedProxyInfo => $"127.0.0.1:{_trafficRouter.Socks5Port}"; // Exclude list public ObservableCollection ExcludedDestinations { get; } = new(); @@ -845,7 +845,7 @@ public partial class MainViewModel : INotifyPropertyChanged private void UpdateConfigDiagnostics() { - if (CurrentTunnelType != TunnelType.V2Ray) + if (CurrentTunnelType == TunnelType.L2tpIpsec) { ConfigCoreHint = "L2TP/IPsec"; ConfigValidationText = string.IsNullOrWhiteSpace(ServerAddress) @@ -871,9 +871,9 @@ public partial class MainViewModel : INotifyPropertyChanged : error; } - private bool ValidateSocks5Port(out string message) + private bool ValidateMixedProxyPort(out string message) { - var port = _socks5Port; + var port = _mixedProxyPort; if (port < 1024 || port > 65535) { message = "پورت باید بین 1024 تا 65535 باشد"; @@ -910,10 +910,10 @@ public partial class MainViewModel : INotifyPropertyChanged return true; } - private void UpdateSocks5PortStatus() + private void UpdateMixedProxyPortStatus() { - ValidateSocks5Port(out var message); - Socks5PortStatusText = message; + ValidateMixedProxyPort(out var message); + MixedProxyPortStatusText = message; } private void TryAutoNameProfileFromConfig(string config) diff --git a/AppTunnel/ViewModels/MainViewModel.ProfileManagement.cs b/AppTunnel/ViewModels/MainViewModel.ProfileManagement.cs index d1c5daa..24c5310 100644 --- a/AppTunnel/ViewModels/MainViewModel.ProfileManagement.cs +++ b/AppTunnel/ViewModels/MainViewModel.ProfileManagement.cs @@ -111,7 +111,7 @@ public partial class MainViewModel _selectedProfile.PreSharedKey = PreSharedKey; _selectedProfile.TunnelType = _currentTunnelType; _selectedProfile.V2RayConfig = SelectedV2RayConfig; - _selectedProfile.Socks5Port = Socks5Port; + _selectedProfile.MixedProxyPort = MixedProxyPort; _selectedProfile.AutoTuneMtu = AutoTuneMtu; _selectedProfile.EnableDnsOptimization = IsDnsOptimizationEnabled; _selectedProfile.EnableGameMode = IsGameModeEnabled; @@ -145,12 +145,12 @@ public partial class MainViewModel Username = profile.Username; Password = profile.Password; PreSharedKey = profile.PreSharedKey; - _socks5Port = profile.Socks5Port > 0 ? profile.Socks5Port : 1080; - _trafficRouter.Socks5Port = _socks5Port; - OnPropertyChanged(nameof(Socks5Port)); - OnPropertyChanged(nameof(Socks5PortText)); - OnPropertyChanged(nameof(Socks5Info)); - UpdateSocks5PortStatus(); + _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; @@ -174,7 +174,7 @@ public partial class MainViewModel private void CreateNewProfile() { SaveCurrentProfileState(); - var profile = new ConnectionProfile { Name = $"پروفایل {Profiles.Count + 1}", Socks5Port = Socks5Port }; + var profile = new ConnectionProfile { Name = $"پروفایل {Profiles.Count + 1}", MixedProxyPort = MixedProxyPort }; profile.AutoTuneMtu = AutoTuneMtu; profile.EnableDnsOptimization = IsDnsOptimizationEnabled; profile.EnableGameMode = IsGameModeEnabled; @@ -196,7 +196,7 @@ public partial class MainViewModel PreSharedKey = _selectedProfile.PreSharedKey, TunnelType = _selectedProfile.TunnelType, V2RayConfig = _selectedProfile.V2RayConfig, - Socks5Port = _selectedProfile.Socks5Port, + MixedProxyPort = _selectedProfile.MixedProxyPort, AutoTuneMtu = _selectedProfile.AutoTuneMtu, EnableDnsOptimization = _selectedProfile.EnableDnsOptimization, EnableGameMode = _selectedProfile.EnableGameMode, diff --git a/AppTunnel/Views/ConnectionTabView.xaml b/AppTunnel/Views/ConnectionTabView.xaml index 51ab8a7..ab2f25d 100644 --- a/AppTunnel/Views/ConnectionTabView.xaml +++ b/AppTunnel/Views/ConnectionTabView.xaml @@ -202,7 +202,7 @@ - + @@ -328,7 +328,7 @@ - + @@ -357,9 +357,9 @@ - + + ToolTip="پروکسی Mixed داخلی روی 127.0.0.1:1080 — برای برنامه‌هایی که از VPN تشخیص داده نمی‌شوند می‌توانید پروکسی را دستی تنظیم کنید"> @@ -367,11 +367,11 @@ - - diff --git a/AppTunnel/Views/SettingsTabView.xaml b/AppTunnel/Views/SettingsTabView.xaml index bcd1dee..736373f 100644 --- a/AppTunnel/Views/SettingsTabView.xaml +++ b/AppTunnel/Views/SettingsTabView.xaml @@ -19,20 +19,20 @@ + ToolTip="پورت داخلی 127.0.0.1 برای پروکسی SOCKS5 و HTTP"/> +