mirror of
https://github.com/MaxiFan/TunnelX.git
synced 2026-05-19 08:04:41 +03:00
8283b9d6d1
Co-authored-by: Cursor <cursoragent@cursor.com>
240 lines
8.3 KiB
C#
240 lines
8.3 KiB
C#
using System.Net;
|
|
|
|
namespace AppTunnel.Services;
|
|
|
|
public partial class TrafficRouterService
|
|
{
|
|
#region Excluded Destinations
|
|
|
|
/// <summary>
|
|
/// Replace the entire exclude list. Resolves domains to IPs.
|
|
/// </summary>
|
|
public void SetExcludedDestinations(IEnumerable<string> entries)
|
|
{
|
|
lock (_destinationListLock)
|
|
{
|
|
_excludedIps.Clear();
|
|
_excludedEntries.Clear();
|
|
_excludedDomainRules.Clear();
|
|
foreach (var entry in entries)
|
|
AddExcludedDestinationCore(entry);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add a single domain or IP to the exclude list.
|
|
/// If the tunnel is already running and a host route for any of the resolved
|
|
/// IPs was previously installed (e.g. the user browsed there before excluding
|
|
/// it), the route is removed immediately so that subsequent connections bypass
|
|
/// the VPN rather than continuing to use the stale route.
|
|
/// Also removes stale routes left over from a previously crashed session
|
|
/// (even if _isRunning is false at this point), since those routes are not
|
|
/// tracked in _addedRoutes but can still silently redirect traffic to the VPN.
|
|
/// </summary>
|
|
public void AddExcludedDestination(string entry)
|
|
{
|
|
lock (_destinationListLock)
|
|
AddExcludedDestinationCore(entry);
|
|
}
|
|
|
|
private void AddExcludedDestinationCore(string entry)
|
|
{
|
|
var originalEntry = entry.Trim();
|
|
entry = NormalizeDestinationEntry(originalEntry);
|
|
if (string.IsNullOrEmpty(entry)) return;
|
|
if (_excludedEntries.ContainsKey(entry)) return;
|
|
|
|
var ips = ResolveDestinationEntry(entry, "[EXCLUDE]", out var unsupportedIp);
|
|
if (unsupportedIp)
|
|
{
|
|
_excludedEntries[entry] = ips;
|
|
return;
|
|
}
|
|
|
|
if (IPAddress.TryParse(entry, out var ip))
|
|
{
|
|
Logger.Info($"[EXCLUDE] Added IP {entry}");
|
|
}
|
|
else
|
|
{
|
|
_excludedDomainRules[entry] = true;
|
|
var normalizedSuffix = originalEntry.Equals(entry, StringComparison.OrdinalIgnoreCase)
|
|
? ""
|
|
: $" (from '{originalEntry}')";
|
|
Logger.Info($"[EXCLUDE] Added domain rule '*.{entry}'{normalizedSuffix} → {ips.Count} current IPs");
|
|
}
|
|
|
|
foreach (var nbo in ips)
|
|
{
|
|
_excludedIps[nbo] = true;
|
|
PurgeRouteForExcludedIp(nbo, new IPAddress(BitConverter.GetBytes(nbo)));
|
|
}
|
|
_excludedEntries[entry] = ips;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes any /32 host route for <paramref name="nbo"/> that may exist in
|
|
/// the Windows routing table — regardless of whether TunnelX added it in this
|
|
/// session (tracked via <see cref="_addedRoutes"/>) or it is a stale route
|
|
/// left behind by a previous session that crashed without cleaning up.
|
|
/// Also cancels any pending delayed-removal timer and clears the per-session
|
|
/// flow-tracking state for the IP, so the egress/ingress sniff loops stop
|
|
/// attributing bytes and the NAT table stops matching replies.
|
|
/// </summary>
|
|
private void PurgeRouteForExcludedIp(uint nbo, IPAddress ipForLog)
|
|
{
|
|
// Cancel any pending delayed removal (superseded by this explicit remove).
|
|
if (_pendingRouteRemoval.TryRemove(nbo, out var pendingCts))
|
|
try { pendingCts.Cancel(); } catch { }
|
|
|
|
// Clear in-session tracking state.
|
|
_addedRoutes.TryRemove(nbo, out _);
|
|
_ipToProcess.TryRemove(nbo, out _);
|
|
_ipRefCount.TryRemove(nbo, out _);
|
|
|
|
// Force-delete from the Windows routing table using route.exe.
|
|
// This covers:
|
|
// 1. Routes that TunnelX added in the current session.
|
|
// 2. Stale routes from a previous session that crashed before StopAsync
|
|
// could call RemoveAllHostRoutes().
|
|
// We do NOT gate this on _isRunning — stale routes can be present even
|
|
// before the tunnel starts, and we must clean them up proactively.
|
|
ForceDeleteRouteFromWindows(ipForLog);
|
|
AddExcludedDirectRoute(nbo, ipForLog);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unconditionally removes the /32 host route for <paramref name="ip"/> from
|
|
/// the Windows routing table via route.exe delete. Does not touch _addedRoutes.
|
|
/// </summary>
|
|
internal void ForceDeleteRouteFromWindows(IPAddress ip)
|
|
{
|
|
try
|
|
{
|
|
var psi = new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "route.exe",
|
|
Arguments = $"delete {ip} mask 255.255.255.255",
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
};
|
|
using var proc = System.Diagnostics.Process.Start(psi);
|
|
proc?.WaitForExit(1500);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove a single domain or IP from the exclude list.
|
|
/// </summary>
|
|
public void RemoveExcludedDestination(string entry)
|
|
{
|
|
entry = NormalizeDestinationEntry(entry);
|
|
lock (_destinationListLock)
|
|
{
|
|
if (_excludedEntries.TryRemove(entry, out var ips))
|
|
{
|
|
foreach (var nbo in ips)
|
|
{
|
|
if (!IsIpPresentInEntries(_excludedEntries, nbo))
|
|
{
|
|
_excludedIps.TryRemove(nbo, out _);
|
|
RemoveExcludedDirectRoute(nbo);
|
|
}
|
|
}
|
|
_excludedDomainRules.TryRemove(entry, out _);
|
|
Logger.Info($"[EXCLUDE] Removed '{entry}'");
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool IsExcludedDestination(uint dstIpNbo)
|
|
=> _excludedIps.ContainsKey(dstIpNbo);
|
|
|
|
private bool IsExcludedDomain(string host)
|
|
=> IsDomainRuleMatch(_excludedDomainRules, host);
|
|
|
|
private void RefreshExcludedDestinations()
|
|
{
|
|
lock (_destinationListLock)
|
|
{
|
|
foreach (var entry in _excludedEntries.Keys.ToList())
|
|
{
|
|
var oldIps = _excludedEntries.TryGetValue(entry, out var existing)
|
|
? existing
|
|
: new HashSet<uint>();
|
|
var newIps = ResolveDestinationEntry(entry, "[EXCLUDE]", out _);
|
|
|
|
_excludedEntries[entry] = newIps;
|
|
foreach (var nbo in oldIps.Except(newIps).ToList())
|
|
{
|
|
if (!IsIpPresentInEntries(_excludedEntries, nbo))
|
|
{
|
|
_excludedIps.TryRemove(nbo, out _);
|
|
RemoveExcludedDirectRoute(nbo);
|
|
}
|
|
}
|
|
|
|
foreach (var nbo in newIps)
|
|
{
|
|
_excludedIps[nbo] = true;
|
|
PurgeRouteForExcludedIp(nbo, new IPAddress(BitConverter.GetBytes(nbo)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RefreshDestinationLists(bool installIncludedRoutes)
|
|
{
|
|
RefreshExcludedDestinations();
|
|
RefreshIncludedDestinations(installIncludedRoutes);
|
|
}
|
|
|
|
private void RefreshExcludedDirectRoutes()
|
|
{
|
|
if (!_fullRouteEnabled)
|
|
return;
|
|
|
|
foreach (var nbo in _excludedIps.Keys)
|
|
AddExcludedDirectRoute(nbo, new IPAddress(BitConverter.GetBytes(nbo)));
|
|
}
|
|
|
|
private void AddExcludedDirectRoute(uint nbo, IPAddress ip)
|
|
{
|
|
if (!_fullRouteEnabled)
|
|
return;
|
|
if (string.IsNullOrWhiteSpace(_physicalGatewayIp) || _physicalInterfaceIndex <= 0)
|
|
return;
|
|
|
|
TryRunRouteCommand($"delete {ip}", out _);
|
|
if (TryRunRouteCommand(
|
|
$"add {ip} mask 255.255.255.255 {_physicalGatewayIp} IF {_physicalInterfaceIndex} METRIC 1",
|
|
out var stderr))
|
|
{
|
|
_excludedDirectRoutes[nbo] = true;
|
|
return;
|
|
}
|
|
|
|
Logger.Warning($"[EXCLUDE] Failed to add direct full-route bypass for {ip}: {stderr.Trim()}");
|
|
}
|
|
|
|
private void RemoveExcludedDirectRoute(uint nbo)
|
|
{
|
|
if (!_excludedDirectRoutes.TryRemove(nbo, out _))
|
|
return;
|
|
|
|
TryRunRouteCommand($"delete {new IPAddress(BitConverter.GetBytes(nbo))}", out _);
|
|
}
|
|
|
|
private void RemoveExcludedDirectRoutes()
|
|
{
|
|
foreach (var nbo in _excludedDirectRoutes.Keys.ToList())
|
|
RemoveExcludedDirectRoute(nbo);
|
|
_excludedDirectRoutes.Clear();
|
|
}
|
|
|
|
#endregion
|
|
}
|