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

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
}