Scripts/Start-FortiClientIKEv2Capture.ps1

<#
.SYNOPSIS
    Detached background capture of local network stack state during a FortiClient
    IKEv2 connection attempt. Designed to survive loss of the remote session
    (e.g. ScreenConnect) the moment the tunnel starts negotiating.
 
.DESCRIPTION
    Run this BEFORE the user clicks Connect. It launches a separate, detached
    PowerShell process (not a job tied to this console/session) that polls
    every second and appends to a CSV on disk:
        - UDP 500 / 4500 binding + owning process
        - Fortinet Virtual Ethernet Adapter (IKEv2) status
        - Presence/PID of fortivpn.exe, ipsecproxy.exe, ipsec.exe (FortiAuth)
        - Default route + interface (catches route hijack/flap that could
          explain the remote session drop)
 
    Because it's launched via Start-Process (not Start-Job/Invoke-Command),
    it keeps running independently even if the ScreenConnect session hangs
    up mid-negotiation. Just reconnect afterward and read the CSV.
 
    Self-terminates after -DurationMinutes, or immediately if a stop-flag
    file appears (see -StopFlagPath), so nobody has to babysit it.
 
    Part of the FortiClient Connection Troubleshooting Toolkit (see FCT-Toolkit.ps1 in this same
    folder for the menu-driven entry point) - also fully usable standalone.
 
.PARAMETER DurationMinutes
    How long to capture. Default 10. Give it enough headroom to cover the user
    clicking Connect, waiting, and the eventual cancel/disconnect.
 
.PARAMETER OutputDir
    Folder for the CSV output. Defaults to C:\Admin\FCTToolkit\Output\Captures.
 
.EXAMPLE
    # Start it, then have the user attempt the connection, then walk away
    . C:\Admin\FCTToolkit\Start-FortiClientIKEv2Capture.ps1 -DurationMinutes 10
 
.EXAMPLE
    # Stop it early once you're back in, without waiting for the timer
    New-Item -ItemType File -Path C:\Admin\FCTToolkit\Output\Captures\STOP -Force
#>


[CmdletBinding()]
param(
    [int]$DurationMinutes = 10,
    [string]$OutputDir = "C:\Admin\FCTToolkit\Output\Captures",
    [switch]$RunCapture   # internal - used when the script relaunches itself detached
)

$stopFlagPath = Join-Path $OutputDir "STOP"

# ---------------------------------------------------------------------------
# LAUNCHER MODE: spawn the detached worker and return control immediately
# ---------------------------------------------------------------------------
if (-not $RunCapture) {
    New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
    Remove-Item $stopFlagPath -ErrorAction SilentlyContinue

    $scriptPath = $MyInvocation.MyCommand.Path
    $csvPath = Join-Path $OutputDir "IKEv2_Capture_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"

    $argList = @(
        "-NoProfile","-ExecutionPolicy","Bypass",
        "-File", "`"$scriptPath`"",
        "-RunCapture",
        "-DurationMinutes", $DurationMinutes,
        "-OutputDir", "`"$OutputDir`""
    )

    $proc = Start-Process -FilePath "powershell.exe" -ArgumentList $argList `
        -WindowStyle Hidden -PassThru

    Write-Host "Capture started in detached process (PID $($proc.Id))." -ForegroundColor Green
    Write-Host "It will run for $DurationMinutes minutes even if this session drops."
    Write-Host "Output folder: $OutputDir"
    Write-Host "`nTo stop it early once you're reconnected, run:"
    Write-Host " New-Item -ItemType File -Path '$stopFlagPath' -Force"
    Write-Host "`nHave the user attempt the IKEv2 connection now."
    return
}

# ---------------------------------------------------------------------------
# WORKER MODE: this is the detached process. Runs independent of the caller.
# ---------------------------------------------------------------------------
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
$csvPath = Join-Path $OutputDir "IKEv2_Capture_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$endTime = (Get-Date).AddMinutes($DurationMinutes)

$rows = New-Object System.Collections.Generic.List[object]

while ((Get-Date) -lt $endTime) {

    if (Test-Path $stopFlagPath) { break }

    $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'

    # UDP 500 / 4500
    $u500 = Get-NetUDPEndpoint -LocalPort 500 -ErrorAction SilentlyContinue | Select-Object -First 1
    $u4500 = Get-NetUDPEndpoint -LocalPort 4500 -ErrorAction SilentlyContinue | Select-Object -First 1
    $u500Proc  = if ($u500)  { (Get-Process -Id $u500.OwningProcess -ErrorAction SilentlyContinue).ProcessName } else { $null }
    $u4500Proc = if ($u4500) { (Get-Process -Id $u4500.OwningProcess -ErrorAction SilentlyContinue).ProcessName } else { $null }

    # IKEv2 virtual adapter status
    $ikeAdapter = Get-NetAdapter -ErrorAction SilentlyContinue |
        Where-Object { $_.InterfaceDescription -match 'Fortinet Virtual Ethernet' } |
        Select-Object -First 1

    # Relevant Forti processes
    $fortivpn    = Get-Process -Name fortivpn    -ErrorAction SilentlyContinue | Select-Object -First 1
    $ipsecproxy  = Get-Process -Name ipsecproxy  -ErrorAction SilentlyContinue | Select-Object -First 1
    $ipsecAuth   = Get-Process -Name ipsec       -ErrorAction SilentlyContinue | Select-Object -First 1

    # Default route (catches a route flap that could explain losing the remote session)
    $defRoute = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
        Sort-Object -Property RouteMetric | Select-Object -First 1

    $row = [PSCustomObject]@{
        Timestamp        = $ts
        UDP500_Bound     = [bool]$u500
        UDP500_PID       = $u500.OwningProcess
        UDP500_Process   = $u500Proc
        UDP4500_Bound    = [bool]$u4500
        UDP4500_PID      = $u4500.OwningProcess
        UDP4500_Process  = $u4500Proc
        IKEv2Adapter     = $ikeAdapter.Status
        FortiVPN_PID     = $fortivpn.Id
        IpsecProxy_PID   = $ipsecproxy.Id
        IpsecAuth_PID    = $ipsecAuth.Id
        DefaultRoute_If  = $defRoute.InterfaceAlias
        DefaultRoute_NH  = $defRoute.NextHop
    }
    $rows.Add($row)

    # Write incrementally so partial data survives even if the process is killed
    $row | Export-Csv -Path $csvPath -NoTypeInformation -Append

    Start-Sleep -Seconds 1
}

# Final summary appended as a plain text sidecar for quick reading
$summaryPath = $csvPath -replace '\.csv$', '_summary.txt'
$sawIpsecProxy = $rows | Where-Object { $_.IpsecProxy_PID } | Select-Object -First 1
$sawAdapterUp  = $rows | Where-Object { $_.IKEv2Adapter -eq 'Up' } | Select-Object -First 1
$sawPort500    = $rows | Where-Object { $_.UDP500_Bound } | Select-Object -First 1

@"
Capture window: $($rows[0].Timestamp) to $($rows[-1].Timestamp)
Total samples: $($rows.Count)
 
ipsecproxy.exe ever seen running: $([bool]$sawIpsecProxy)
IKEv2 virtual adapter ever went Up: $([bool]$sawAdapterUp)
UDP 500 ever bound: $([bool]$sawPort500)
 
If ipsecproxy.exe never appeared and UDP 500 was never bound, FortiClient
never actually attempted the network-level handshake - the failure is
happening inside fortivpn.exe / the GUI layer before it hands off to the
IPsec engine. Point next troubleshooting at FortiClient itself (profile,
cert store, emulation) rather than the network path or FortiGate.
 
If ipsecproxy.exe appeared and UDP 500 bound, but the adapter never went
Up, the handshake started but didn't complete - correlate the timestamps
here against the FortiGate-side IKE debug for the actual rejection reason.
"@
 | Out-File -FilePath $summaryPath -Encoding UTF8