using System.Net;
using System.Runtime.InteropServices;
namespace AppTunnel.Services;
public partial class TrafficRouterService
{
///
/// Main packet interception loop using WinDivert.
/// Captures outbound packets, checks process ownership, redirects if needed.
///
private void OutboundRoutingLoop(CancellationToken ct)
{
try
{
// CRITICAL: Exclude VPN server IP from interception to avoid breaking the tunnel
// Also exclude loopback and local network traffic
string filter = $"outbound and (tcp or udp) and ip.DstAddr != {_vpnServerIp} and ip.DstAddr != 127.0.0.1";
Logger.Info($"WinDivert filter: {filter}");
lock (_handleLock)
{
_outboundHandle = WinDivertNative.WinDivertOpen(
filter,
WinDivertLayer.Network,
0, 0);
}
if (_outboundHandle == IntPtr.Zero || _outboundHandle == new IntPtr(-1))
{
int error = Marshal.GetLastWin32Error();
Logger.Error($"WinDivert outbound open failed: {error} ({GetWinDivertErrorMessage(error)})");
return;
}
Logger.Info("WinDivert outbound handle opened successfully");
// Tune queue params to prevent dropping/slowing other system traffic.
// WINDIVERT_PARAM_QUEUE_LENGTH=0, QUEUE_TIME=1, QUEUE_SIZE=2
WinDivertNative.WinDivertSetParam(_outboundHandle, 0, 16384); // packets
WinDivertNative.WinDivertSetParam(_outboundHandle, 1, 2000); // ms
WinDivertNative.WinDivertSetParam(_outboundHandle, 2, 33554432); // bytes (32MB)
var buffer = new byte[65535];
var addrBuf = new WinDivertAddress();
var connectionCache = new ConnectionProcessCache();
var lastCacheRefresh = DateTime.MinValue;
while (!ct.IsCancellationRequested)
{
uint readLen = 0;
bool success = WinDivertNative.WinDivertRecv(
_outboundHandle, buffer, (uint)buffer.Length, ref readLen, ref addrBuf);
if (!success)
{
if (ct.IsCancellationRequested || !_isRunning) break;
continue;
}
Interlocked.Increment(ref _statTotalCaptured);
if ((DateTime.UtcNow - lastCacheRefresh).TotalMilliseconds > 500)
{
connectionCache.Refresh();
lastCacheRefresh = DateTime.UtcNow;
}
bool shouldRedirect = false;
string? matchedProcess = null;
if (TryParseConnectionTuple(buffer, readLen, out var tuple))
{
var processName = connectionCache.GetProcessName(tuple);
if (!string.IsNullOrWhiteSpace(processName) && IsExecutableTargeted(processName))
{
shouldRedirect = true;
matchedProcess = processName;
if (_trafficCounters.TryGetValue(processName, out var counter))
{
Interlocked.Add(ref counter.BytesSent, readLen);
}
}
}
// In PassthroughMode, do NOT rewrite — just reinject unchanged.
if (shouldRedirect && !PassthroughMode && _vpnInterfaceIndex >= 0 && _vpnLocalIpBytes != null && readLen >= 20)
{
// Save original source IP (before rewrite) so inbound can reverse NAT.
var origSrc = new byte[4];
Buffer.BlockCopy(buffer, 12, origSrc, 0, 4);
var origIfIdx = addrBuf.IfIdx;
// CRITICAL: ensure Windows actually routes this destination via VPN.
// Without a host route, the default route (Wi-Fi) wins and the
// kernel drops our reinjected packet because srcIP=192.168.19.10
// doesn't belong to the egress interface (strong host model).
uint dstIpNbo = BitConverter.ToUInt32(buffer, 16);
EnsureHostRouteViaVpn(dstIpNbo, tuple.RemoteIp);
_natTable[(tuple.Protocol, tuple.LocalPort, dstIpNbo)] = new NatEntry
{
OriginalSrcIp = origSrc,
PhysicalIfIdx = origIfIdx,
ProcessName = matchedProcess!,
LastSeen = DateTime.UtcNow
};
Buffer.BlockCopy(_vpnLocalIpBytes, 0, buffer, 12, 4);
addrBuf.IfIdx = (uint)_vpnInterfaceIndex;
addrBuf.SubIfIdx = 0;
WinDivertNative.WinDivertHelperCalcChecksums(buffer, readLen, ref addrBuf, 0);
Interlocked.Increment(ref _statRedirected);
if (_redirectCount < 10)
{
_redirectCount++;
Logger.Info($"OUT REDIRECT #{_redirectCount}: {matchedProcess} {tuple.LocalIp}:{tuple.LocalPort} → {tuple.RemoteIp}:{tuple.RemotePort} (proto={tuple.Protocol}) origIf={origIfIdx} vpnIf={_vpnInterfaceIndex} srcIP rewritten to {_vpnLocalIp}");
}
}
else
{
Interlocked.Increment(ref _statPassthrough);
}
bool sent = WinDivertNative.WinDivertSend(
_outboundHandle, buffer, readLen, IntPtr.Zero, ref addrBuf);
if (!sent)
{
long f = Interlocked.Increment(ref _statSendFailed);
if (f <= 5)
{
int err = Marshal.GetLastWin32Error();
Logger.Warning($"OUT WinDivertSend failed #{f}: err={err} ({GetWinDivertErrorMessage(err)}), ifIdx={addrBuf.IfIdx}, len={readLen}");
}
}
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
if (_isRunning)
Logger.Error($"Outbound routing error: {ex.Message}");
}
}
///
/// Inbound loop that does REVERSE NAT for target app traffic returning via VPN.
/// Packets arrive with dst = VPN local IP; we rewrite dst back to the original
/// physical IP so the app's socket (bound to physical IP) receives the reply.
///
private void InboundTrackingLoop(CancellationToken ct)
{
try
{
// Only capture inbound packets addressed to the VPN local IP.
// Everything else (normal inbound traffic on physical NIC) is not touched.
string filter = $"inbound and (tcp or udp) and ip.DstAddr == {_vpnLocalIp}";
lock (_handleLock)
{
_inboundHandle = WinDivertNative.WinDivertOpen(
filter,
WinDivertLayer.Network,
0,
0); // Full modify mode (not sniff)
}
if (_inboundHandle == IntPtr.Zero || _inboundHandle == new IntPtr(-1))
{
Logger.Warning($"WinDivert inbound open failed: {Marshal.GetLastWin32Error()}. Reverse NAT disabled.");
return;
}
Logger.Info($"WinDivert inbound (reverse NAT) filter: {filter}");
// Increase queue capacity to keep up with bursty traffic.
WinDivertNative.WinDivertSetParam(_inboundHandle, 0, 16384);
WinDivertNative.WinDivertSetParam(_inboundHandle, 1, 2000);
WinDivertNative.WinDivertSetParam(_inboundHandle, 2, 33554432);
var buffer = new byte[65535];
var addrBuf = new WinDivertAddress();
while (!ct.IsCancellationRequested)
{
uint readLen = 0;
bool success = WinDivertNative.WinDivertRecv(
_inboundHandle, buffer, (uint)buffer.Length, ref readLen, ref addrBuf);
if (!success)
{
if (ct.IsCancellationRequested || !_isRunning) break;
continue;
}
Interlocked.Increment(ref _statInboundCaptured);
if (TryParseConnectionTuple(buffer, readLen, out var tuple))
{
// For inbound packets:
// tuple.LocalIp/LocalPort = srcIp/srcPort (the SERVER)
// tuple.RemoteIp/RemotePort = dstIp/dstPort (US — the client)
// NAT table is keyed by (proto, clientPort) which was the OUTBOUND srcPort.
// That equals this packet's DESTINATION port = tuple.RemotePort.
uint srvNbo = BitConverter.ToUInt32(buffer, 12); // src IP of inbound = server
var key = (tuple.Protocol, tuple.RemotePort, srvNbo);
if (!PassthroughMode && _natTable.TryGetValue(key, out var nat))
{
// Rewrite dst IP (bytes 16..19) back to the original physical IP
Buffer.BlockCopy(nat.OriginalSrcIp, 0, buffer, 16, 4);
addrBuf.IfIdx = nat.PhysicalIfIdx;
addrBuf.SubIfIdx = 0;
WinDivertNative.WinDivertHelperCalcChecksums(buffer, readLen, ref addrBuf, 0);
nat.LastSeen = DateTime.UtcNow;
if (_trafficCounters.TryGetValue(nat.ProcessName, out var counter))
Interlocked.Add(ref counter.BytesReceived, readLen);
Interlocked.Increment(ref _statInboundNatMatched);
if (_inboundRewriteCount < 10)
{
_inboundRewriteCount++;
Logger.Info($"IN REVERSE NAT #{_inboundRewriteCount}: proto={tuple.Protocol} from {tuple.LocalIp}:{tuple.LocalPort} → dst port {tuple.RemotePort} rewritten to physical (target={nat.ProcessName}, iface={nat.PhysicalIfIdx})");
}
}
else if (_inboundRewriteCount < 10)
{
// Diagnostic: log unmatched inbound VPN packets
if (Interlocked.Read(ref _statInboundCaptured) <= 10)
Logger.Info($"IN UNMATCHED: proto={tuple.Protocol} {tuple.LocalIp}:{tuple.LocalPort} → {tuple.RemoteIp}:{tuple.RemotePort} (lookup key proto={tuple.Protocol},port={tuple.RemotePort})");
}
}
bool sent = WinDivertNative.WinDivertSend(_inboundHandle, buffer, readLen, IntPtr.Zero, ref addrBuf);
if (!sent)
{
long f = Interlocked.Increment(ref _statInboundSendFailed);
if (f <= 5)
{
int err = Marshal.GetLastWin32Error();
Logger.Warning($"IN WinDivertSend failed #{f}: err={err} ({GetWinDivertErrorMessage(err)}), ifIdx={addrBuf.IfIdx}, len={readLen}");
}
}
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
if (_isRunning)
Logger.Warning($"Inbound NAT error: {ex.Message}");
}
}
private static bool TryParseConnectionTuple(byte[] packet, uint length, out ConnectionTuple tuple)
{
tuple = default;
if (length < 20) return false;
byte version = (byte)((packet[0] >> 4) & 0xF);
if (version != 4) return false;
int headerLength = (packet[0] & 0xF) * 4;
byte protocol = packet[9];
var srcIp = new IPAddress(new ReadOnlySpan(packet, 12, 4));
var dstIp = new IPAddress(new ReadOnlySpan(packet, 16, 4));
if (protocol == 6 && length >= headerLength + 4) // TCP
{
ushort srcPort = (ushort)((packet[headerLength] << 8) | packet[headerLength + 1]);
ushort dstPort = (ushort)((packet[headerLength + 2] << 8) | packet[headerLength + 3]);
tuple = new ConnectionTuple(protocol, srcIp, srcPort, dstIp, dstPort);
return true;
}
else if (protocol == 17 && length >= headerLength + 4) // UDP
{
ushort srcPort = (ushort)((packet[headerLength] << 8) | packet[headerLength + 1]);
ushort dstPort = (ushort)((packet[headerLength + 2] << 8) | packet[headerLength + 3]);
tuple = new ConnectionTuple(protocol, srcIp, srcPort, dstIp, dstPort);
return true;
}
return false;
}
}