Public/Export-NSPFortiGateVpnReport.ps1

function Export-NSPFortiGateVpnReport {
    <#
    .SYNOPSIS
        Writes a printable HTML document and an Excel workbook of a VPN tunnel's access chain.
    .DESCRIPTION
        One sheet per topic; in the HTML each starts on a new printed page (landscape) so the
        printout can be separated and cross-referenced, and in the workbook each is its own tab
        (header row frozen and filterable) after an About tab:

          Cover tunnel settings, NPS server, sources, checks worth a look, contents
          1 firewall policies using the tunnel
          2 user groups, the VSA each expects, and the NPS policy that sends it
          3 service groups expanded to protocol/port
          4 address groups expanded to addresses
          5 certificate peer groups, the subject each peer matches, and its template
          6 NPS network policies: conditions, VSAs returned, where each lands
          7 AD group membership tree
          8 certificate templates: enrollment groups and TameMyCerts OU stamp
          9 effective access by AD group, including users an earlier NPS policy shadows

        Sheets 1-5 need only the FortiGate captures. Sheets 6 and 9 need -NpsConfig, sheets 7 and 8
        need -AdInventory; without them those sheets print as headed placeholders. The Checks list
        on the cover collects mismatches found along the way (VSA case differences, VSAs no
        FortiGate group matches, shadowed users, peers no template stamps, RADIUS source addresses
        that aren't NPS RADIUS clients).
    .PARAMETER Path
        FortiGate capture files, parsed together (policies, groups, services, addresses, RADIUS,
        peers, IPsec phase1/phase2), or a full configuration backup.
    .PARAMETER Tunnel
        Phase1 interface name. Only policies with it as source or destination interface are
        documented. Omit to document every policy loaded.
    .PARAMETER OutputPath
        The .html file to write.
    .PARAMETER ExcelPath
        The .xlsx file to write. Defaults to OutputPath with an .xlsx extension.
    .PARAMETER NoExcel
        Write the HTML only.
    .PARAMETER NpsConfig
        A copy of the NPS server's ias.xml (C:\Windows\System32\ias\ias.xml) or a 'netsh nps export'.
        Policies, profiles, and each RADIUS client's name, address, and enabled state are read;
        shared secrets are not.
    .PARAMETER AdInventory
        The ADInventory_*.json written by AD-Manager menu 6.
    .PARAMETER RadiusSourceIp
        The address(es) the FortiGate sends RADIUS requests from, as NPS sees them: the interface
        facing the NPS server, unless 'set source-ip' is configured on the RADIUS server (those are
        read from the captures and checked too). Each is checked against ias.xml's RADIUS clients.
    .PARAMETER NpsFacts
        Label/value pairs shown on the cover under "NPS server", such as the server name or NPS
        Extension version. Use an ordered dictionary to keep the order. Don't pass secrets.
    .PARAMETER DeviceName
        Defaults to the hostname in the capture's CLI prompt.
    .EXAMPLE
        Export-NSPFortiGateVpnReport -Path .\captures\*.txt -Tunnel 'Dialup-IKEv2' -OutputPath .\Dialup-IKEv2.html
    .EXAMPLE
        Export-NSPFortiGateVpnReport -Path .\captures\*.txt -Tunnel 'Dialup-IKEv2' -NpsConfig .\ias.xml -AdInventory .\ADInventory_example.json -OutputPath .\Dialup-IKEv2.html
    .EXAMPLE
        Export-NSPFortiGateVpnReport -Path .\backup.conf -Tunnel 'Dialup-IKEv2' -NpsConfig .\ias.xml -RadiusSourceIp 10.0.0.1 -NpsFacts ([ordered]@{ 'Server' = 'NPS01'; 'NPS Extension' = '1.2.2893.1' }) -OutputPath .\Dialup-IKEv2.html
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory, Position = 0)][ValidateNotNullOrEmpty()][string[]]$Path,
        [string]$Tunnel,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$OutputPath,
        [string]$ExcelPath,
        [switch]$NoExcel,
        [string]$NpsConfig,
        [string]$AdInventory,
        [string[]]$RadiusSourceIp,
        [System.Collections.IDictionary]$NpsFacts,
        [string]$DeviceName,
        [string]$Title
    )

    $report = Get-NSPFortiGateVpnReportData -Path $Path -Tunnel $Tunnel -NpsConfig $NpsConfig -AdInventory $AdInventory -RadiusSourceIp $RadiusSourceIp -NpsFacts $NpsFacts -DeviceName $DeviceName -Title $Title
    $encode = { param($Value) [System.Net.WebUtility]::HtmlEncode([string]$Value) }
    $generated = Get-Date -Format 'yyyy-MM-dd HH:mm'

    $css = @'
@page { size: letter landscape; margin: 0.45in; }
* { box-sizing: border-box; }
body { font-family: "Segoe UI", Calibri, Arial, sans-serif; font-size: 10pt; color: #1a1a1a; margin: 0 auto; max-width: 1700px; padding: 16px; background: #fff; }
h1 { font-size: 20pt; margin: 0 0 4px; }
h2 { font-size: 14pt; margin: 0 0 6px; }
.sheet { break-before: page; page-break-before: always; }
.sheet-head { display: flex; justify-content: space-between; border-bottom: 2px solid #1a1a1a; padding-bottom: 4px; margin-bottom: 10px; font-size: 9pt; color: #444; }
.sheet-head strong { color: #1a1a1a; }
p { margin: 0 0 8px; }
ul { margin: 0 0 10px; padding-left: 20px; }
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
thead { display: table-header-group; }
tr { break-inside: avoid; page-break-inside: avoid; }
th { background: #e8e8e8; text-align: left; }
th, td { border: 1px solid #9a9a9a; padding: 3px 6px; vertical-align: top; overflow-wrap: break-word; }
td.group { font-weight: 600; background: #f6f6f6; }
th.nw, td.nw { white-space: nowrap; overflow-wrap: normal; }
tr.disabled td { color: #777; font-style: italic; }
tr.disabled td.mixed { color: inherit; font-style: normal; }
.empty, .todo { border: 1px dashed #9a9a9a; padding: 18px; color: #555; }
.todo { min-height: 4in; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 12px 0; }
dt { font-weight: 600; }
dd { margin: 0; }
.checks li { margin: 3px 0; }
.contents li { margin: 2px 0; }
.meta { color: #555; font-size: 9pt; }
@media screen { .sheet { margin-top: 36px; border-top: 1px dashed #bbb; padding-top: 16px; } }
@media print { body { max-width: none; padding: 0; } }
'@


    $html = New-Object System.Text.StringBuilder
    [void]$html.Append("<!DOCTYPE html>`n<html lang=""en""><head><meta charset=""utf-8""><title>$(& $encode $report.Title)</title><style>$css</style></head><body>")
    [void]$html.Append("<section><h1>$(& $encode $report.Title)</h1><p class=""meta"">Generated $generated.</p>")
    if ($report.Facts.Count) {
        [void]$html.Append('<dl>')
        foreach ($key in $report.Facts.Keys) { if ($report.Facts[$key]) { [void]$html.Append("<dt>$(& $encode $key)</dt><dd>$(& $encode $report.Facts[$key])</dd>") } }
        [void]$html.Append('</dl>')
    }
    [void]$html.Append("<p><strong>$($report.PolicyCount)</strong> firewall policies reference this tunnel.</p>")
    if ($report.NpsFacts.Count) {
        [void]$html.Append('<h2>NPS server</h2><dl>')
        foreach ($key in $report.NpsFacts.Keys) { [void]$html.Append("<dt>$(& $encode $key)</dt><dd>$(& $encode $report.NpsFacts[$key])</dd>") }
        [void]$html.Append('</dl>')
    }
    if ($report.Checks.Count) {
        [void]$html.Append('<h2>Checks</h2><ul class="checks">')
        foreach ($check in $report.Checks) { [void]$html.Append("<li>$(& $encode $check)</li>") }
        [void]$html.Append('</ul>')
    }
    [void]$html.Append('<h2>Contents</h2><ol class="contents">')
    foreach ($sheet in $report.Sheets) { [void]$html.Append("<li>$(& $encode $sheet.Title)</li>") }
    [void]$html.Append('</ol><p class="meta">Sources:<br>' + (($report.Sources | ForEach-Object { & $encode $_ }) -join '<br>') + '</p></section>')

    for ($i = 0; $i -lt $report.Sheets.Count; $i++) {
        $sheet = $report.Sheets[$i]
        [void]$html.Append("<section class=""sheet""><div class=""sheet-head""><span><strong>Sheet $($i + 1) of $($report.Sheets.Count): $(& $encode $sheet.Title)</strong></span><span>$(& $encode "$($report.DeviceName) / $($report.TunnelName)")</span></div>")
        [void]$html.Append("<h2>$($i + 1). $(& $encode $sheet.Title)</h2><p>$(& $encode $sheet.Intro)</p>")
        if (@($sheet.Notes).Count) {
            [void]$html.Append('<ul>')
            foreach ($note in $sheet.Notes) { [void]$html.Append("<li>$(& $encode $note)</li>") }
            [void]$html.Append('</ul>')
        }
        if ($sheet.Placeholder) {
            [void]$html.Append("<div class=""todo"">$(& $encode $sheet.Placeholder)</div>")
        } else {
            $empty = if ($sheet.Empty) { $sheet.Empty } else { 'None referenced.' }
            $span = if ($sheet.SpanColumns) { $sheet.SpanColumns } else { @() }
            $nowrap = if ($sheet.NoWrapColumns) { $sheet.NoWrapColumns } else { @() }
            $widths = if ($sheet.ColumnWidth) { $sheet.ColumnWidth } else { @{} }
            [void]$html.Append((Format-NSPFortiGateHtmlTable -Columns $sheet.Columns -Rows @($sheet.Rows) -SpanColumns $span -NoWrapColumns $nowrap -ColumnWidth $widths -Empty $empty))
        }
        [void]$html.Append('</section>')
    }
    [void]$html.Append('</body></html>')

    $OutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
    $directory = Split-Path -Parent $OutputPath
    if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null }
    [IO.File]::WriteAllText($OutputPath, $html.ToString(), (New-Object System.Text.UTF8Encoding $true))
    Get-Item -LiteralPath $OutputPath

    if (-not $NoExcel) {
        if (-not $ExcelPath) { $ExcelPath = [IO.Path]::ChangeExtension($OutputPath, '.xlsx') }
        # About tab: what the HTML cover and sheet introductions say, as rows.
        $about = New-Object System.Collections.Generic.List[hashtable]
        $about.Add(@{ Item = 'Title'; Detail = $report.Title })
        $about.Add(@{ Item = 'Generated'; Detail = $generated })
        foreach ($key in $report.Facts.Keys) { if ($report.Facts[$key]) { $about.Add(@{ Item = $key; Detail = $report.Facts[$key] }) } }
        foreach ($key in $report.NpsFacts.Keys) { $about.Add(@{ Item = "NPS: $key"; Detail = $report.NpsFacts[$key] }) }
        foreach ($source in $report.Sources) { $about.Add(@{ Item = 'Source'; Detail = $source }) }
        foreach ($check in $report.Checks) { $about.Add(@{ Item = 'Check'; Detail = $check }) }
        for ($i = 0; $i -lt $report.Sheets.Count; $i++) {
            $sheet = $report.Sheets[$i]
            $about.Add(@{ Item = "Sheet $($i + 1): $($sheet.Title)"; Detail = (@($sheet.Intro) + @($sheet.Notes | ForEach-Object { "- $_" }) + @($sheet.Placeholder | Where-Object { $_ })) -join "`n" })
        }
        $workbook = @(@{ Name = 'About'; Columns = @('Item', 'Detail'); Rows = $about.ToArray() })
        for ($i = 0; $i -lt $report.Sheets.Count; $i++) {
            $sheet = $report.Sheets[$i]
            $workbook += @{ Name = "$($i + 1) $($sheet.Title)"; Columns = $sheet.Columns; Rows = @($sheet.Rows) }
        }
        Write-NSPFortiGateXlsx -Path $ExcelPath -Sheet $workbook
    }
}