Files
TunnelX/AppTunnel/Services/TrafficRouterService.ConnectivityChecks.cs
T
MaxFan 8283b9d6d1 Prepare release v1.2.27
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:20:44 +03:30

129 lines
6.9 KiB
C#

using System.Net;
using System.Net.Sockets;
namespace AppTunnel.Services;
public partial class TrafficRouterService
{
// Well-known hostnames used by connectivity checks. Resolved at
// runtime via DNS so no hardcoded IPs remain in the codebase.
private const string IntranetCheckHost = "isna.ir";
private const string InternationalCheckHost = "google.com";
private async Task RunConnectivityChecks()
{
try
{
// Give VPN a moment to fully settle.
await Task.Delay(1000);
using var ping = new System.Net.NetworkInformation.Ping();
// 1. TCP-connect to the tunnel/proxy server directly (via physical NIC).
// ICMP is useless here: CDN servers (e.g. Cloudflare, Arvancloud) never
// respond to pings, so Ping.Send() always times out even when the server
// is perfectly healthy. We use the original hostname (_vpnServerHost) so
// that CDN-aware DNS (which may return different IPs per resolve) is used,
// matching exactly how sing-box connects.
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
using var tcpServer = new TcpClient();
using var serverCts = new System.Threading.CancellationTokenSource(3000);
await tcpServer.ConnectAsync(_vpnServerHost, _vpnServerPort, serverCts.Token);
sw.Stop();
Logger.Info($"[CONN-CHECK] TCP tunnel server {_vpnServerHost}:{_vpnServerPort} (direct): {sw.ElapsedMilliseconds}ms — reachable");
}
catch (OperationCanceledException) { Logger.Warning($"[CONN-CHECK] TCP tunnel server {_vpnServerHost}:{_vpnServerPort}: timeout (3000ms) — server unreachable or port blocked"); }
catch (Exception ex) { Logger.Warning($"[CONN-CHECK] TCP tunnel server {_vpnServerHost}:{_vpnServerPort} failed: {ex.Message}"); }
// 2. Resolve an Iranian intranet hostname and ping it via the
// default route (physical NIC). Confirms the local intranet
// still works while the VPN is connected.
try
{
var intraIp = await DnsResolverCache.ResolveFirstIpv4Async(IntranetCheckHost, CancellationToken.None);
if (intraIp != null)
{
var reply = ping.Send(intraIp, 2000);
Logger.Info($"[CONN-CHECK] Ping intranet {IntranetCheckHost} ({intraIp}, default route): {reply.Status}, {reply.RoundtripTime}ms");
}
else
Logger.Warning($"[CONN-CHECK] DNS {IntranetCheckHost}: no IPv4 address");
}
catch (Exception ex) { Logger.Warning($"[CONN-CHECK] Ping intranet failed: {ex.Message}"); }
// 3. Resolve an international hostname and ping it via the default
// route. This is diagnostic only: on some networks this can
// legitimately succeed without indicating full-route VPN mode.
IPAddress? intlIp = null;
try
{
intlIp = await DnsResolverCache.ResolveFirstIpv4Async(InternationalCheckHost, CancellationToken.None);
}
catch { }
if (intlIp != null)
{
uint intlNbo = BitConverter.ToUInt32(intlIp.GetAddressBytes(), 0);
bool routeAlreadyPresent = _addedRoutes.ContainsKey(intlNbo);
// 3. Ping international IP via default route. Treat success as a
// reachability observation, not proof of a split-tunnel failure.
// SKIP when a /32 host route for this IP is already installed (e.g.
// chrome started connecting before this check ran) — that route would
// win over the default route and give a false "Success, 0ms" result.
if (!routeAlreadyPresent)
{
try
{
var reply = ping.Send(intlIp, 3000);
if (reply.Status == System.Net.NetworkInformation.IPStatus.Success)
Logger.Info($"[CONN-CHECK] Ping {InternationalCheckHost} ({intlIp}, default route): {reply.Status}, {reply.RoundtripTime}ms — direct route is reachable; not a leak by itself");
else
Logger.Info($"[CONN-CHECK] Ping {InternationalCheckHost} ({intlIp}, default route): {reply.Status} — OK, international not reachable via default route (split-tunnel working)");
}
catch (Exception ex) { Logger.Warning($"[CONN-CHECK] Ping international (default route) failed: {ex.Message}"); }
}
else
{
Logger.Info($"[CONN-CHECK] Ping {InternationalCheckHost} default-route check skipped — host route already present (app traffic in flight)");
}
// 4. TCP-connect to the same international IP via VPN — confirms the
// tunnel can actually reach international destinations.
// sing-box VLESS/VMess outbound does not forward ICMP, so a regular
// Ping.Send() would bounce locally in the TUN and report a fake 0 ms.
// A TCP handshake gives the real round-trip time through the tunnel.
// When the route was already installed by live traffic we don't remove
// it afterwards (it's in active use).
try
{
bool routeAdded = false;
if (!routeAlreadyPresent)
{
EnsureHostRouteViaVpn(intlNbo, intlIp);
routeAdded = true;
}
var sw = System.Diagnostics.Stopwatch.StartNew();
using var tcp = new System.Net.Sockets.TcpClient();
using var tcpCts = new System.Threading.CancellationTokenSource(3000);
await tcp.ConnectAsync(intlIp, 443, tcpCts.Token);
sw.Stop();
Logger.Info($"[CONN-CHECK] TCP {InternationalCheckHost} ({intlIp}:443, via VPN tunnel): {sw.ElapsedMilliseconds}ms");
// Remove temp route only if we added it and no target app needs it
if (routeAdded && !_ipToProcess.ContainsKey(intlNbo))
TryRemoveHostRoute(intlNbo);
}
catch (OperationCanceledException) { Logger.Warning($"[CONN-CHECK] TCP {InternationalCheckHost} via VPN: timeout (3000ms)"); }
catch (Exception ex) { Logger.Warning($"[CONN-CHECK] TCP {InternationalCheckHost} via VPN failed: {ex.Message}"); }
}
else
Logger.Warning($"[CONN-CHECK] Could not resolve {InternationalCheckHost} — skipping international checks");
}
catch { }
}
}