<# ============================================================================ TAP - seamless capture launcher for Windows 10 / 11 ============================================================================ Double-click "tap-capture.bat" (it asks Windows for Administrator rights and runs this). This script will: 1. show your network adapters (pktmon captures across all of them), 2. record ~30 seconds of traffic into a .pcapng file, 3. open the TAP dashboard in your browser with the reading ALREADY on screen - no file to choose, nothing uploaded. The good news for Windows: this uses "pktmon", the packet monitor BUILT INTO Windows 10 and 11. You do NOT need Npcap, WinPcap, Wireshark, or any driver. Packet capture does require Administrator rights. The capture is handed to the in-browser engine over a tiny web server that listens ONLY on 127.0.0.1 and ONLY while this window is open. What it can see: a normal switched port shows THIS computer's traffic plus broadcast/multicast. To see the whole LAN, capture at the gateway/router, a mirror (SPAN) port, or the Wi-Fi access point. Options (override on the command line): -Secs 60 -Mb 250 ============================================================================ #> param( # RANGE-VALIDATED, the way the macOS script digit-validates its arguments. # [int] alone accepts -1, which Start-Sleep then rejects as a terminating # error AFTER the capture has started — leaving a machine-wide pktmon # session running. The finally block below catches that now; this stops it # happening at all, and refuses the value where the user can see why. [ValidateRange(1, 3600)][int]$Secs = 30, # seconds to capture (15 / 30 / 60) [ValidateRange(1, 4096)][int]$Mb = 100 # ETL ring size in MB (50 / 100 / 250) ) $ErrorActionPreference = 'Stop' $DIR = $PSScriptRoot # ---- 1. Administrator, or ask Windows for it ------------------------------- # THIS USED TO BE A DEAD END. Running the script from a normal PowerShell — the # obvious thing to do with a .ps1, and what happens when somebody opens it from # the folder rather than through the .bat — printed a yellow paragraph and quit. # The person then had to know what "run as Administrator" means, find the right # shell, and start again, having already been told the tool needs rights it did # not ask for. # # So it asks. Start-Process -Verb RunAs is the same UAC prompt the .bat raises; # the script relaunches itself through it, carrying whatever arguments it was # given, and the unelevated copy exits quietly. Declining the prompt throws, and # THAT is when we say what happened — a refusal is a decision, not a failure. $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host "" Write-Host " Packet capture needs Administrator rights." -ForegroundColor Yellow Write-Host " Asking Windows for them now - approve the prompt and this window will close." Write-Host "" try { # -File, not -Command: the path may contain spaces, and -File takes it # as one argument. The parameters are re-passed so -Secs / -Mb survive. $argv = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath, '-Secs', $Secs, '-Mb', $Mb) Start-Process -FilePath (Get-Process -Id $PID).Path ` -ArgumentList $argv -Verb RunAs -ErrorAction Stop | Out-Null exit 0 } catch { Write-Host "" Write-Host " The Administrator prompt was declined, so nothing was captured." -ForegroundColor Yellow Write-Host " Nothing has been changed on this machine." Write-Host " If you would rather not use the prompt: right-click 'tap-capture.bat'" Write-Host " and choose 'Run as administrator'." Write-Host "" Read-Host "Press Enter to close" exit 1 } } # ---- 2. Paths -------------------------------------------------------------- $etl = Join-Path $env:TEMP 'tap-latest.etl' $out = Join-Path $DIR 'tap-latest.pcapng' # served next to index.html Write-Host "======================================================================" Write-Host " TAP capture (Windows / pktmon - no Npcap needed)" Write-Host "----------------------------------------------------------------------" Write-Host (" Duration : {0}s (size cap {1} MB)" -f $Secs, $Mb) Write-Host " Nothing is uploaded. The file stays on this machine." Write-Host "----------------------------------------------------------------------" # ---- 3. Show adapters (pktmon captures across ALL of them) ----------------- try { Write-Host " Network adapters seen right now:" Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | Sort-Object ifIndex | ForEach-Object { Write-Host (" {0} {1} {2}" -f $_.ifIndex, $_.Name, $_.InterfaceDescription) } Write-Host " (pktmon records all of them; the reading tells you who talked.)" } catch { } Write-Host "----------------------------------------------------------------------" # ---- 4. Capture ------------------------------------------------------------ Write-Host "Preparing pktmon..." cmd /c "pktmon stop" 2>$null | Out-Null if (Test-Path $etl) { Remove-Item $etl -Force -ErrorAction SilentlyContinue } Write-Host "Starting capture..." # --capture: capture packets; --pkt-size 512: keep the headers and drop the # payloads; --file-size: the ETL ring size in MB; --file-name: where it goes. # # --pkt-size 512, NOT 0. With 0 pktmon keeps every byte on the wire, so the # capture held complete HTTP bodies, cookies and cleartext credentials — while # the README told the reader it held headers. 512 keeps everything TAP reads # (IP/TCP/UDP headers, a DNS answer, a DHCP BOOTP frame with options, a NetBIOS # registration, a TLS server name) and nothing after it. pktmon still records # each packet's real on-wire length and the engine sums that, so no figure on # the dashboard moves. & pktmon start --capture --pkt-size 512 --file-size $Mb --file-name $etl | Out-Null if ($LASTEXITCODE -ne 0) { Write-Host "pktmon failed to start (are you Administrator?)." -ForegroundColor Red Read-Host "Press Enter to close"; exit 1 } # A FINALLY, because pktmon is a MACHINE-WIDE capture session. With no handler, # a Ctrl-C during the sleep left it running and writing to %TEMP% until the next # run or a reboot — the macOS side already guards both cases, so this was a # parity gap on the platform where the capture is system-wide rather than # per-interface. try { Write-Host ("Recording for {0}s..." -f $Secs) Start-Sleep -Seconds $Secs } finally { Write-Host "Stopping..." & pktmon stop 2>$null | Out-Null } Write-Host "Converting to pcapng..." if (Test-Path $out) { Remove-Item $out -Force -ErrorAction SilentlyContinue } & pktmon pcapng $etl -o $out | Out-Null Remove-Item $etl -Force -ErrorAction SilentlyContinue if (-not (Test-Path $out)) { Write-Host "Conversion failed - no .pcapng produced." -ForegroundColor Red Read-Host "Press Enter to close"; exit 1 } $bytes = (Get-Item $out).Length Write-Host ("Captured {0:N0} bytes -> {1}" -f $bytes, $out) # ---- 5. A tiny local web server (127.0.0.1 only) --------------------------- # Serves this folder so the in-browser engine can fetch the capture. Built on # .NET's HttpListener - no install. Stops when you press a key here. function Get-ContentType([string]$path) { switch -Regex ($path.ToLower()) { '\.html?$' { 'text/html; charset=utf-8'; break } '\.css$' { 'text/css; charset=utf-8'; break } '\.js$' { 'application/javascript; charset=utf-8'; break } '\.svg$' { 'image/svg+xml'; break } '\.woff2$' { 'font/woff2'; break } '\.json$' { 'application/json'; break } '\.pcapng$' { 'application/octet-stream'; break } '\.pcap$' { 'application/octet-stream'; break } default { 'application/octet-stream' } } } $port = 0 foreach ($p in 8790..8820) { $inUse = Get-NetTCPConnection -State Listen -LocalPort $p -ErrorAction SilentlyContinue if (-not $inUse) { $port = $p; break } } if ($port -eq 0) { $port = 8790 } $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add("http://127.0.0.1:$port/") try { $listener.Start() } catch { Write-Host "Could not start the local server. Open index.html and choose the file:" -ForegroundColor Yellow Write-Host (" {0}" -f $out) Start-Process (Join-Path $DIR 'index.html'); Read-Host "Press Enter to close"; exit 1 } # WITH a trailing separator, so the prefix test cannot match a sibling whose # name merely begins with this folder's name. $dirFull = [System.IO.Path]::GetFullPath($DIR).TrimEnd('\') + '\' $url = "http://127.0.0.1:$port/index.html?load=tap-latest.pcapng" Write-Host "----------------------------------------------------------------------" Write-Host (" Opening the reading: {0}" -f $url) Write-Host " (local server, this machine only - press any key here to close it)" Write-Host "----------------------------------------------------------------------" Start-Process $url # Serve until a key is pressed. GetContextAsync + Wait lets us poll the keyboard. while ($true) { if ([Console]::KeyAvailable) { [void][Console]::ReadKey($true); break } $task = $listener.GetContextAsync() if (-not $task.Wait(400)) { continue } $ctx = $task.Result try { # LocalPath IS ALREADY DECODED. Unescaping it a second time undid the # dot-segment removal Uri had just done, so %252e%252e%252f became ../ # AFTER the only defence had run — and $dirFull carried no trailing # separator, so any sibling whose name merely STARTS with the folder # name passed the prefix test. With the folder ...\Downloads\tap, # a request for %252e%252e%252ftap.zip served ...\Downloads\tap.zip: # this property's own shipping archive. $rel = $ctx.Request.Url.LocalPath.TrimStart('/') if ([string]::IsNullOrEmpty($rel)) { $rel = 'index.html' } $full = [System.IO.Path]::GetFullPath((Join-Path $DIR $rel)) if (-not $full.StartsWith($dirFull, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $full -PathType Leaf)) { $ctx.Response.StatusCode = 404 } else { $data = [System.IO.File]::ReadAllBytes($full) $ctx.Response.ContentType = Get-ContentType $full $ctx.Response.Headers.Add('Cache-Control','no-store') $ctx.Response.ContentLength64 = $data.Length $ctx.Response.OutputStream.Write($data, 0, $data.Length) } } catch { try { $ctx.Response.StatusCode = 500 } catch { } } finally { try { $ctx.Response.OutputStream.Close() } catch { } try { $ctx.Response.Close() } catch { } } } $listener.Stop() Write-Host "Local server closed. The capture is still here if you want it again:" Write-Host (" {0}" -f $out) Write-Host "Done."