diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c339ed..b4064eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,7 @@ jobs: scripts/network/test-bridge-transaction.ps1 scripts/network/test-bridge-datapath.ps1 scripts/network/test-bridge-native.ps1 + scripts/network/test-npcap-frames.ps1 - name: Test bridge lab safety in Windows PowerShell shell: powershell @@ -92,6 +93,7 @@ jobs: scripts/network/test-bridge-transaction.ps1 scripts/network/test-bridge-datapath.ps1 scripts/network/test-bridge-native.ps1 + scripts/network/test-npcap-frames.ps1 guest-contract: name: Guest contract diff --git a/docs/LAN-BRIDGE.md b/docs/LAN-BRIDGE.md index 160a6cd..1d4cc5c 100644 --- a/docs/LAN-BRIDGE.md +++ b/docs/LAN-BRIDGE.md @@ -159,6 +159,79 @@ not establish behavior on physical Ethernet, supported Windows 10 builds, Secure Boot/HVCI, DHCP servers that rely on `chaddr`, or multiple guests. NAT and existing LAN forwarding remain the supported launcher paths. +## Npcap forwarding experiment + +The native Windows bridge's MAC translation and raw EtherType limit are separate +from the TAP attachment failure. A disposable lab now also has a user-mode +forwarding path using manually installed [Npcap 1.89](https://npcap.com/). +Its running driver has a valid Microsoft signature. The +[pinned installed files](../scripts/network/npcap-1.89.lock.json) include the +kernel driver and signed Nmap DLLs. The helper verifies those files and the +running service before loading from the system Npcap directory. + +Npcap is a separate dependency. Its [license](https://npcap.com/guide/#npcap-license) +allows limited end-user installations and prohibits redistribution without +permission. No installer, driver or DLL is bundled here. The helper never +downloads, installs, upgrades or silently configures Npcap. This experiment +does not establish a redistribution or product licensing decision. + +The dedicated TAP must have a saved binding baseline and IPv4/IPv6 already +unbound by an explicit administrator lab action. Start a diskless QEMU fixture +with that TAP, a fixed guest MAC and a separate private NAT service NIC. Keep +an independent management adapter and local console available. Do not create a +native Windows bridge at the same time. Then use a fresh administrator +PowerShell process: + +```powershell +powershell.exe -NoProfile -File scripts/network/npcap-lab.ps1 ` + -WiredGuid '' -WiredPnp '' ` + -TapGuid '' -TapPnp '' ` + -GuestMac '52:54:00:16:66:01' ` + -DriverDirectory C:\BridgeLab\dist.win10\amd64 ` + -QemuProcessId -QemuExecutable C:\BridgeLab\qemu-system-x86_64w.exe ` + -ProbeName bridge-peer.example -ProbeAddress 192.0.2.1 -ProbePort 443 ` + -Seconds 60 -DisposableLab -LocalConsole -DedicatedTap +``` + +Require Npcap's administrator-only option, without wireless capture or WinPcap +compatibility. Exact GUID/PNP, wired DHCP/DNS/TCP, pinned TAP driver, running +QEMU, its selected TAP and guest MAC, existing-bridge restrictions and binding +checks gate startup. A per-TAP mutex rejects another helper. Link loss, +identity/binding changes or QEMU exit stop forwarding. The duration is bounded +to 600 seconds. Native handles close after workers stop; an unresponsive worker +terminates the lab process instead of closing a handle under it. Process exit +releases capture handles. This helper changes no bindings, DHCP, DNS, routes, +firewall or offload settings, and has no automatic adapter or NAT fallback. +After it exits, stop the owned fixture and restore the saved dedicated-TAP +bindings or remove that exact owned device. Do not uninstall a shared driver. + +The pump forwards only the fixed guest's source frames and peer frames addressed +to that guest or to broadcast/multicast. It preserves Ethernet MACs. Per-handle +receive injection delivers guest frames to Windows without a system-wide +Npcap registry change. Windows host captures can contain unfinished hardware +checksums. The pump completes IPv4, TCP, UDP and ICMP checksums on captured host +frames before sending them through TAP. Peer and guest raw Ethernet payloads +are not rewritten. Packet tests include a captured pre-offload SYN and its +hardware-completed wire vector, IPv4/IPv6 UDP, VLAN preservation, malformed +frames and startup arguments. Both PowerShell dialects run these checks in CI. + +The native helper passed unchanged guest Ethernet MAC and DHCP `chaddr`, a +bidirectional experimental EtherType `0x88b5` exchange, LAN DHCP/DNS/TCP, direct +Windows and peer TCP, IPv4 broadcast/multicast, explicit-address IPv6 ping and +private services. Exact-GUID adapter rename preserved forwarding. Virtual +Ethernet loss stopped the helper and retained independent management access; +reconnection and a fresh helper run recovered forwarding. These are controlled +virtual-Ethernet results. + +This is a bounded single-guest lab helper, not the launcher bridge feature. +Host large-send segmentation, fragmented host traffic, IPv6 routing/fragment/IPsec +headers, throughput, VLAN wire behavior, sleep and broader adapter/version +coverage remain unaccepted. Unsupported captured host packets stop the helper +rather than changing the NIC's offload configuration. Physical wired Ethernet, +Secure Boot/HVCI, Windows 10, administrator cancellation and normal guest +integration still need validation. `BridgeAccepted` remains false. NAT and +existing forwarding remain the launcher defaults. + ## Launcher integration still required Keep the LAN NIC separate from a private NAT service NIC. Existing guest diff --git a/scripts/network/NpcapFramePump.cs b/scripts/network/NpcapFramePump.cs new file mode 100644 index 0000000..7c472d2 --- /dev/null +++ b/scripts/network/NpcapFramePump.cs @@ -0,0 +1,395 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.IO; + +// Disposable Windows lab helper. No driver installation or adapter configuration. +public static class NpcapFramePump +{ + public sealed class Result + { + public int GuestOut; + public int PeerIn; + public int Raw; + } + + static int active; + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool FreeLibrary(IntPtr library); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate int LinkType(IntPtr cap); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate IntPtr Version(); + static LinkType linkType; + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags); + [DllImport("kernel32.dll", CharSet = CharSet.Ansi, SetLastError = true)] + static extern IntPtr GetProcAddress(IntPtr lib, string name); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate IntPtr Open(string dev, int snap, int promisc, int ms, StringBuilder err); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate int Next(IntPtr cap, out IntPtr header, out IntPtr data); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate int Send(IntPtr cap, byte[] data, int size); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate int Nonblock(IntPtr cap, int value, StringBuilder err); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate int Compile(IntPtr cap, ref Program filter, string expression, int optimize, uint mask); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate int Setfilter(IntPtr cap, ref Program filter); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate void Freecode(ref Program filter); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate void Close(IntPtr cap); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate IntPtr Error(IntPtr cap); + [StructLayout(LayoutKind.Sequential)] + struct Program + { + public uint length; + public IntPtr instructions; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + delegate int Mode(IntPtr cap, int mode); + static Mode mode; + static Open open; + static Next next; + static Send send; + static Nonblock nonblock; + static Compile compile; + static Setfilter setfilter; + static Freecode freecode; + static Close close; + static Error error; + static T Function(IntPtr lib, string name) + { + IntPtr p = GetProcAddress(lib, name); + if (p == IntPtr.Zero) + throw new Exception("Missing Npcap export: " + name); + return (T)(object)Marshal.GetDelegateForFunctionPointer(p, typeof(T)); + } + + static IntPtr Capture(string guid, string filter, bool reading) + { + StringBuilder err = new StringBuilder(512); + IntPtr cap = open(@"\Device\NPF_{" + new Guid(guid).ToString().ToUpperInvariant() + "}", 65535, 1, 100, err); + if (cap == IntPtr.Zero) + throw new Exception("Npcap open failed: " + err); + try + { + if (linkType(cap) != 1) + throw new Exception("Selected adapter is not Ethernet"); + if (!reading) + { + if (mode(cap, 0x200) != 0) + throw new Exception("Cannot force transmit injection"); + return cap; + } + + Program bpf = new Program(); + if (compile(cap, ref bpf, filter, reading ? 1 : 0, 0xffffffff) != 0) + throw new Exception("Filter compile: " + Marshal.PtrToStringAnsi(error(cap))); + try + { + if (setfilter(cap, ref bpf) != 0) + throw new Exception("Filter install: " + Marshal.PtrToStringAnsi(error(cap))); + } + finally + { + freecode(ref bpf); + } + + if (reading && nonblock(cap, 1, err) != 0) + throw new Exception("Nonblocking capture failed: " + err); + return cap; + } + catch + { + close(cap); + throw; + } + } + + public static Result Run(string wired, string tap, string guestMac, string wiredMac, int seconds, Action guard) + { + if (seconds < 1 || seconds > 600) + throw new ArgumentException("Duration must be 1 to 600 seconds"); + if (new Guid(wired) == new Guid(tap)) + throw new ArgumentException("Select two distinct adapters"); + byte[] mac = ParseMac(guestMac), host = ParseMac(wiredMac); + if (guestMac.Equals(wiredMac, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Guest and wired MACs must differ"); + if (guard == null) + throw new ArgumentNullException("guard"); + guard(); + if (Interlocked.CompareExchange(ref active, 1, 0) != 0) + throw new InvalidOperationException("A pump is already running in this process"); + IntPtr library = IntPtr.Zero; + try + { + library = LoadLibraryEx(Path.Combine(Environment.SystemDirectory, @"Npcap\wpcap.dll"), IntPtr.Zero, 0x100 | 0x800); + if (library == IntPtr.Zero) + throw new Exception("Trusted Npcap DLL load failed: " + Marshal.GetLastWin32Error()); + linkType = Function(library, "pcap_datalink"); + string version = Marshal.PtrToStringAnsi(Function(library, "pcap_lib_version")()); + if (version == null || !version.StartsWith("Npcap version 1.89,")) + throw new Exception("Pinned Npcap 1.89 runtime required"); + mode = Function(library, "pcap_setmode"); + open = Function(library, "pcap_open_live"); + next = Function(library, "pcap_next_ex"); + send = Function(library, "pcap_sendpacket"); + nonblock = Function(library, "pcap_setnonblock"); + compile = Function(library, "pcap_compile"); + setfilter = Function(library, "pcap_setfilter"); + freecode = Function(library, "pcap_freecode"); + close = Function(library, "pcap_close"); + error = Function(library, "pcap_geterr"); + string text = BitConverter.ToString(mac).Replace('-', ':'); + IntPtr[] h = new IntPtr[5]; + int[] count = new int[2]; + int raw = 0, stop = 0; + Exception failure = null; + Thread[] threads = new Thread[2]; + Stopwatch timer = Stopwatch.StartNew(); + try + { + h[0] = Capture(tap, "ether src " + text, true); + h[1] = Capture(wired, "0 = 1", false); + h[2] = Capture(wired, "(ether dst " + text + " or ether multicast) and not ether src " + text, true); + h[3] = Capture(tap, "0 = 1", false); + h[4] = Capture(wired, "0 = 1", false); + if (mode(h[4], 0x100) != 0) + throw new Exception("Receive-path injection unavailable"); + for (int direction = 0; direction < 2; direction++) + { + int d = direction; + threads[d] = new Thread(delegate () + { + try + { + while (Volatile.Read(ref stop) == 0 && Volatile.Read(ref failure) == null) + { + IntPtr header, data; + int n = next(h[d * 2], out header, out data); + if (n == 0) + { + Thread.Sleep(5); + continue; + } + + if (n < 0) + throw new Exception("Capture terminated: " + n); + int captured = Marshal.ReadInt32(header, 8), length = Marshal.ReadInt32(header, 12); + if (captured != length || length < 14 || length > 9022) + throw new Exception("Truncated or oversized Ethernet frame"); + byte[] frame = new byte[length]; + Marshal.Copy(data, frame, 0, length); + if (d == 0) + { + for (int m = 0; m < 6; m++) + if (frame[6 + m] != mac[m]) + throw new Exception("Unexpected guest source MAC"); + } + + if (d == 1) + { + bool fromHost = true; + for (int m = 0; m < 6; m++) + if (frame[6 + m] != host[m]) + fromHost = false; + if (fromHost) + CompleteHostChecksums(frame); + } + + bool toHost = d == 0; + for (int m = 0; m < 6 && toHost; m++) + if (frame[m] != host[m]) + toHost = false; + if (!toHost && send(h[d * 2 + 1], frame, length) != 0) + throw new Exception("Transmit injection failed: " + Marshal.PtrToStringAnsi(error(h[d * 2 + 1]))); + if (d == 0 && (toHost || (frame[0] & 1) != 0) && send(h[4], frame, length) != 0) + throw new Exception("Host receive injection failed: " + Marshal.PtrToStringAnsi(error(h[4]))); + Interlocked.Increment(ref count[d]); + if (frame[12] == 0x88 && frame[13] == 0xb5) + Interlocked.Increment(ref raw); + } + } + catch (Exception e) + { + Interlocked.CompareExchange(ref failure, e, null); + } + }); + threads[d].IsBackground = true; + threads[d].Start(); + } + + while (timer.Elapsed.TotalSeconds < seconds && Volatile.Read(ref failure) == null) + { + guard(); + Thread.Sleep(250); + } + + } + finally + { + Interlocked.Exchange(ref stop, 1); + // Never close a native handle while its worker may still be using it. + foreach (Thread thread in threads) + if (thread != null && !thread.Join(5000)) + Environment.FailFast("Npcap worker did not stop; terminating the lab process to release driver handles"); + foreach (IntPtr p in h) + if (p != IntPtr.Zero) + close(p); + } + if (failure != null) + throw failure; + return new Result { GuestOut = count[0], PeerIn = count[1], Raw = raw }; + } + finally + { + if (library != IntPtr.Zero) + FreeLibrary(library); + Interlocked.Exchange(ref active, 0); + } + } + + public static byte[] ParseMac(string text) + { + if (text == null) + throw new ArgumentException("Missing MAC"); + string[] parts = text.Split(':'); + if (parts.Length != 6) + throw new ArgumentException("MAC must have six colon-separated bytes"); + byte[] mac = new byte[6]; + bool zero = true; + for (int i = 0; i < 6; i++) + { + if (parts[i].Length != 2 || !Byte.TryParse(parts[i], System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out mac[i])) + throw new ArgumentException("Invalid MAC"); + if (mac[i] != 0) + zero = false; + } + + if (zero || (mac[0] & 1) != 0) + throw new ArgumentException("MAC must be nonzero and unicast"); + return mac; + } + + public static void CompleteHostChecksums(byte[] frame) + { + if (frame == null || frame.Length < 14) + throw new ArgumentException("Short Ethernet frame"); + int offset = 14, type = Read16(frame, 12); + for (int tags = 0; type == 0x8100 || type == 0x88a8; tags++) + { + if (tags == 2 || frame.Length < offset + 4) + throw new ArgumentException("Invalid VLAN header"); + type = Read16(frame, offset + 2); + offset += 4; + } + + int protocol, start, length; + uint pseudo = 0; + if (type == 0x0800) + { + if (frame.Length < offset + 20 || (frame[offset] >> 4) != 4) + throw new ArgumentException("Invalid IPv4 header"); + int header = (frame[offset] & 15) * 4, total = Read16(frame, offset + 2); + if (header < 20 || total < header || offset + total > frame.Length) + throw new ArgumentException("Unsupported host segmentation or truncated IPv4 packet"); + if ((Read16(frame, offset + 6) & 0x3fff) != 0) + throw new ArgumentException("Fragmented host packet is unsupported in this lab"); + Write16(frame, offset + 10, 0); + Write16(frame, offset + 10, Checksum(frame, offset, header, 0)); + protocol = frame[offset + 9]; + start = offset + header; + length = total - header; + if (protocol == 6 || protocol == 17) + pseudo = Sum(frame, offset + 12, 8) + (uint)protocol + (uint)length; + } + else if (type == 0x86dd) + { + if (frame.Length < offset + 40 || (frame[offset] >> 4) != 6) + throw new ArgumentException("Invalid IPv6 header"); + length = Read16(frame, offset + 4); + protocol = frame[offset + 6]; + start = offset + 40; + if (length == 0 || start + length > frame.Length) + throw new ArgumentException("Unsupported host segmentation or truncated IPv6 packet"); + int count = 0; + while (protocol == 0 || protocol == 60) + { + if (++count > 8 || length < 8) + throw new ArgumentException("Invalid IPv6 extension header"); + int extension = (frame[start + 1] + 1) * 8; + if (extension > length) + throw new ArgumentException("Truncated IPv6 extension header"); + protocol = frame[start]; + start += extension; + length -= extension; + } + + if (protocol == 43 || protocol == 44 || protocol == 51 || protocol == 50) + throw new ArgumentException("Host IPv6 routing, fragment and IPsec headers are unsupported in this lab"); + pseudo = Sum(frame, offset + 8, 32) + (uint)protocol + (uint)length; + } + else + return; + int checksum; + if (protocol == 6) + { + if (length < 20 || (frame[start + 12] >> 4) * 4 < 20 || (frame[start + 12] >> 4) * 4 > length) + throw new ArgumentException("Invalid host TCP segment"); + checksum = start + 16; + } + else if (protocol == 17) + { + if (length < 8 || Read16(frame, start + 4) != length) + throw new ArgumentException("Invalid host UDP datagram"); + checksum = start + 6; + } + else if ((type == 0x0800 && protocol == 1) || (type == 0x86dd && protocol == 58)) + { + if (length < 4) + throw new ArgumentException("Invalid host ICMP message"); + checksum = start + 2; + } + else + return; + Write16(frame, checksum, 0); + ushort result = Checksum(frame, start, length, pseudo); + if (protocol == 17 && result == 0) + result = 0xffff; + Write16(frame, checksum, result); + } + + static int Read16(byte[] bytes, int offset) + { + return (bytes[offset] << 8) | bytes[offset + 1]; + } + + static void Write16(byte[] bytes, int offset, int value) + { + bytes[offset] = (byte)(value >> 8); + bytes[offset + 1] = (byte)value; + } + + static uint Sum(byte[] bytes, int offset, int length) + { + uint sum = 0; + for (int i = 0; i < length; i += 2) + sum += (uint)(bytes[offset + i] << 8) + (uint)(i + 1 < length ? bytes[offset + i + 1] : 0); + return sum; + } + + static ushort Checksum(byte[] bytes, int offset, int length, uint seed) + { + uint sum = seed + Sum(bytes, offset, length); + while ((sum >> 16) != 0) + sum = (sum & 0xffff) + (sum >> 16); + return (ushort)~sum; + } +} diff --git a/scripts/network/npcap-1.89.lock.json b/scripts/network/npcap-1.89.lock.json new file mode 100644 index 0000000..66795fc --- /dev/null +++ b/scripts/network/npcap-1.89.lock.json @@ -0,0 +1,10 @@ +{ + "version": "1.89", + "source": "https://npcap.com/dist/npcap-1.89.exe", + "installerSha256": "8aed85e900d783d1308506e919587d3e540451947af8a82f2d04f819e44305cc", + "files": { + "drivers/npcap.sys": "ecd194d91257fa145a6100065f1c98d599e73437355f939929ccb027c2c69004", + "Npcap/wpcap.dll": "aa2c63a5a0b732e2aaec6660f5d97727d857fcfd315084940859e5fe71a65c24", + "Npcap/Packet.dll": "1181ba48394dfd64e281d43850e41355bd6a0ed2523819980820221c0be09d74" + } +} diff --git a/scripts/network/npcap-lab.ps1 b/scripts/network/npcap-lab.ps1 new file mode 100644 index 0000000..cb12d50 --- /dev/null +++ b/scripts/network/npcap-lab.ps1 @@ -0,0 +1,91 @@ +# Explicit forwarding experiment. Does not install drivers or mutate networking. +[CmdletBinding()] +param( + [Parameter(Mandatory)][guid]$WiredGuid, + [Parameter(Mandatory)][string]$WiredPnp, + [Parameter(Mandatory)][guid]$TapGuid, + [Parameter(Mandatory)][string]$TapPnp, + [Parameter(Mandatory)][ValidatePattern('^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')][string]$GuestMac, + [Parameter(Mandatory)][string]$DriverDirectory, + [Parameter(Mandatory)][int]$QemuProcessId, + [Parameter(Mandatory)][string]$QemuExecutable, + [Parameter(Mandatory)][string]$ProbeName, + [Parameter(Mandatory)][string]$ProbeAddress, + [Parameter(Mandatory)][ValidateRange(1,65535)][int]$ProbePort, + [ValidateRange(1,600)][int]$Seconds = 60, + [switch]$DisposableLab, + [switch]$LocalConsole, + [switch]$DedicatedTap +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) { throw 'Native x64 Windows PowerShell is required.' } +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run explicitly as administrator. This helper never elevates.' } +if (-not $DisposableLab -or -not $LocalConsole -or -not $DedicatedTap -or $env:SSH_CONNECTION -or $env:SSH_CLIENT -or $env:SESSIONNAME -like 'RDP-*') { throw 'A disposable wired lab, independent local console and dedicated TAP are required.' } +if ($WiredGuid -eq $TapGuid -or -not $WiredPnp -or -not $TapPnp) { throw 'Select distinct adapters with exact PNP identities.' } +Import-Module (Join-Path $PSScriptRoot 'bridge-native.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'bridge-preflight.psm1') -Force +$snapshot = Get-BridgeHostSnapshot $DriverDirectory +# The native bridge assessment requires idle TAP. Here QEMU must already own +# and open the dedicated TAP. Every other restriction remains applicable. +$assessment = Get-BridgeLabAssessment -Snapshot $snapshot -WiredGuid $WiredGuid -TapGuid $TapGuid -DisposableLab -LocalConsole -DedicatedTap +$blockers = @($assessment.Blockers | Where-Object { $_ -ne 'tap-not-idle' }) +if ($blockers.Count) { throw ('Lab preflight failed: ' + ($blockers -join ', ')) } +$wired = & (Get-Module bridge-native) { param($g,$p) Get-SelectedAdapter $g $p } $WiredGuid.ToString() $WiredPnp +$tap = & (Get-Module bridge-native) { param($g,$p) Get-SelectedAdapter $g $p } $TapGuid.ToString() $TapPnp +& (Get-Module bridge-native) { param($a) Assert-InstalledTapDriver $a } $tap +$wiredMac = ([string]$wired.MacAddress).Replace('-',':').ToLowerInvariant() +if ($wiredMac -eq $GuestMac.ToLowerInvariant()) { throw 'Guest and wired MACs must differ.' } +$bindings = & (Get-Module bridge-native) { param($a) Get-SelectedBindings $a } $tap +if (@($bindings | Where-Object { $_.Enabled -and $_.ComponentID -in @('ms_tcpip','ms_tcpip6') }).Count) { throw 'The dedicated TAP must already have IPv4 and IPv6 unbound, with a saved recovery baseline. This helper will not change bindings.' } +$journal = Join-Path $env:ProgramData 'TryOmarchyBridgeLab\operation.json' +if ((Test-Path -LiteralPath $journal) -and (Get-Content -LiteralPath $journal -Raw | ConvertFrom-Json).Phase -ne 'Complete') { throw 'Recover the native bridge journal first.' } +$other = @(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and ([guid]$_.InterfaceGuid) -notin @($WiredGuid,$TapGuid) }) +if (-not $other.Count) { throw 'An independent management adapter must remain up.' } +$lock = Get-Content (Join-Path $PSScriptRoot 'npcap-1.89.lock.json') -Raw | ConvertFrom-Json +$system = [Environment]::SystemDirectory +foreach ($entry in $lock.files.PSObject.Properties) { + $path = Join-Path $system $entry.Name + if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $entry.Value -or (Get-AuthenticodeSignature -LiteralPath $path).Status -ne 'Valid') { throw ('Untrusted or unpinned Npcap file: ' + $entry.Name) } +} +$drivers = @(Get-CimInstance Win32_SystemDriver -Filter "Name='npcap'") +if ($drivers.Count -ne 1 -or $drivers[0].State -ne 'Running' -or ([IO.Path]::GetFullPath(([string]$drivers[0].PathName).Trim('"'))) -ne (Join-Path $system 'drivers\npcap.sys')) { throw 'Pinned Npcap kernel driver is not running at its expected path.' } +$options = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\npcap\Parameters' +if ($options.AdminOnly -ne 1 -or $options.Dot11Support -ne 0 -or $options.WinPcapCompatible -ne 0) { throw 'Npcap requires administrator-only access, without wireless capture or WinPcap compatibility.' } +$probe = [pscustomobject]@{ ProbeName=$ProbeName; ProbeAddress=$ProbeAddress; ProbePort=$ProbePort } +if (-not (& (Get-Module bridge-native) { param($g,$r) Test-WiredProbe $g $r 5 } $WiredGuid.ToString() $probe)) { throw 'Selected host DHCP, DNS or TCP baseline failed.' } +$qemu = Get-CimInstance Win32_Process -Filter "ProcessId=$QemuProcessId" +if (-not $qemu -or $qemu.ExecutablePath -ne $QemuExecutable -or $qemu.Name -notmatch '^qemu-system-x86_64w?\.exe$') { throw 'Selected QEMU process identity does not match.' } +$qemuCreation = $qemu.CreationDate +$tapName = [regex]::Escape([string]$tap.Name) +$tapArgument = '-netdev\s+tap,[^\s]*ifname="?' + $tapName + '(?:"|\s|$)' +if ($qemu.CommandLine -notmatch $tapArgument -or $qemu.CommandLine -notmatch ('mac=' + [regex]::Escape($GuestMac))) { throw 'QEMU must already open the selected TAP with the fixed guest MAC.' } +$guard = [Action]{ + $currentWired = & (Get-Module bridge-native) { param($g,$p) Get-SelectedAdapter $g $p } $WiredGuid.ToString() $WiredPnp + $currentTap = & (Get-Module bridge-native) { param($g,$p) Get-SelectedAdapter $g $p } $TapGuid.ToString() $TapPnp + if ($currentWired.Status -ne 'Up' -or $currentTap.Status -ne 'Up' -or ([string]$currentWired.MacAddress).Replace('-',':').ToLowerInvariant() -ne $wiredMac) { throw 'Selected adapter lost its link or identity. Forwarding stopped.' } + foreach ($adapter in @($currentWired,$currentTap)) { + $currentBindings = & (Get-Module bridge-native) { param($a) Get-SelectedBindings $a } $adapter + $ids = @($currentBindings | Where-Object Enabled | Select-Object -ExpandProperty ComponentID) + if ('nmap_npcap' -notin $ids -or 'vms_pp' -in $ids -or (Test-BridgeBinding $ids)) { throw 'Selected adapter bindings changed. Forwarding stopped.' } + } + $currentQemu = Get-CimInstance Win32_Process -Filter "ProcessId=$QemuProcessId" + if (-not $currentQemu -or $currentQemu.CreationDate -ne $qemuCreation -or $currentQemu.ExecutablePath -ne $QemuExecutable) { throw 'Owned QEMU process ended or changed. Forwarding stopped.' } +} +$created = $false +$mutex = [Threading.Mutex]::new($false, ('Global\TryOmarchyNpcapLab-' + $TapGuid.ToString()), [ref]$created) +$held = $false +try { + try { $held = $mutex.WaitOne(0) } catch [Threading.AbandonedMutexException] { $held = $true } + if (-not $held) { throw 'Another frame pump owns this TAP.' } + $guard.Invoke() + if ('NpcapFramePump' -as [type]) { throw 'Run this helper in a fresh PowerShell process to avoid stale loaded code.' } + Add-Type -Path (Join-Path $PSScriptRoot 'NpcapFramePump.cs') + $result = [NpcapFramePump]::Run($WiredGuid.ToString(),$TapGuid.ToString(),$GuestMac,$wiredMac,$Seconds,$guard) + if (-not (& (Get-Module bridge-native) { param($g,$r) Test-WiredProbe $g $r 5 } $WiredGuid.ToString() $probe)) { throw 'Selected host connectivity failed after forwarding.' } + [pscustomobject]@{ GuestOut=$result.GuestOut; PeerIn=$result.PeerIn; Raw=$result.Raw; BridgeAccepted=$false } +} finally { + if ($held) { $mutex.ReleaseMutex() } + $mutex.Dispose() +} diff --git a/scripts/network/test-npcap-frames.ps1 b/scripts/network/test-npcap-frames.ps1 new file mode 100644 index 0000000..183c458 --- /dev/null +++ b/scripts/network/test-npcap-frames.ps1 @@ -0,0 +1,84 @@ +# Pure packet and argument checks. Never opens a device or touches a session. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -Path (Join-Path $PSScriptRoot 'NpcapFramePump.cs') +$script:checks = 0 +function Bytes([string]$Hex) { + [byte[]]$data = New-Object byte[] ($Hex.Length / 2) + for ($i=0; $i -lt $data.Length; $i++) { $data[$i] = [Convert]::ToByte($Hex.Substring($i*2,2),16) } + return ,$data +} +function Hex([byte[]]$Data) { ([BitConverter]::ToString($Data)).Replace('-','').ToLowerInvariant() } +function Assert([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message }; $script:checks++ +} +function Reject([scriptblock]$Action, [string]$Message) { + $failed = $false + try { & $Action | Out-Null } catch { $failed = $true } + Assert $failed $Message +} +foreach ($path in @(Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.ps1')) { + $tokens=$null; $errors=$null + [Management.Automation.Language.Parser]::ParseFile($path.FullName,[ref]$tokens,[ref]$errors) | Out-Null + Assert ($errors.Count -eq 0) ('PowerShell parse errors: '+$path.Name) +} +# Windows pre-offload SYN and the corresponding hardware-completed wire frame. +$inputHex = '5254001666015254001660010800450000341ef2400080060000c000021ac000021bfb971f920295173a000000008002ffff845c0000020405b40103030801010402' +$wireHex = '5254001666015254001660010800450000341ef240008006579cc000021ac000021bfb971f920295173a000000008002ffffb5e10000020405b40103030801010402' +$frame = Bytes $inputHex +[NpcapFramePump]::CompleteHostChecksums($frame) +Assert ((Hex $frame) -eq $wireHex) 'Captured host SYN did not match the hardware wire checksums' +[NpcapFramePump]::CompleteHostChecksums($frame) +Assert ((Hex $frame) -eq $wireHex) 'Valid host SYN changed on a second pass' +foreach ($tag in @('81000001','88a8002381000011')) { + $tagged = Bytes ($inputHex.Substring(0,24)+$tag+$inputHex.Substring(24)) + [NpcapFramePump]::CompleteHostChecksums($tagged) + Assert ((Hex $tagged) -eq ($wireHex.Substring(0,24)+$tag+$wireHex.Substring(24))) 'VLAN bytes or checksums changed incorrectly' +} +foreach ($etherType in @('88b5','0806','88cc')) { + $raw = Bytes ('ffffffffffff525400166601'+$etherType+'4269646972656374696f6e616c45746865726e6574') + $original = Hex $raw + [NpcapFramePump]::CompleteHostChecksums($raw) + Assert ((Hex $raw) -eq $original) 'Non-IP Ethernet payload was modified' +} +# Independently calculated IPv4 UDP odd-payload vector. +$udp = Bytes 'ffffffffffff52540016600108004500001f1234000040110000c000021ac000021b30390035000b0000616263' +[NpcapFramePump]::CompleteHostChecksums($udp) +Assert ((Hex $udp) -eq 'ffffffffffff52540016600108004500001f123400004011e464c000021ac000021b30390035000b86d1616263') 'IPv4 UDP checksum vector mismatch' +# IPv6 UDP with a three-byte payload and hop-by-hop extension. +$udp6 = Bytes '33330000000152540016600186dd6000000000130040fd000166000000000000000000000026fd000166000000000000000000000027110000000000000030390035000b0000616263' +[NpcapFramePump]::CompleteHostChecksums($udp6) +Assert ((Hex $udp6).EndsWith('30390035000b0ded616263')) 'IPv6 extension UDP checksum vector mismatch' +foreach ($mutate in @( + {param($f) $f[14]=0x44}, + {param($f) $f[16]=0; $f[17]=0}, + {param($f) $f[16]=0xff; $f[17]=0xff}, + {param($f) $f[20]=0x20}, + {param($f) $f[46]=0x10} +)) { + $bad = Bytes $inputHex + & $mutate $bad + Reject { [NpcapFramePump]::CompleteHostChecksums($bad) } 'Unsupported host packet was accepted' +} +Reject { [NpcapFramePump]::CompleteHostChecksums((New-Object byte[] 13)) } 'Short Ethernet header accepted' +Reject { [NpcapFramePump]::CompleteHostChecksums((Bytes 'ffffffffffff5254001660018100')) } 'Truncated VLAN accepted' +foreach ($mac in @('00:00:00:00:00:00','ff:ff:ff:ff:ff:ff','01:00:00:00:00:01','52:54:00:16:66','52:54:00:16:66:gg','5:54:00:16:66:01')) { + Reject { [NpcapFramePump]::ParseMac($mac) } ('Invalid MAC accepted: '+$mac) +} +Assert ((Hex ([NpcapFramePump]::ParseMac('52:54:00:16:66:01'))) -eq '525400166601') 'Fixed unicast MAC rejected' +function RejectMessage([scriptblock]$Action,[string]$Expected) { + $message = '' + try { & $Action | Out-Null } catch { $message = $_.Exception.ToString() } + Assert ($message.Contains($Expected)) ('Expected rejection: '+$Expected) +} +$wired='00000000-0000-0000-0000-000000000001' +$tap='00000000-0000-0000-0000-000000000002' +$script:guardCalled = $false +$never = [Action]{ $script:guardCalled=$true; throw 'Unexpected guard call' } +RejectMessage { [NpcapFramePump]::Run($wired,$tap,'52:54:00:16:66:01','52:54:00:16:60:01',0,$never) } 'Duration must be' +RejectMessage { [NpcapFramePump]::Run($wired,$tap,'52:54:00:16:66:01','52:54:00:16:60:01',601,$never) } 'Duration must be' +RejectMessage { [NpcapFramePump]::Run($wired,$wired,'52:54:00:16:66:01','52:54:00:16:60:01',1,$never) } 'distinct adapters' +RejectMessage { [NpcapFramePump]::Run($wired,$tap,'52:54:00:16:66:01','52:54:00:16:66:01',1,$never) } 'MACs must differ' +Assert (-not $script:guardCalled) 'Invalid arguments reached the device guard' +RejectMessage { [NpcapFramePump]::Run($wired,$tap,'52:54:00:16:66:01','52:54:00:16:60:01',1,[Action]{throw 'Adapter absent'}) } 'Adapter absent' +"$script:checks Npcap frame checks passed"