mirror of
https://github.com/MaxiFan/TunnelX.git
synced 2026-05-17 21:14:37 +03:00
feat(proxy): SOCKS5/HTTP via V2Ray/sing-box, add MixedProxyServer, remove standalone proxy types and local auth
This commit is contained in:
@@ -28,7 +28,7 @@ public class ConnectionProfile : INotifyPropertyChanged
|
||||
private List<string> _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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace AppTunnel.Models;
|
||||
|
||||
/// <summary>
|
||||
/// L2TP/IPsec server connection configuration.
|
||||
/// Server connection configuration (L2TP/IPsec, V2Ray).
|
||||
/// </summary>
|
||||
public class ServerConfig
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IPAddress>? _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<IPAddress>? 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<string, string>(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<TcpClient?> 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<bool> 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 { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -77,7 +78,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
|
||||
@@ -108,7 +109,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
|
||||
@@ -165,6 +166,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;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace AppTunnel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parses proxy URLs like socks5://user:pass@host:port or http://host:port.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the [::ffff:] prefix and port from an IPEndPoint string, returning the IPv4 address.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,6 @@ namespace AppTunnel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public partial class TrafficRouterService : IDisposable
|
||||
{
|
||||
@@ -155,7 +147,7 @@ public partial class TrafficRouterService : IDisposable
|
||||
/// </summary>
|
||||
public bool EnableSocks5 { get; set; } = true;
|
||||
|
||||
/// <summary>Listener port for the built-in SOCKS5 proxy.</summary>
|
||||
/// <summary>Listener port for the built-in mixed proxy (SOCKS5 + HTTP).</summary>
|
||||
public int Socks5Port { get; set; } = 1080;
|
||||
|
||||
/// <summary>
|
||||
@@ -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<string, long, long>? 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)
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -11,10 +11,6 @@ public class VpnService
|
||||
private ITunnelProvider? _activeProvider;
|
||||
private readonly ConnectionStatus _defaultStatus = new();
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the active V2Ray tunnel collapses unexpectedly.
|
||||
/// Set this before calling ConnectAsync; it is forwarded to V2RayTunnelProvider.
|
||||
/// </summary>
|
||||
public Action? OnTunnelFailed { get; set; }
|
||||
|
||||
/// <summary>Live status, forwarded from the active provider.</summary>
|
||||
@@ -42,9 +38,5 @@ public class VpnService
|
||||
await _activeProvider.DisconnectAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the active provider's network interface is still operational.
|
||||
/// Mirrors the health-check used by the connection monitor.
|
||||
/// </summary>
|
||||
public bool IsInterfaceUp() => _activeProvider?.IsInterfaceUp() ?? false;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -128,7 +130,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;
|
||||
|
||||
@@ -376,10 +378,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)
|
||||
{
|
||||
@@ -480,6 +482,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();
|
||||
|
||||
@@ -123,55 +123,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;
|
||||
@@ -568,7 +568,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<string> ExcludedDestinations { get; } = new();
|
||||
@@ -786,7 +786,7 @@ public partial class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
private void UpdateConfigDiagnostics()
|
||||
{
|
||||
if (CurrentTunnelType != TunnelType.V2Ray)
|
||||
if (CurrentTunnelType == TunnelType.L2tpIpsec)
|
||||
{
|
||||
ConfigCoreHint = "L2TP/IPsec";
|
||||
ConfigValidationText = string.IsNullOrWhiteSpace(ServerAddress)
|
||||
@@ -812,9 +812,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 باشد";
|
||||
@@ -851,10 +851,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
</StackPanel>
|
||||
<!-- End V2Ray fields -->
|
||||
|
||||
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,8,0,0">
|
||||
<Border Background="#11FFFFFF" CornerRadius="8" Padding="10,8" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -328,7 +328,7 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Apps + SOCKS5 in one row -->
|
||||
<!-- Apps + Mixed Proxy in one row -->
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -357,9 +357,9 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- SOCKS5 Proxy -->
|
||||
<!-- Mixed Proxy -->
|
||||
<Border Grid.Column="2" Background="#11FFFFFF" CornerRadius="10" Padding="12,10"
|
||||
ToolTip="پروکسی SOCKS5 داخلی روی 127.0.0.1:1080 — برای برنامههایی که از VPN تشخیص داده نمیشوند میتوانید پروکسی را دستی تنظیم کنید">
|
||||
ToolTip="پروکسی Mixed داخلی روی 127.0.0.1:1080 — برای برنامههایی که از VPN تشخیص داده نمیشوند میتوانید پروکسی را دستی تنظیم کنید">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -367,11 +367,11 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="🧦" FontSize="16" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="SOCKS5" FontSize="11"
|
||||
<TextBlock Grid.Column="1" Text="Mixed" FontSize="11"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
<Border Grid.Column="2" Background="#1AE8803A" CornerRadius="8" Padding="8,3">
|
||||
<TextBlock Text="{Binding Socks5Info}" FontSize="11" FontWeight="SemiBold"
|
||||
<TextBlock Text="{Binding MixedProxyInfo}" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource AccentBrush}"
|
||||
FlowDirection="LeftToRight"/>
|
||||
</Border>
|
||||
|
||||
@@ -19,20 +19,20 @@
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="پورت SOCKS5"
|
||||
Text="پورت پروکسی محلی (SOCKS5/HTTP)"
|
||||
FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="2"
|
||||
Style="{StaticResource ModernTextBox}"
|
||||
Text="{Binding Socks5PortText, UpdateSourceTrigger=PropertyChanged}"
|
||||
Text="{Binding MixedProxyPortText, UpdateSourceTrigger=PropertyChanged}"
|
||||
FlowDirection="LeftToRight"
|
||||
FontSize="12"
|
||||
Padding="8,5"
|
||||
ToolTip="پورت داخلی 127.0.0.1 برای پروکسی SOCKS5"/>
|
||||
ToolTip="پورت داخلی 127.0.0.1 برای پروکسی SOCKS5 و HTTP"/>
|
||||
<TextBlock Grid.Column="4"
|
||||
Text="{Binding Socks5PortStatusText}"
|
||||
Text="{Binding MixedProxyPortStatusText}"
|
||||
FontSize="10"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
@@ -43,6 +43,7 @@
|
||||
Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,6,0,0"/>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user