mirror of
https://github.com/MaxiFan/TunnelX.git
synced 2026-05-17 21:14:37 +03:00
1004 lines
44 KiB
C#
1004 lines
44 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Runtime.InteropServices;
|
|
|
|
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
|
|
{
|
|
private readonly ConcurrentDictionary<string, bool> _targetExecutables = new(StringComparer.OrdinalIgnoreCase);
|
|
// Apps explicitly disabled by the user while split mode is active.
|
|
// This hard-deny guard prevents stale flow/process cache races from
|
|
// reinstalling routes for disabled executables.
|
|
private readonly ConcurrentDictionary<string, bool> _blockedExecutables = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ConcurrentDictionary<string, TrafficCounter> _trafficCounters = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Excluded destination IPs (network byte order). Populated from user-entered
|
|
// domains/IPs; checked before installing host routes.
|
|
private readonly ConcurrentDictionary<uint, bool> _excludedIps = new();
|
|
// Raw exclude entries → resolved NBO IPs, so we can remove cleanly.
|
|
private readonly ConcurrentDictionary<string, HashSet<uint>> _excludedEntries = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Included destination IPs (network byte order). Populated from user-entered
|
|
// domains/IPs; forced through tunnel regardless of target app selection.
|
|
private readonly ConcurrentDictionary<uint, bool> _includedIps = new();
|
|
// Raw include entries → resolved NBO IPs, so we can remove cleanly.
|
|
private readonly ConcurrentDictionary<string, HashSet<uint>> _includedEntries = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// NAT table: key=(protocol, srcPort); value=entry with original IP/ifIdx/process
|
|
// Used to reverse-translate inbound packets so replies reach the correct socket.
|
|
// NAT key = (protocol, client-srcPort, server-dstIp-NBO)
|
|
// Including the destination IP prevents false matches when the OS recycles
|
|
// a source port for a new connection to a *different* server before the old
|
|
// NAT entry has been cleaned up (Bug #3 — port collision).
|
|
private readonly ConcurrentDictionary<(byte proto, ushort port, uint dstIp), NatEntry> _natTable = new();
|
|
|
|
// Host routes we added (dstIP in network byte order) so we can clean them up on stop.
|
|
private readonly ConcurrentDictionary<uint, bool> _addedRoutes = new();
|
|
private long _statRoutesAdded;
|
|
private long _statRoutesFailed;
|
|
|
|
private CancellationTokenSource? _cts;
|
|
private Task? _routingTask;
|
|
private Task? _inboundTask = null;
|
|
private int _vpnInterfaceIndex = -1;
|
|
private string _vpnLocalIp = "";
|
|
private string _vpnServerIp = ""; // resolved IPv4 — used in WinDivert filter strings
|
|
private string _vpnServerHost = ""; // original hostname/IP from config — used for TCP health checks
|
|
private byte[]? _vpnLocalIpBytes;
|
|
private volatile bool _isRunning;
|
|
private bool _fullRouteEnabled;
|
|
private int _inboundRewriteCount = 0;
|
|
private IntPtr _outboundHandle = IntPtr.Zero;
|
|
private IntPtr _inboundHandle = IntPtr.Zero;
|
|
private readonly object _handleLock = new();
|
|
|
|
public bool IsRunning => _isRunning;
|
|
private long _redirectCount = 0;
|
|
|
|
/// <summary>
|
|
/// Checks whether the VPN network interface is still operational.
|
|
/// Returns false if the interface no longer exists or is not Up.
|
|
/// </summary>
|
|
public bool IsVpnInterfaceUp()
|
|
{
|
|
if (!_isRunning || _vpnInterfaceIndex < 0) return false;
|
|
try
|
|
{
|
|
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
|
{
|
|
var props = nic.GetIPProperties();
|
|
var ipv4 = props.GetIPv4Properties();
|
|
if (ipv4 != null && ipv4.Index == _vpnInterfaceIndex)
|
|
return nic.OperationalStatus == System.Net.NetworkInformation.OperationalStatus.Up;
|
|
}
|
|
}
|
|
catch { }
|
|
return false; // Interface not found
|
|
}
|
|
|
|
// Diagnostics counters
|
|
private long _statTotalCaptured;
|
|
private long _statRedirected;
|
|
private long _statPassthrough;
|
|
private long _statSendFailed;
|
|
private long _statInboundCaptured;
|
|
private long _statInboundNatMatched;
|
|
private long _statInboundSendFailed;
|
|
// Packet intercept (source-IP rewriting) counters
|
|
private long _statNetOutRewritten;
|
|
private long _statNetInRewritten;
|
|
private long _statNetOutPassthrough;
|
|
private long _statNetOutSendFailed;
|
|
private long _statVpnEgressSniffed;
|
|
private long _statVpnEgressFromOurIp;
|
|
// Leak accounting:
|
|
// - Confirmed: packet escaped split policy (should remain 0 in normal operation)
|
|
// - Blocked: attempted leak that was dropped locally by leak-guard
|
|
private long _statLeakConfirmed;
|
|
private long _statLeakBlocked;
|
|
private long _statLeakBlockedRecovered;
|
|
private long _statLeakBlockedSuppressed;
|
|
private long _policyTransitionGraceUntilTick;
|
|
// Total bytes observed on the VPN interface, regardless of per-app
|
|
// attribution. These counters give an honest "total tunnel throughput"
|
|
// value for the connection tab, even for traffic we haven't been able
|
|
// to attribute to a selected app (e.g. tail packets after a flow ends).
|
|
private long _totalVpnBytesSent;
|
|
private long _totalVpnBytesReceived;
|
|
// Physical NIC baseline for computing total network usage since connection start.
|
|
private string? _physicalNicId;
|
|
private int _physicalInterfaceIndex = -1;
|
|
private string _physicalGatewayIp = "";
|
|
private long _baselinePhysBytesSent;
|
|
private long _baselinePhysBytesReceived;
|
|
private long _directBytesSent;
|
|
private long _directBytesReceived;
|
|
private long _diagTick;
|
|
private System.Threading.Timer? _statsTimer;
|
|
private Task? _vpnSniffTask;
|
|
private Task? _physSniffTask;
|
|
private Task? _directSniffTask;
|
|
private Task? _vpnIngressTask;
|
|
private IntPtr _vpnSniffHandle = IntPtr.Zero;
|
|
private IntPtr _physSniffHandle = IntPtr.Zero;
|
|
private IntPtr _directSniffHandle = IntPtr.Zero;
|
|
private IntPtr _vpnIngressHandle = IntPtr.Zero;
|
|
private IntPtr _ipv6BlockHandle = IntPtr.Zero;
|
|
private Task? _ipv6BlockTask;
|
|
|
|
/// <summary>
|
|
/// If true, do NOT rewrite any packets — just capture, classify, and reinject.
|
|
/// Used to test whether WinDivert itself is breaking connectivity.
|
|
/// Set via environment variable TUNNELX_PASSTHROUGH=1 or property.
|
|
/// </summary>
|
|
public bool PassthroughMode { get; set; } =
|
|
Environment.GetEnvironmentVariable("TUNNELX_PASSTHROUGH") == "1";
|
|
|
|
/// <summary>
|
|
/// When true, a SOCKS5 proxy is started on 127.0.0.1:<see cref="Socks5Port"/>
|
|
/// while the VPN is up. Outgoing sockets are bound to the VPN local IP,
|
|
/// guaranteeing egress via the tunnel for any app configured to use the
|
|
/// proxy (e.g. Telegram Settings → Connection type → SOCKS5).
|
|
/// </summary>
|
|
public bool EnableSocks5 { get; set; } = true;
|
|
|
|
/// <summary>Listener port for the built-in SOCKS5 proxy.</summary>
|
|
public int Socks5Port { get; set; } = 1080;
|
|
|
|
/// <summary>
|
|
/// Enables DNS optimization features (cached resolves + best DNS redirect target).
|
|
/// </summary>
|
|
public bool EnableDnsOptimization { get; set; } = true;
|
|
|
|
private bool _enableGameMode;
|
|
/// <summary>
|
|
/// Game mode prefers lower latency behavior for routed packets.
|
|
/// </summary>
|
|
public bool EnableGameMode
|
|
{
|
|
get => _enableGameMode;
|
|
set
|
|
{
|
|
_enableGameMode = value;
|
|
_routeRemovalGraceSeconds = value ? 180 : 60;
|
|
}
|
|
}
|
|
|
|
private int _routeRemovalGraceSeconds = 60;
|
|
internal int RouteRemovalGraceSeconds => _routeRemovalGraceSeconds;
|
|
|
|
private IPAddress _dnsRedirectIp = IPAddress.Parse("8.8.8.8");
|
|
private uint _dnsRedirectIpNbo = BitConverter.ToUInt32(new byte[] { 8, 8, 8, 8 }, 0);
|
|
private byte[] _dnsRedirectIpBytes = new byte[] { 8, 8, 8, 8 };
|
|
|
|
private Socks5Server? _socks5;
|
|
|
|
#pragma warning disable CS0067
|
|
public event Action<string, long, long>? TrafficUpdated;
|
|
#pragma warning restore CS0067
|
|
|
|
public void AddTargetApp(string executableName)
|
|
{
|
|
_blockedExecutables.TryRemove(executableName, out _);
|
|
_targetExecutables[executableName] = true;
|
|
_trafficCounters.TryAdd(executableName, new TrafficCounter());
|
|
InvalidateProcessCaches();
|
|
Logger.Info($"[APP-TARGET] Enabled '{executableName}' (targets={_targetExecutables.Count}, blocked={_blockedExecutables.Count})");
|
|
ReconcileTargetAppPolicy(executableName, enabled: true);
|
|
}
|
|
|
|
public void ClearTargetApps()
|
|
{
|
|
foreach (var exe in _targetExecutables.Keys)
|
|
_blockedExecutables[exe] = true;
|
|
_targetExecutables.Clear();
|
|
InvalidateProcessCaches();
|
|
CleanupRoutesForCurrentMode();
|
|
}
|
|
|
|
public void RemoveTargetApp(string executableName)
|
|
{
|
|
// Policy transitions can trigger short-lived retransmits from stale sockets.
|
|
// Keep a grace window so these blocked packets are not surfaced as hard leaks.
|
|
MarkPolicyTransitionGrace(TimeSpan.FromSeconds(20));
|
|
_targetExecutables.TryRemove(executableName, out _);
|
|
_blockedExecutables[executableName] = true;
|
|
InvalidateProcessCaches();
|
|
Logger.Info($"[APP-TARGET] Disabled '{executableName}' (targets={_targetExecutables.Count}, blocked={_blockedExecutables.Count})");
|
|
ReconcileTargetAppPolicy(executableName, enabled: false);
|
|
CleanupRoutesForCurrentMode(dropStaleNat: true);
|
|
}
|
|
|
|
public (long sent, long received) GetTraffic(string executableName)
|
|
{
|
|
if (_trafficCounters.TryGetValue(executableName, out var counter))
|
|
return (counter.BytesSent, counter.BytesReceived);
|
|
return (0, 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the total number of bytes sent and received through the VPN
|
|
/// interface since the tunnel was started. Unlike <see cref="GetTraffic"/>,
|
|
/// this is NOT filtered by process and includes all tunnelled traffic,
|
|
/// so the connection-tab "total" reading reflects actual tunnel usage.
|
|
/// </summary>
|
|
public (long sent, long received) GetTotalVpnTraffic()
|
|
=> (Interlocked.Read(ref _totalVpnBytesSent),
|
|
Interlocked.Read(ref _totalVpnBytesReceived));
|
|
|
|
/// <summary>
|
|
/// Returns the sum of tunnel traffic attributed to app counters during the
|
|
/// current connection. This intentionally includes apps that were disabled
|
|
/// later in the same session, so per-app totals remain consistent with the
|
|
/// history and total tunnel counters.
|
|
/// </summary>
|
|
public (long sent, long received) GetTrackedAppsTraffic()
|
|
{
|
|
long sent = 0;
|
|
long received = 0;
|
|
foreach (var counter in _trafficCounters.Values)
|
|
{
|
|
sent += Interlocked.Read(ref counter.BytesSent);
|
|
received += Interlocked.Read(ref counter.BytesReceived);
|
|
}
|
|
return (sent, received);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns total bytes sent/received on the physical NIC since the tunnel started.
|
|
/// This represents ALL network activity (VPN-encapsulated + direct).
|
|
/// </summary>
|
|
public (long sent, long received) GetTotalNetworkTraffic()
|
|
{
|
|
if (_physicalNicId == null) return (0, 0);
|
|
try
|
|
{
|
|
var nic = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()
|
|
.FirstOrDefault(n => n.Id == _physicalNicId);
|
|
if (nic == null) return (0, 0);
|
|
var stats = nic.GetIPStatistics();
|
|
return (stats.BytesSent - _baselinePhysBytesSent,
|
|
stats.BytesReceived - _baselinePhysBytesReceived);
|
|
}
|
|
catch { return (0, 0); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns traffic observed on the physical NIC that did not belong to the
|
|
/// tunnel carrier connection itself. This is the UI's "direct traffic"
|
|
/// source; it avoids subtracting inner tunnel payload from encrypted
|
|
/// physical bytes, which is inaccurate for Xray/sing-box transports.
|
|
/// </summary>
|
|
public (long sent, long received) GetDirectTraffic()
|
|
=> (Interlocked.Read(ref _directBytesSent),
|
|
Interlocked.Read(ref _directBytesReceived));
|
|
|
|
/// <summary>
|
|
/// Confirmed leak count (actual escape from split policy).
|
|
/// Expected to stay zero when leak-guard is active.
|
|
/// </summary>
|
|
public long LeakCount => Interlocked.Read(ref _statLeakConfirmed);
|
|
/// <summary>
|
|
/// Number of attempted leaks blocked locally by leak-guard.
|
|
/// Diagnostic-only signal; these packets did not escape the machine.
|
|
/// </summary>
|
|
public long LeakBlockedCount => Interlocked.Read(ref _statLeakBlocked);
|
|
public long LeakBlockedRecoveredCount => Interlocked.Read(ref _statLeakBlockedRecovered);
|
|
public long LeakBlockedSuppressedCount => Interlocked.Read(ref _statLeakBlockedSuppressed);
|
|
public long Ipv6BlockedCount => Interlocked.Read(ref _statFlowIPv6Blocked);
|
|
public long DnsRedirectCount => Interlocked.Read(ref _redirectCount);
|
|
public long ActiveRouteCount => _addedRoutes.Count;
|
|
public long RouteFailureCount => Interlocked.Read(ref _statRoutesFailed);
|
|
|
|
public void Start(int vpnInterfaceIndex, string vpnLocalIp, string vpnServerIp)
|
|
{
|
|
if (_isRunning) return;
|
|
|
|
_vpnInterfaceIndex = vpnInterfaceIndex;
|
|
_vpnLocalIp = vpnLocalIp;
|
|
_vpnLocalIpBytes = IPAddress.TryParse(vpnLocalIp, out var vpnAddr)
|
|
? vpnAddr.GetAddressBytes()
|
|
: null;
|
|
_cts = new CancellationTokenSource();
|
|
_isRunning = true;
|
|
|
|
// Keep the original hostname for TCP-based health checks (domain may be behind
|
|
// a CDN that returns different IPs; we should connect by name, not cached IP).
|
|
_vpnServerHost = vpnServerIp;
|
|
|
|
// Resolve VPN server address to an IPv4 string.
|
|
// WinDivert filters require a literal IP address — hostnames are invalid
|
|
// and cause WinDivertOpen to fail, silently killing packet interception.
|
|
// Re-resolved on every Start() so CDN IP changes are picked up on reconnect.
|
|
if (IPAddress.TryParse(vpnServerIp, out _))
|
|
{
|
|
_vpnServerIp = vpnServerIp;
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
var v4 = DnsResolverCache.ResolveFirstIpv4(vpnServerIp);
|
|
_vpnServerIp = v4?.ToString() ?? vpnServerIp;
|
|
if (v4 != null)
|
|
Logger.Info($"[DNS] Resolved VPN server '{vpnServerIp}' → {_vpnServerIp}");
|
|
else
|
|
Logger.Warning($"[DNS] Could not resolve VPN server '{vpnServerIp}' to IPv4 — WinDivert filters may fail");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_vpnServerIp = vpnServerIp;
|
|
Logger.Warning($"[DNS] Hostname resolution failed for '{vpnServerIp}': {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// Defensive: if _vpnServerIp is still not a literal IPv4 address (DNS
|
|
// resolution failed completely), fall back to a sentinel that is a
|
|
// syntactically valid IPv4 but unroutable. Without this guard, the
|
|
// hostname would be embedded into WinDivert filter strings such as
|
|
// "ip.DstAddr != example.com", which fail to compile silently and
|
|
// disable packet interception. The sentinel keeps the filter valid;
|
|
// the connection will of course not work end-to-end but at least the
|
|
// failure mode is observable (sing-box reports a clear DNS error).
|
|
if (!IPAddress.TryParse(_vpnServerIp, out _))
|
|
{
|
|
Logger.Error($"[DNS] '{_vpnServerIp}' is not a valid IPv4 — using sentinel 0.0.0.0 in filters so they remain syntactically valid");
|
|
_vpnServerIp = "0.0.0.0";
|
|
}
|
|
|
|
// Auto-exclude all currently-configured Windows DNS servers from VPN
|
|
// routing. Otherwise, when a target app (e.g. chrome) does a DNS
|
|
// lookup, TunnelX adds a /32 host route for the DNS server through
|
|
// the VPN. sing-box itself uses the same system DNS to resolve the
|
|
// VPN server's hostname \u2014 and that DNS query then enters the TUN it
|
|
// is trying to forward through, causing a recursive "lookup\u2026 i/o
|
|
// timeout" loop. Keeping DNS traffic on the physical NIC avoids it.
|
|
try
|
|
{
|
|
var dnsCount = 0;
|
|
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
|
{
|
|
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
|
|
if (nic.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
|
|
if (nic.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Tunnel) continue;
|
|
System.Net.NetworkInformation.IPInterfaceProperties props;
|
|
try { props = nic.GetIPProperties(); } catch { continue; }
|
|
foreach (var dns in props.DnsAddresses)
|
|
{
|
|
if (dns.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
|
|
var nbo = BitConverter.ToUInt32(dns.GetAddressBytes(), 0);
|
|
if (_excludedIps.TryAdd(nbo, true))
|
|
{
|
|
Logger.Info($"[DNS-EXCLUDE] {dns} (auto-excluded \u2014 system DNS on '{nic.Name}')");
|
|
dnsCount++;
|
|
}
|
|
}
|
|
}
|
|
if (dnsCount == 0)
|
|
Logger.Info("[DNS-EXCLUDE] No system DNS servers found to auto-exclude");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning($"[DNS-EXCLUDE] Enumeration failed: {ex.Message}");
|
|
}
|
|
|
|
ConfigureDnsRedirectTarget();
|
|
|
|
// Reset total-throughput counters for this session.
|
|
Interlocked.Exchange(ref _totalVpnBytesSent, 0);
|
|
Interlocked.Exchange(ref _totalVpnBytesReceived, 0);
|
|
Interlocked.Exchange(ref _directBytesSent, 0);
|
|
Interlocked.Exchange(ref _directBytesReceived, 0);
|
|
foreach (var counter in _trafficCounters.Values)
|
|
{
|
|
Interlocked.Exchange(ref counter.BytesSent, 0);
|
|
Interlocked.Exchange(ref counter.BytesReceived, 0);
|
|
}
|
|
|
|
// Reset flow-log counters so session 2 gets fresh log output.
|
|
_flowLogCount = 0;
|
|
_flowMatchLogCount = 0;
|
|
|
|
Logger.Info($"TrafficRouter starting: VPN Interface={vpnInterfaceIndex}, LocalIP={vpnLocalIp}, ServerIP={vpnServerIp}");
|
|
Logger.Info($"Target apps: {string.Join(", ", _targetExecutables.Keys)}");
|
|
if (PassthroughMode)
|
|
Logger.Warning("DIAGNOSTIC PASSTHROUGH MODE ENABLED — packets will NOT be redirected. For testing only.");
|
|
|
|
LogNetworkInterfaces();
|
|
|
|
// Record baseline physical NIC stats for total network traffic calculation.
|
|
// Iterate manually so a single adapter throwing GetIPv4Properties() doesn't
|
|
// abort the whole search (LINQ FirstOrDefault propagates predicate exceptions).
|
|
try
|
|
{
|
|
System.Net.NetworkInformation.NetworkInterface? physNic = null;
|
|
foreach (var n in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
|
{
|
|
try
|
|
{
|
|
if (n.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
|
|
if (n.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
|
|
if (n.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Tunnel) continue;
|
|
if ((n.GetIPProperties().GetIPv4Properties()?.Index ?? -1) == vpnInterfaceIndex) continue;
|
|
physNic = n;
|
|
break;
|
|
}
|
|
catch { /* skip adapters whose property queries fail */ }
|
|
}
|
|
if (physNic != null)
|
|
{
|
|
_physicalNicId = physNic.Id;
|
|
var physProps = physNic.GetIPProperties();
|
|
_physicalInterfaceIndex = physProps.GetIPv4Properties()?.Index ?? -1;
|
|
_physicalGatewayIp = physProps.GatewayAddresses
|
|
.FirstOrDefault(g => g.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
|
?.Address.ToString() ?? "";
|
|
var nicStats = physNic.GetIPStatistics();
|
|
_baselinePhysBytesSent = nicStats.BytesSent;
|
|
_baselinePhysBytesReceived = nicStats.BytesReceived;
|
|
Logger.Info($"[NIC-BASELINE] Physical NIC '{physNic.Name}' ifIdx={_physicalInterfaceIndex} baseline: sent={_baselinePhysBytesSent} recv={_baselinePhysBytesReceived}");
|
|
}
|
|
else
|
|
{
|
|
_physicalInterfaceIndex = -1;
|
|
_physicalGatewayIp = "";
|
|
Logger.Warning("[NIC-BASELINE] No suitable physical NIC found — DirectTraffic counter will be unavailable.");
|
|
}
|
|
}
|
|
catch (Exception ex) { Logger.Warning($"[NIC-BASELINE] Failed: {ex.Message}"); }
|
|
|
|
// Validate VPN interface actually exists with expected IP
|
|
try
|
|
{
|
|
var vpnNic = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()
|
|
.FirstOrDefault(n => {
|
|
try { return n.GetIPProperties().GetIPv4Properties()?.Index == vpnInterfaceIndex; }
|
|
catch { return false; }
|
|
});
|
|
if (vpnNic == null)
|
|
Logger.Warning($"[VPN-DETECT] No NIC found with interface index {vpnInterfaceIndex}!");
|
|
else
|
|
Logger.Info($"[VPN-DETECT] VPN NIC confirmed: name='{vpnNic.Name}' type={vpnNic.NetworkInterfaceType} status={vpnNic.OperationalStatus}");
|
|
}
|
|
catch (Exception ex) { Logger.Warning($"[VPN-DETECT] NIC validation failed: {ex.Message}"); }
|
|
|
|
RemoveDefaultRouteOnVpn();
|
|
_fullRouteEnabled = false;
|
|
_ = Task.Run(RunConnectivityChecks);
|
|
|
|
// NEW ARCHITECTURE (flow-based, zero-copy):
|
|
// We no longer capture/rewrite every packet. Instead we listen at the
|
|
// FLOW layer for connect events from target apps, and proactively add
|
|
// a /32 host route via the VPN adapter. Windows then natively routes
|
|
// that destination via the VPN — picking the VPN IP as source —
|
|
// without any user-mode packet handling on the data path.
|
|
_routingTask = Task.Run(() => FlowTrackingLoop(_cts.Token));
|
|
|
|
// Block outbound IPv6 from target apps at the NETWORK layer.
|
|
// The FLOW layer is observe-only (SNIFF mode) and cannot block;
|
|
// intercepting at the packet level lets us silently drop IPv6
|
|
// packets whose owning process is a target app, forcing IPv4
|
|
// fallback which gets properly tunneled via host routes.
|
|
_ipv6BlockTask = Task.Run(() => IPv6BlockLoop(_cts.Token));
|
|
|
|
// Diagnostic sniff loops stay enabled so we can verify traffic actually
|
|
// exits via VPN and that no srcIP leakage happens on the physical NIC.
|
|
_vpnSniffTask = Task.Run(() => VpnEgressSniffLoop(_cts.Token));
|
|
_physSniffTask = Task.Run(() => PhysicalEgressSniffLoop(_cts.Token));
|
|
_directSniffTask = Task.Run(() => PhysicalDirectTrafficSniffLoop(_cts.Token));
|
|
_vpnIngressTask = Task.Run(() => VpnIngressSniffLoop(_cts.Token));
|
|
|
|
// Stats reporter every 5 seconds
|
|
_statsTimer = new System.Threading.Timer(_ => ReportStats(), null,
|
|
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
|
|
|
// PACKET INTERCEPT (source-IP rewriting):
|
|
// The FLOW layer detects target-app connections and installs /32 host
|
|
// routes. However, sockets bound before the route was installed keep
|
|
// their physical source IP. The Windows strong-host model then drops
|
|
// those packets on the VPN interface (source IP mismatch). The network
|
|
// intercept loops below rewrite the source IP to the VPN IP and
|
|
// reverse-NAT the replies, making all target-app connections work.
|
|
_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.
|
|
if (EnableSocks5)
|
|
{
|
|
_socks5 = new Socks5Server(Socks5Port);
|
|
_socks5.Start(vpnLocalIp, EnsureHostRouteForSocks5);
|
|
}
|
|
}
|
|
|
|
private void ReportStats()
|
|
{
|
|
if (!_isRunning) return;
|
|
long flowEst = Interlocked.Read(ref _statFlowEstablished);
|
|
long flowDel = Interlocked.Read(ref _statFlowDeleted);
|
|
long flowHit = Interlocked.Read(ref _statFlowTargetMatched);
|
|
long flowExcl = Interlocked.Read(ref _statFlowExcluded);
|
|
long ipv6Blocked = Interlocked.Read(ref _statFlowIPv6Blocked);
|
|
long leakConfirmed = Interlocked.Read(ref _statLeakConfirmed);
|
|
long leakBlocked = Interlocked.Read(ref _statLeakBlocked);
|
|
long leakBlockedRecovered = Interlocked.Read(ref _statLeakBlockedRecovered);
|
|
long leakBlockedSuppressed = Interlocked.Read(ref _statLeakBlockedSuppressed);
|
|
long netOutRw = Interlocked.Read(ref _statNetOutRewritten);
|
|
long netInRw = Interlocked.Read(ref _statNetInRewritten);
|
|
long netOutFail = Interlocked.Read(ref _statNetOutSendFailed);
|
|
string mode = _fullRouteEnabled ? "full-route" : "split";
|
|
string leakState = leakConfirmed > 0 ? "LEAK-DETECTED" :
|
|
(leakBlocked > 0 ? "PROTECTED" : "OK");
|
|
Logger.Info(
|
|
$"[STATS] mode={mode} health={leakState} " +
|
|
$"flows={flowEst}/{flowDel} targetHit={flowHit} excluded={flowExcl} ipv6Drop={ipv6Blocked} " +
|
|
$"routes={Interlocked.Read(ref _statRoutesAdded)}({Interlocked.Read(ref _statRoutesFailed)}fail)/{_addedRoutes.Count}active " +
|
|
$"rewriteOut={netOutRw} rewriteIn={netInRw} rewriteFail={netOutFail} nat={_natTable.Count} " +
|
|
$"leakConfirmed={leakConfirmed} protectedBlocked={leakBlocked} recovered={leakBlockedRecovered} suppressed={leakBlockedSuppressed} " +
|
|
$"targets={_targetExecutables.Count} blockedApps={_blockedExecutables.Count}");
|
|
|
|
// Loop health check — warn if any background loop has exited unexpectedly
|
|
var deadLoops = new List<string>();
|
|
if (_routingTask?.IsCompleted == true) deadLoops.Add("FlowTracking");
|
|
if (_networkOutTask?.IsCompleted == true) deadLoops.Add("NetOut");
|
|
if (_networkInTask?.IsCompleted == true) deadLoops.Add("NetIn");
|
|
if (_vpnSniffTask?.IsCompleted == true) deadLoops.Add("VpnSniff");
|
|
if (_physSniffTask?.IsCompleted == true) deadLoops.Add("PhysSniff");
|
|
if (_directSniffTask?.IsCompleted == true) deadLoops.Add("DirectSniff");
|
|
if (_vpnIngressTask?.IsCompleted == true) deadLoops.Add("VpnIngress");
|
|
if (_ipv6BlockTask?.IsCompleted == true) deadLoops.Add("IPv6Block");
|
|
if (deadLoops.Count > 0)
|
|
Logger.Warning($"[HEALTH] Dead loops detected: {string.Join(", ", deadLoops)}");
|
|
|
|
// Cleanup stale NAT entries (connections closed > 2 minutes ago)
|
|
int natCleaned = 0;
|
|
var natStaleTime = DateTime.UtcNow.AddMinutes(-2);
|
|
foreach (var kv in _natTable)
|
|
if (kv.Value.LastSeen < natStaleTime)
|
|
if (_natTable.TryRemove(kv.Key, out _))
|
|
natCleaned++;
|
|
|
|
// Periodic network-state diagnostics (every ~30s) so we can tell from
|
|
// the log whether a sing-box "missing default interface" or sustained
|
|
// i/o timeout coincides with the physical NIC actually flapping.
|
|
var ticks = Interlocked.Increment(ref _diagTick);
|
|
if (ticks % 6 == 1) // 6 \u00d7 5s = 30s
|
|
{
|
|
try
|
|
{
|
|
var defGwCount = 0;
|
|
string? defNic = null;
|
|
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
|
{
|
|
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
|
|
System.Net.NetworkInformation.IPInterfaceProperties props;
|
|
try { props = nic.GetIPProperties(); } catch { continue; }
|
|
foreach (var gw in props.GatewayAddresses)
|
|
{
|
|
if (gw.Address?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
|
{
|
|
defGwCount++;
|
|
defNic ??= nic.Name;
|
|
}
|
|
}
|
|
}
|
|
Logger.Info($"[DIAG] defaultGateways={defGwCount} primaryNic='{defNic ?? "<none>"}'");
|
|
}
|
|
catch { }
|
|
}
|
|
if (natCleaned > 0)
|
|
Logger.Info($"[NET-STATS] NAT stale entries cleaned: {natCleaned}");
|
|
}
|
|
|
|
private void ConfigureDnsRedirectTarget()
|
|
{
|
|
var selected = EnableDnsOptimization
|
|
? (EnableGameMode ? IPAddress.Parse("1.1.1.1") : IPAddress.Parse("8.8.8.8"))
|
|
: IPAddress.Parse("8.8.8.8");
|
|
|
|
_dnsRedirectIp = selected;
|
|
_dnsRedirectIpBytes = selected.GetAddressBytes();
|
|
_dnsRedirectIpNbo = BitConverter.ToUInt32(_dnsRedirectIpBytes, 0);
|
|
|
|
Logger.Info($"[DNS] Redirect target={_dnsRedirectIp} optimization={EnableDnsOptimization} gameMode={EnableGameMode}");
|
|
}
|
|
|
|
private void LogNetworkInterfaces()
|
|
{
|
|
try
|
|
{
|
|
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
|
{
|
|
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up)
|
|
continue;
|
|
var props = nic.GetIPProperties();
|
|
var ipv4 = props.GetIPv4Properties();
|
|
int idx = ipv4?.Index ?? -1;
|
|
var addrs = props.UnicastAddresses
|
|
.Where(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
|
.Select(a => a.Address.ToString());
|
|
var gws = props.GatewayAddresses.Select(g => g.Address.ToString());
|
|
Logger.Info($"[NIC] idx={idx} name='{nic.Name}' type={nic.NetworkInterfaceType} ips=[{string.Join(",", addrs)}] gw=[{string.Join(",", gws)}]");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning($"LogNetworkInterfaces failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears process/ownership caches so policy changes apply immediately to
|
|
/// already-running processes.
|
|
/// </summary>
|
|
private void InvalidateProcessCaches()
|
|
{
|
|
_pidTargetOwnerCache.Clear();
|
|
_pidNameCache.Clear();
|
|
_pidParentCache.Clear();
|
|
}
|
|
|
|
private bool IsExecutableBlocked(string? executableName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(executableName))
|
|
return false;
|
|
return _blockedExecutables.ContainsKey(executableName);
|
|
}
|
|
|
|
private bool IsExecutableTargeted(string? executableName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(executableName))
|
|
return false;
|
|
if (IsExecutableBlocked(executableName))
|
|
return false;
|
|
return _targetExecutables.ContainsKey(executableName);
|
|
}
|
|
|
|
private void ResetLiveTrafficCounters()
|
|
{
|
|
Interlocked.Exchange(ref _totalVpnBytesSent, 0);
|
|
Interlocked.Exchange(ref _totalVpnBytesReceived, 0);
|
|
Interlocked.Exchange(ref _directBytesSent, 0);
|
|
Interlocked.Exchange(ref _directBytesReceived, 0);
|
|
foreach (var counter in _trafficCounters.Values)
|
|
{
|
|
Interlocked.Exchange(ref counter.BytesSent, 0);
|
|
Interlocked.Exchange(ref counter.BytesReceived, 0);
|
|
}
|
|
}
|
|
|
|
private void MarkPolicyTransitionGrace(TimeSpan duration)
|
|
{
|
|
long now = Environment.TickCount64;
|
|
long until = now + (long)duration.TotalMilliseconds;
|
|
while (true)
|
|
{
|
|
long current = Interlocked.Read(ref _policyTransitionGraceUntilTick);
|
|
if (current >= until)
|
|
return;
|
|
if (Interlocked.CompareExchange(ref _policyTransitionGraceUntilTick, until, current) == current)
|
|
return;
|
|
}
|
|
}
|
|
|
|
private bool IsPolicyTransitionGraceActive()
|
|
{
|
|
long now = Environment.TickCount64;
|
|
long until = Interlocked.Read(ref _policyTransitionGraceUntilTick);
|
|
return now < until;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reconciles routing/NAT state for one target app immediately after the user
|
|
/// toggles it in the UI. When disabled, lingering host-routes and NAT records
|
|
/// owned by that app are removed so policy changes take effect right away.
|
|
/// </summary>
|
|
private void ReconcileTargetAppPolicy(string executableName, bool enabled)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(executableName))
|
|
return;
|
|
|
|
if (enabled)
|
|
return;
|
|
|
|
var removedRouteIps = new HashSet<uint>();
|
|
foreach (var kv in _ipToProcess)
|
|
{
|
|
if (!string.Equals(kv.Value, executableName, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
uint ipNbo = kv.Key;
|
|
_ipToProcess.TryRemove(ipNbo, out _);
|
|
_ipRefCount.TryRemove(ipNbo, out _);
|
|
foreach (var flowKey in _flowOwnerByTuple.Keys.Where(k => k.remoteIp == ipNbo))
|
|
_flowOwnerByTuple.TryRemove(flowKey, out _);
|
|
_loggedMatchIps.TryRemove(ipNbo, out _);
|
|
_loggedExcludedIps.TryRemove(ipNbo, out _);
|
|
if (_pendingRouteRemoval.TryRemove(ipNbo, out var pending))
|
|
{
|
|
try { pending.Cancel(); } catch { }
|
|
}
|
|
TryRemoveHostRoute(ipNbo);
|
|
_recentLeakByDst.TryRemove(ipNbo, out _);
|
|
removedRouteIps.Add(ipNbo);
|
|
}
|
|
|
|
int natRemoved = 0;
|
|
foreach (var nat in _natTable)
|
|
{
|
|
bool remove = string.Equals(nat.Value.ProcessName, executableName, StringComparison.OrdinalIgnoreCase) ||
|
|
removedRouteIps.Contains(nat.Key.dstIp);
|
|
if (remove && _natTable.TryRemove(nat.Key, out _))
|
|
natRemoved++;
|
|
}
|
|
|
|
if (removedRouteIps.Count > 0 || natRemoved > 0)
|
|
Logger.Info($"[APP-RECONCILE] '{executableName}' disabled: removedRoutes={removedRouteIps.Count}, removedNat={natRemoved}");
|
|
}
|
|
|
|
public async Task StopAsync()
|
|
{
|
|
if (!_isRunning) return;
|
|
|
|
Logger.Info("TrafficRouter stopping...");
|
|
_isRunning = false;
|
|
_cts?.Cancel();
|
|
_statsTimer?.Dispose();
|
|
_statsTimer = null;
|
|
|
|
try { _socks5?.Stop(); } catch { }
|
|
_socks5 = null;
|
|
|
|
// Cancel all pending delayed route removals.
|
|
foreach (var kvp in _pendingRouteRemoval)
|
|
try { kvp.Value.Cancel(); } catch { }
|
|
_pendingRouteRemoval.Clear();
|
|
|
|
// Close WinDivert handles to unblock WinDivertRecv calls
|
|
CloseHandles();
|
|
|
|
if (_routingTask != null)
|
|
{
|
|
try { await _routingTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
if (_inboundTask != null)
|
|
{
|
|
try { await _inboundTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
if (_vpnSniffTask != null)
|
|
{
|
|
try { await _vpnSniffTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
if (_physSniffTask != null)
|
|
{
|
|
try { await _physSniffTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
if (_directSniffTask != null)
|
|
{
|
|
try { await _directSniffTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
if (_vpnIngressTask != null)
|
|
{
|
|
try { await _vpnIngressTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
if (_ipv6BlockTask != null)
|
|
{
|
|
try { await _ipv6BlockTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
if (_networkOutTask != null)
|
|
{
|
|
try { await _networkOutTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
if (_networkInTask != null)
|
|
{
|
|
try { await _networkInTask; }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
|
|
if (_fullRouteEnabled)
|
|
try { SetFullRouteEnabled(false); } catch (Exception ex) { Logger.Warning($"Disable full-route failed: {ex.Message}"); }
|
|
|
|
// Clean up host routes we added so we don't pollute the system routing table.
|
|
try { RemoveAllHostRoutes(); } catch (Exception ex) { Logger.Warning($"RemoveAllHostRoutes failed: {ex.Message}"); }
|
|
|
|
// Clear stale per-session state so it doesn't affect the next connection.
|
|
_natTable.Clear();
|
|
_pidNameCache.Clear();
|
|
_pidParentCache.Clear();
|
|
_pidTargetOwnerCache.Clear();
|
|
_ipToProcess.Clear();
|
|
_ipRefCount.Clear();
|
|
_flowOwnerByTuple.Clear();
|
|
_loggedMatchIps.Clear();
|
|
_loggedExcludedIps.Clear();
|
|
_recentLeakByDst.Clear();
|
|
// Note: do NOT clear _excludedEntries (user-set list) or _targetExecutables.
|
|
// Auto-added DNS-server excludes are mixed into _excludedIps; rebuild the
|
|
// _excludedIps map from _excludedEntries only, so DNS auto-excludes from
|
|
// this session are dropped (they will be re-detected on next Start()).
|
|
_excludedIps.Clear();
|
|
foreach (var ips in _excludedEntries.Values)
|
|
foreach (var nbo in ips)
|
|
_excludedIps[nbo] = true;
|
|
|
|
// Reset diagnostic counters that should not span sessions.
|
|
Interlocked.Exchange(ref _statTotalCaptured, 0);
|
|
Interlocked.Exchange(ref _statRedirected, 0);
|
|
Interlocked.Exchange(ref _statPassthrough, 0);
|
|
Interlocked.Exchange(ref _statSendFailed, 0);
|
|
Interlocked.Exchange(ref _statInboundCaptured, 0);
|
|
Interlocked.Exchange(ref _statInboundNatMatched, 0);
|
|
Interlocked.Exchange(ref _statInboundSendFailed, 0);
|
|
Interlocked.Exchange(ref _statNetOutRewritten, 0);
|
|
Interlocked.Exchange(ref _statNetInRewritten, 0);
|
|
Interlocked.Exchange(ref _statNetOutPassthrough, 0);
|
|
Interlocked.Exchange(ref _statNetOutSendFailed, 0);
|
|
Interlocked.Exchange(ref _statVpnEgressSniffed, 0);
|
|
Interlocked.Exchange(ref _statVpnEgressFromOurIp, 0);
|
|
Interlocked.Exchange(ref _statLeakConfirmed, 0);
|
|
Interlocked.Exchange(ref _statLeakBlocked, 0);
|
|
Interlocked.Exchange(ref _statLeakBlockedRecovered, 0);
|
|
Interlocked.Exchange(ref _statLeakBlockedSuppressed, 0);
|
|
Interlocked.Exchange(ref _policyTransitionGraceUntilTick, 0);
|
|
ResetLiveTrafficCounters();
|
|
Interlocked.Exchange(ref _statRoutesAdded, 0);
|
|
Interlocked.Exchange(ref _statRoutesFailed, 0);
|
|
Interlocked.Exchange(ref _statFlowEstablished, 0);
|
|
Interlocked.Exchange(ref _statFlowTargetMatched, 0);
|
|
Interlocked.Exchange(ref _statFlowDeleted, 0);
|
|
Interlocked.Exchange(ref _statFlowExcluded, 0);
|
|
Interlocked.Exchange(ref _statFlowIPv6Blocked, 0);
|
|
Interlocked.Exchange(ref _diagTick, 0);
|
|
_fullRouteEnabled = false;
|
|
_inboundRewriteCount = 0;
|
|
_redirectCount = 0;
|
|
|
|
Logger.Info($"TrafficRouter stopped. Final: routes={Interlocked.Read(ref _statRoutesAdded)} netRewrites={Interlocked.Read(ref _statNetOutRewritten)} natEntries={_natTable.Count}");
|
|
_cts?.Dispose();
|
|
_cts = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Synchronous disposal: makes a best-effort cleanup if the service is
|
|
/// still running. Calling Dispose without first awaiting StopAsync is
|
|
/// supported but will block briefly while WinDivert handles are closed
|
|
/// and background loops drain. Idempotent.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
if (_isRunning)
|
|
StopAsync().GetAwaiter().GetResult();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning($"[DISPOSE] StopAsync during Dispose threw: {ex.Message}");
|
|
}
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
private void CloseHandles()
|
|
{
|
|
lock (_handleLock)
|
|
{
|
|
int closed = 0;
|
|
if (_outboundHandle != IntPtr.Zero && _outboundHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_outboundHandle);
|
|
_outboundHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
if (_inboundHandle != IntPtr.Zero && _inboundHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_inboundHandle);
|
|
_inboundHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
if (_vpnSniffHandle != IntPtr.Zero && _vpnSniffHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_vpnSniffHandle);
|
|
_vpnSniffHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
if (_physSniffHandle != IntPtr.Zero && _physSniffHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_physSniffHandle);
|
|
_physSniffHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
if (_directSniffHandle != IntPtr.Zero && _directSniffHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_directSniffHandle);
|
|
_directSniffHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
if (_vpnIngressHandle != IntPtr.Zero && _vpnIngressHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_vpnIngressHandle);
|
|
_vpnIngressHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
if (_ipv6BlockHandle != IntPtr.Zero && _ipv6BlockHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_ipv6BlockHandle);
|
|
_ipv6BlockHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
if (_networkOutHandle != IntPtr.Zero && _networkOutHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_networkOutHandle);
|
|
_networkOutHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
if (_networkInHandle != IntPtr.Zero && _networkInHandle != new IntPtr(-1))
|
|
{
|
|
WinDivertNative.WinDivertClose(_networkInHandle);
|
|
_networkInHandle = IntPtr.Zero;
|
|
closed++;
|
|
}
|
|
Logger.Info($"[HANDLE] Closed {closed} WinDivert handles");
|
|
}
|
|
}
|
|
|
|
internal class TrafficCounter
|
|
{
|
|
public long BytesSent;
|
|
public long BytesReceived;
|
|
}
|
|
|
|
/// <summary>
|
|
/// NAT table entry: remembers the original physical source IP and interface
|
|
/// for a (protocol, srcPort) connection so replies can be reverse-translated.
|
|
/// </summary>
|
|
internal class NatEntry
|
|
{
|
|
public byte[] OriginalSrcIp = new byte[4];
|
|
public uint PhysicalIfIdx;
|
|
public string ProcessName = "";
|
|
public DateTime LastSeen;
|
|
// Set when this entry was created by a DNS-redirect rewrite.
|
|
// On inbound response the source IP is spoofed back to DnsOrigDstIp so the
|
|
// application believes the answer came from its configured DNS server.
|
|
public bool IsDnsRedirect;
|
|
public byte[]? DnsOrigDstIp;
|
|
}
|
|
|
|
private static string GetWinDivertErrorMessage(int error)
|
|
{
|
|
return error switch
|
|
{
|
|
2 => "WinDivert driver not found (WinDivert64.sys missing)",
|
|
5 => "Access denied - run as Administrator",
|
|
87 => "Invalid filter syntax",
|
|
577 => "Driver blocked by security policy",
|
|
654 => "WinDivert driver version mismatch",
|
|
1060 => "WinDivert service not installed",
|
|
1275 => "Driver blocked by Windows - disable driver signature enforcement",
|
|
_ => $"Unknown error {error}"
|
|
};
|
|
}
|
|
}
|