Public/Show-AACApplicationGatewayMap.ps1

function Show-AACApplicationGatewayMap {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Draws Application Gateways as their connection flow - frontend IPs,
        listeners, rules, path rules and redirects, backend settings and
        probes, pools and targets, WAF policies - inside their network and
        subnet, with health and findings; shows each gateway's flows at the
        prompt and opens the map in your browser.
    .DESCRIPTION
        Read-only, with Azure Resource Graph (Reader is enough): every setting
        of each gateway, and what it refers to - public IPs, WAF policies,
        the network and subnet (with its NSG and route table), the NICs and
        VMs in the pools, and App Services whose host name is a target.
 
        The map, left to right: frontend IPs > listeners (protocol, port,
        host names, certificate and its expiry, WAF policy) > rules
        (priority, basic or path-based) > path rules and redirects > backend
        settings (protocol, port, timeout, host name, affinity, draining) and
        their probes > pools > targets. WAF policies sit on the left with a
        line to what they protect (the gateway, a site or a path). Pools and
        targets are outlined green, amber or red by health. Click a card for
        all its settings, its findings and its connections.
 
        Health: metrics always (healthy and unhealthy host counts per pool,
        last 30 minutes, v2 gateways); -IncludeHealth runs each gateway's live
        backend health check - every target's state and, when it fails, the
        probe's reason. That check takes 30-90 seconds and needs the
        Microsoft.Network/applicationGateways/backendhealth/action permission;
        a gateway that refuses is reported, not fatal.
 
        Findings (High, Medium, Low): v1 SKU, WAF tier without a WAF, WAF in
        Detection or disabled, old rule sets, old TLS policy, no zones or
        under 2 instances, HTTP not redirected to HTTPS, certificates
        expired, expiring (-CertificateDays) or uploaded instead of from Key
        Vault, backend settings without a custom probe, probe host mismatch,
        connection draining off, empty or unhealthy pools, and listeners,
        settings, probes and pools nothing uses.
 
        At the prompt: tiles, each gateway's flows (listener > rule > path >
        settings > pool, with health) and the findings. -CsvPath writes the
        flows; -PdfPath the gateways, flows and findings; -HtmlPath the map
        (a file in your temp folder by default).
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ResourceGroupName
        Only these resource groups.
    .PARAMETER Name
        Only gateways with these names; wildcards work.
    .PARAMETER IncludeHealth
        Run each gateway's live backend health check: every target's state
        and the probe's reason.
    .PARAMETER CertificateDays
        A certificate expiring within this many days is a finding (30 by
        default).
    .PARAMETER Direction
        LeftToRight (the default) or TopToBottom.
    .PARAMETER Theme
        Dark (the default), Light or Blueprint.
    .PARAMETER HtmlPath
        Where to write the map. By default a file in your temp folder.
    .PARAMETER CsvPath
        Write the flows to this CSV file.
    .PARAMETER PdfPath
        Write a PDF report: gateways, flows and findings.
    .PARAMETER Title
        The map's title.
    .PARAMETER NoBrowser
        Write the map without opening it.
    .PARAMETER PassThru
        Also return the map: gateways, flows, findings, cards and lines.
    .EXAMPLE
        Connect-AAC
        Show-AACApplicationGatewayMap
        Every Application Gateway you can see, as a map in the browser.
    .EXAMPLE
        Show-AACApplicationGatewayMap -ResourceGroupName 'rg-edge' -Name 'appgw-prod' -IncludeHealth
        One gateway with live backend health and the probes' reasons.
    .EXAMPLE
        (Show-AACApplicationGatewayMap -NoBrowser -PassThru).Flows | Where-Object Health -In 'unhealthy', 'degraded'
        The routes that reach unhealthy backends.
    .OUTPUTS
        AAC.ApplicationGatewayMap, with -PassThru
    #>

    [CmdletBinding()]
    [OutputType('AAC.ApplicationGatewayMap')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [string[]] $Name,

        [switch] $IncludeHealth,

        [ValidateRange(1, 365)]
        [int] $CertificateDays = 30,

        [ValidateSet('LeftToRight', 'TopToBottom')]
        [string] $Direction = 'LeftToRight',

        [ValidateSet('Dark', 'Light', 'Blueprint')]
        [string] $Theme = 'Dark',

        [string] $HtmlPath,

        [string] $CsvPath,

        [string] $PdfPath,

        [string] $Title,

        [switch] $NoBrowser,

        [switch] $PassThru
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $htmlFullPath = if ($HtmlPath) { & $resolve $HtmlPath } else { Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('AAC-ApplicationGatewayMap-{0:yyyyMMdd-HHmmss}.html' -f (Get-Date)) }
    $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); Name = @($Name | Where-Object { $_ }); IncludeHealth = [bool]$IncludeHealth; CertificateDays = $CertificateDays; Title = $Title }

    Write-AACRule -Title 'Azure Admin Console :: Application Gateway map' -Color 'darkorange'
    $state = Invoke-AACProgress -ScriptBlock {
        $null = Get-AACAccessToken
        Update-AACProgress -Id 'read' -Indeterminate -Description 'Reading the Application Gateways (Resource Graph)'
        $read = Invoke-AACGraphBatch -Query (Get-AACApplicationGatewayQuery -ResourceGroupName $request.ResourceGroupName) -SubscriptionId $request.SubscriptionId
        $gateways = @($read.Rows['gateways'] | Where-Object { $n = [string]$_['name']; $null -ne $_ -and (-not $request.Name.Count -or @($request.Name | Where-Object { $n -like $_ }).Count) })
        $names = @{}
        foreach ($s in @($read.Rows['subscriptions'])) { if ($s) { $names[[string]$s['subscriptionId']] = [string]$s['name'] } }
        # What the gateways refer to: public IPs, WAF policies, networks, NICs, App Services.
        $ids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $hosts = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($g in $gateways) {
            $text = ConvertTo-Json -InputObject $g['properties'] -Depth 30 -Compress
            foreach ($m in [regex]::Matches($text, '(?i)"id":"(/subscriptions/[^"]+/providers/microsoft\.network/(publicipaddresses|applicationgatewaywebapplicationfirewallpolicies|networkinterfaces)/[^/"]+)')) { [void]$ids.Add($m.Groups[1].Value) }
            foreach ($m in [regex]::Matches($text, '(?i)"id":"(/subscriptions/[^"]+/providers/microsoft\.network/virtualnetworks/[^/"]+)/subnets/')) { [void]$ids.Add($m.Groups[1].Value) }
            foreach ($m in [regex]::Matches($text, '"fqdn":"([^"]+)"')) { [void]$hosts.Add($m.Groups[1].Value) }
        }
        $related = @()
        $sites = @()
        if ($ids.Count -or $hosts.Count) {
            $relatedRead = Invoke-AACGraphBatch -Query (Get-AACApplicationGatewayQuery -Stage Related -Id @($ids) -HostName @($hosts)) -AllowFailure @('sites')
            foreach ($key in @($relatedRead.Rows.Keys)) { if ($key -eq 'sites') { $sites = @($relatedRead.Rows[$key]) } else { $related += @($relatedRead.Rows[$key]) } }
        }
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} gateway(s) and {1:N0} resource(s) they use' -f $gateways.Count, (@($related).Count + @($sites).Count))

        Update-AACProgress -Id 'health' -Indeterminate -Description $(if ($request.IncludeHealth) { 'Running the live backend health check (30-90 seconds per gateway, all at once)' } else { 'Reading backend health metrics' })
        $health = Read-AACApplicationGatewayHealth -GatewayId @($gateways | ForEach-Object { ([string]$_['id']).ToLowerInvariant() }) -Live:$request.IncludeHealth
        Update-AACProgress -Id 'health' -Complete -Description $(if ($request.IncludeHealth) { 'Live backend health read for {0:N0} of {1:N0} gateway(s)' -f $health.Live.Count, $gateways.Count } else { 'Read backend health metrics' })

        $map = ConvertTo-AACApplicationGatewayMap -Gateway $gateways -Related $related -Site $sites -SubscriptionName $names -Health $health -CertificateDays $request.CertificateDays
        $mapTitle = if ($request.Title) { $request.Title } else { "Application Gateways: $(if ($gateways.Count -le 3) { (@($gateways | ForEach-Object { $_['name'] }) -join ', ') } else { "$($gateways.Count) gateways" })" }
        $facts = @(
            '{0:N0} gateway(s)' -f $map.Stats.Gateways
            '{0:N0} listener(s)' -f $map.Stats.Listeners
            '{0:N0} rule(s)' -f $map.Stats.Rules
            '{0:N0} pool(s)' -f $map.Stats.Pools
            '{0:N0} target(s)' -f $map.Stats.Targets
            if ($map.Stats.Unhealthy) { '{0:N0} unhealthy' -f $map.Stats.Unhealthy }
            '{0:N0} finding(s)' -f $map.Stats.Findings
            "health from $(if ($request.IncludeHealth) { 'the live check' } else { 'metrics' })"
            "drawn $((Get-Date).ToString('d MMM yyyy HH:mm'))"
        )
        if ($gateways.Count) {
            Update-AACProgress -Id 'html' -Indeterminate -Description 'Writing the map'
            Write-AACResourceMapHtml -Map $map -Path $htmlFullPath -Title $mapTitle -Fact $facts -Direction $(if ($Direction -eq 'TopToBottom') { 'DOWN' } else { 'RIGHT' }) -Theme $Theme.ToLowerInvariant()
            Update-AACProgress -Id 'html' -Complete -Description "Map: $htmlFullPath"
        }
        @{ Map = $map; Title = $mapTitle }
    }

    $map = $state.Map
    if (-not $map.Stats.Gateways) { Write-AACMarkup '[grey58]No Application Gateway in that scope.[/]'; return }
    $rank = Get-AACSeverityRank
    $healthTones = @{ healthy = 'good'; degraded = 'warn'; unhealthy = 'bad'; unknown = 'neutral'; redirect = 'info' }
    $stats = $map.Stats
    $report = @{
        Subtitle = 'Application Gateways: connection flow, health and findings'
        Facts    = [ordered]@{ Gateways = $stats.Gateways; Health = $(if ($IncludeHealth) { 'live backend health check' } else { 'metrics, last 30 minutes (-IncludeHealth for the live check)' }); Map = $htmlFullPath }
        Status   = $(if ($stats.High) { 'Failed' } elseif ($stats.Findings) { 'Warning' } else { 'Success' })
        Headline = "$($stats.Gateways) gateway(s), $($stats.Listeners) listener(s), $($stats.Targets) target(s)$(if ($stats.Unhealthy) { ", $($stats.Unhealthy) unhealthy" }): $($stats.High) high finding(s) of $($stats.Findings)"
        Tiles    = @(
            @{ Value = '{0:N0}' -f $stats.Gateways; Label = 'gateways'; Tone = 'info' }
            @{ Value = '{0:N0}' -f $stats.Listeners; Label = 'listeners'; Tone = 'info' }
            @{ Value = '{0:N0}' -f $stats.Rules; Label = 'rules'; Tone = 'info' }
            @{ Value = '{0:N0}' -f $stats.Targets; Label = 'backend targets'; Tone = 'info' }
            @{ Value = '{0:N0}' -f $stats.Unhealthy; Label = 'unhealthy targets'; Tone = $(if ($stats.Unhealthy) { 'bad' } else { 'good' }) }
            @{ Value = '{0:N0}' -f $stats.High; Label = 'high findings'; Tone = $(if ($stats.High) { 'bad' } else { 'good' }) }
        )
        Tables   = @(
            @{ Id = 'gateways'; Title = 'Gateways'; Section = 'Gateways'; Rows = @($map.Gateways); Noun = 'gateways'; NoConsole = $true
                Columns = @(
                    @{ Key = 'Gateway'; Label = 'Gateway'; Pdf = $true }, @{ Key = 'Sku'; Label = 'SKU'; Pdf = $true }, @{ Key = 'Capacity'; Label = 'Capacity'; Pdf = $true }, @{ Key = 'Zones'; Label = 'Zones'; Pdf = $true }
                    @{ Key = 'TlsPolicy'; Label = 'TLS policy'; Pdf = $true }, @{ Key = 'WafPolicy'; Label = 'WAF policy'; Pdf = $true }, @{ Key = 'Listeners'; Label = 'Listeners'; Type = 'number'; Pdf = $true }
                    @{ Key = 'Targets'; Label = 'Targets'; Type = 'number'; Pdf = $true }, @{ Key = 'Unhealthy'; Label = 'Unhealthy'; Type = 'number'; Pdf = $true }, @{ Key = 'Findings'; Label = 'Findings'; Type = 'number'; Pdf = $true }
                ) }
            @{ Id = 'flows'; Title = 'Flows: listener > rule > path > backend settings > pool'; Section = 'Flows'; Rows = @($map.Flows); Noun = 'flows'; ConsoleLimit = 30
                Columns = @(
                    @{ Key = 'Gateway'; Label = 'Gateway'; Console = $true; Pdf = $true }
                    @{ Key = 'Listener'; Label = 'Listener'; Console = $true; Pdf = $true }
                    @{ Key = 'Port'; Label = 'Port'; Console = $true; Pdf = $true }
                    @{ Key = 'HostNames'; Label = 'Host names'; Console = $true; Pdf = $true }
                    @{ Key = 'Rule'; Label = 'Rule'; Console = $true; Pdf = $true }
                    @{ Key = 'Path'; Label = 'Path'; Console = $true; Pdf = $true }
                    @{ Key = 'Redirect'; Label = 'Redirect'; Pdf = $true }
                    @{ Key = 'BackendSettings'; Label = 'Backend settings'; Console = $true; Pdf = $true }
                    @{ Key = 'Pool'; Label = 'Pool'; Console = $true; Pdf = $true }
                    @{ Key = 'Health'; Label = 'Health'; Type = 'badge'; Tones = $healthTones; Console = $true; Pdf = $true }
                    @{ Key = 'WafPolicy'; Label = 'WAF policy' }
                ) }
            @{ Id = 'findings'; Title = 'Findings'; Section = 'Findings'; Rows = @($map.Findings); Noun = 'findings'; ConsoleLimit = 20; Empty = 'Nothing to fix.'; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Console = $true; Pdf = $true }
                    @{ Key = 'Gateway'; Label = 'Gateway'; Console = $true; Pdf = $true }
                    @{ Key = 'Component'; Label = 'Component'; Console = $true; Pdf = $true }
                    @{ Key = 'Name'; Label = 'Name'; Console = $true; Pdf = $true }
                    @{ Key = 'Finding'; Label = 'Finding'; Console = $true; Pdf = $true }
                    @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Recommendation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                ) }
        )
        Hint     = 'Click a card on the map for all its settings; -IncludeHealth runs the live backend health check; -CsvPath writes the flows; -PassThru returns the map.'
    }
    if ($CsvPath -or $PdfPath) { Invoke-AACReportOutput -Report $report -Title $state.Title -CsvObject @($map.Flows) -Noun 'flow' -CsvPath (& $resolve $CsvPath) -PdfPath (& $resolve $PdfPath) }
    Show-AACReportView -Report $report
    if (-not $NoBrowser) {
        Open-AACFile -Path $htmlFullPath
        Write-AACMarkup '[grey58]Opened in your browser: click a card for its settings; Save PNG, JPEG or SVG at the top.[/]'
    }
    if ($PassThru) {
        [pscustomobject]@{
            PSTypeName = 'AAC.ApplicationGatewayMap'
            Title      = $state.Title
            Path       = $htmlFullPath
            Gateways   = @($map.Gateways)
            Flows      = @($map.Flows)
            Findings   = @($map.Findings)
            Nodes      = @($map.Nodes | ForEach-Object { [pscustomobject]@{ Name = $_.name; Type = $_.type; Label = $_.typeLabel; Health = $_.health; Risk = $_.risk; Details = ($_.facts -join ' · '); Id = $_.id } })
            Edges      = @($map.Edges | ForEach-Object { [pscustomobject]@{ Kind = $_.kind; Source = $_.source; Target = $_.target; Label = $_.label } })
            Stats      = [pscustomobject]$map.Stats
        }
    }
}