Public/Show-AACNetworkTopology.ps1
|
function Show-AACNetworkTopology { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Draws the network topology of the tenant - or of management groups, subscriptions or regions - as an interactive diagram: hubs with their spokes around them, or every network inside its tenant, management group, subscription, region and resource group; with the peerings, private endpoints and the firewalls and appliances traffic is routed through. Opens it in your browser, ready to save as PNG, JPEG or SVG. .DESCRIPTION Reads with Azure Resource Graph - read-only, Reader is enough, no Az modules - across every subscription in scope, every region: the management groups and subscriptions above the networks virtual networks and subnets, and their peerings - also across subscriptions, and to networks you can't see VPN and ExpressRoute gateways, connections, circuits, local gateways; Virtual WAN and its hubs and gateways Azure Firewalls, network virtual appliances (VMs whose NIC forwards IP traffic), NSGs, route tables, NAT gateways, Bastion, load balancers, Application Gateways, private link services, DNS resolvers virtual machines, scale sets and private endpoints in their subnets, and the PaaS resources the private endpoints serve (-NetworkOnly leaves these out) A peering outside the scope is followed to the network at its other end, and a route's next hop to the firewall or appliance that has the IP, so a spoke's map reaches its hub. The boxes (-GroupBy): ManagementGroup tenant > management group > subscription > region > resource group > virtual network > subnet Subscription subscription > region > resource group > ... Region region > virtual network > subnet Subnets are drawn in rows inside their network, as wide as their prefix is big. VMs, scale sets, Bastion, gateways, firewalls and private endpoints sit in the subnet they have an IP in; a VM shows its NIC's IPs and NSG, and an appliance is outlined in the route colour. The lines: VNet peering between networks, both ways, with its state and what it lets through (forwarded traffic, gateway transit, remote gateway); red and dashed when not Connected Private link from a private endpoint to the PaaS resource it serves Route from a subnet to the firewall or appliance its routes send traffic to ("0.0.0.0/0 via rt-spoke") Network a gateway's connections and circuits, and the rest Within a network, subnets reach each other through Azure's system routes: no lines are drawn between them. Each network gets a role: hub (gateway transit, or a gateway, firewall or appliance and peerings, or 3+ peerings), spoke (peered with a hub), peered or standalone. The layout (-Layout): HubSpoke each hub at the centre, its spokes in rings around it, hubs side by side, the rest below; each network's box says its subscription, region and resource group Hierarchy the nested boxes, laid out in layers Auto HubSpoke when there are hubs with spokes (the default) The page switches between them, and between left to right and top to bottom; pan, zoom, search, click for details and connections, and save as PNG, JPEG or SVG. At the prompt: tiles, the hubs and their spokes, and findings - a peering that isn't connected, a route to an IP nothing has, a spoke subnet whose internet traffic isn't sent through a firewall or appliance, a peering to a network you can't see, standalone networks. .PARAMETER ManagementGroupId Only the subscriptions under these management groups (at any depth). .PARAMETER SubscriptionId Only these subscriptions. .PARAMETER Location Only these regions (e.g. 'uksouth', 'westeurope'). .PARAMETER GroupBy The boxes around the networks: ManagementGroup (the default), Subscription or Region. .PARAMETER Layout Auto (the default), HubSpoke or Hierarchy. The page can switch too. .PARAMETER NetworkOnly Leave out virtual machines, scale sets, private endpoints and the PaaS resources behind them: only the network fabric - networks, subnets, peerings, gateways, firewalls, appliances, Bastion. For a large estate. .PARAMETER ExcludeType Resource types to leave out; wildcards work. .PARAMETER Direction LeftToRight (the default) or TopToBottom, inside each box. The page can switch it too. .PARAMETER Theme Dark (the default), Light or Blueprint. .PARAMETER NsgView Chips (the default): NSGs and route tables as chips on the subnets and VMs they're applied to. Cards: as resources, with lines. .PARAMETER HtmlPath Where to write the diagram. By default a file in your temp folder. .PARAMETER Title The diagram's title. .PARAMETER NoBrowser Write the diagram without opening it. .PARAMETER PassThru Also return the topology: networks with their roles, findings, resources, connections and the file's path. .EXAMPLE Connect-AAC Show-AACNetworkTopology Every network you can see, hubs at the centre of their spokes. .EXAMPLE Show-AACNetworkTopology -ManagementGroupId 'mg-landingzones' -GroupBy Subscription -Layout Hierarchy A management group's networks inside their subscriptions, regions and resource groups. .EXAMPLE Show-AACNetworkTopology -NetworkOnly -Location 'uksouth', 'ukwest' -Theme Light -HtmlPath .\out\Network.html -NoBrowser The network fabric of two regions, written to a file. .EXAMPLE (Show-AACNetworkTopology -NoBrowser -PassThru).Findings | Where-Object Severity -EQ 'High' Peerings that aren't connected and routes to IPs nothing has. .OUTPUTS AAC.NetworkTopology, with -PassThru #> [CmdletBinding()] [OutputType('AAC.NetworkTopology')] param( [ValidateNotNullOrEmpty()] [string[]] $ManagementGroupId, [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [ValidateNotNullOrEmpty()] [string[]] $Location, [ValidateSet('ManagementGroup', 'Subscription', 'Region')] [string] $GroupBy = 'ManagementGroup', [ValidateSet('Auto', 'HubSpoke', 'Hierarchy')] [string] $Layout = 'Auto', [switch] $NetworkOnly, [SupportsWildcards()] [string[]] $ExcludeType, [ValidateSet('LeftToRight', 'TopToBottom')] [string] $Direction = 'LeftToRight', [ValidateSet('Dark', 'Light', 'Blueprint')] [string] $Theme = 'Dark', [ValidateSet('Chips', 'Cards')] [string] $NsgView = 'Chips', [string] $HtmlPath, [string] $Title, [switch] $NoBrowser, [switch] $PassThru ) # A failure anywhere below ends as a Spectre.Console error panel and this # command's own terminating error, not a line inside the module. A stopped # pipeline (Select-Object -First, Ctrl+C) is no failure: just return - a # rethrow would stop the caller's whole script, not only this command. trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } $htmlFullPath = if ($HtmlPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($HtmlPath) } else { Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('AAC-NetworkTopology-{0:yyyyMMdd-HHmmss}.html' -f (Get-Date)) } # Everything the work below needs, read here: it runs inside # Invoke-AACProgress, whose variables could hide ours. $request = @{ ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }) SubscriptionId = @($SubscriptionId | Where-Object { $_ }) Location = @($Location | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() -replace '\s', '' }) GroupBy = $GroupBy Layout = $Layout NetworkOnly = [bool]$NetworkOnly ExcludeType = @($ExcludeType | Where-Object { $_ }) Title = $Title } $quote = { param([string] $Text) "'" + ($Text -replace '\\', '\\' -replace "'", "\'") + "'" } # A value deep in a row, or nothing when a step along the way is missing. $at = { param($Row, [string[]] $Keys) $value = $Row foreach ($key in $Keys) { if ($value -isnot [System.Collections.IDictionary] -or -not $value.Contains($key)) { return $null }; $value = $value[$key] } $value } $columns = '| project id, name, type, kind, location, resourceGroup, subscriptionId, sku, properties, tags' $fabric = @( 'microsoft.network/virtualnetworks', 'microsoft.network/virtualnetworkgateways', 'microsoft.network/connections', 'microsoft.network/localnetworkgateways', 'microsoft.network/expressroutecircuits', 'microsoft.network/virtualwans', 'microsoft.network/virtualhubs', 'microsoft.network/vpngateways', 'microsoft.network/expressroutegateways', 'microsoft.network/p2svpngateways', 'microsoft.network/azurefirewalls', 'microsoft.network/networksecuritygroups', 'microsoft.network/routetables', 'microsoft.network/bastionhosts', 'microsoft.network/natgateways', 'microsoft.network/loadbalancers', 'microsoft.network/applicationgateways', 'microsoft.network/privatelinkservices', 'microsoft.network/dnsresolvers' ) $workloads = @('microsoft.network/networkinterfaces', 'microsoft.compute/virtualmachines', 'microsoft.compute/virtualmachinescalesets', 'microsoft.network/privateendpoints') Write-AACRule -Title 'Azure Admin Console :: Network topology' -Color 'deepskyblue3_1' $state = Invoke-AACProgress -ScriptBlock { $null = Get-AACAccessToken $asset = Get-AACResourceMapAsset $graph = { param([System.Collections.IDictionary] $Query, [switch] $Everywhere) if ($Everywhere) { (Invoke-AACGraphBatch -Query $Query).Rows } else { (Invoke-AACGraphBatch -Query $Query -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId).Rows } } $byId = { # Rows by resource ID, 200 a query, the queries at once. param([string[]] $Ids) $chunks = [ordered]@{} for ($i = 0; $i -lt $Ids.Count; $i += 200) { $chunk = $Ids[$i..([Math]::Min($i + 199, $Ids.Count - 1))] $chunks["ids$i"] = "resources | where id in~ ($((@($chunk | ForEach-Object { & $quote $_ })) -join ', ')) $columns" } if (-not $chunks.Count) { return @() } $read = & $graph $chunks -Everywhere foreach ($name in @($chunks.Keys)) { @($read[$name]) } } # --- The networks and everything in and around them ----------------------------------------- Update-AACProgress -Id 'read' -Indeterminate -Description 'Reading the networks from Azure Resource Graph' $types = @($fabric) + @(if (-not $request.NetworkOnly) { $workloads }) $where = if ($request.Location.Count) { " | where location in~ ($((@($request.Location | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' } $queries = [ordered]@{ subscriptions = @{ Tenant = $true; Query = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name, chain = properties.managementGroupAncestorsChain" } resources = "resources | where type in~ ($((@($types | ForEach-Object { & $quote $_ })) -join ', '))$where $columns" } # Appliances need their NICs and VMs even with -NetworkOnly: the NICs that forward IP traffic. if ($request.NetworkOnly) { $queries['appliances'] = "resources | where type =~ 'microsoft.network/networkinterfaces' and properties.enableIPForwarding == true$where $columns" } $read = & $graph $queries $resources = [System.Collections.Generic.List[object]]::new() $known = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($read['resources']) + @($read['appliances'])) { if ($row -and $known.Add([string]$row['id'])) { $resources.Add($row) } } if ($request.NetworkOnly) { $vms = @(foreach ($row in @($read['appliances'])) { [string](& $at $row 'properties', 'virtualMachine', 'id') }) | Where-Object { $_ } foreach ($row in @(& $byId @($vms))) { if ($known.Add([string]$row['id'])) { $resources.Add($row) } } } $subscriptions = @($read['subscriptions']) Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} network resource(s) in {1:N0} subscription(s)' -f $resources.Count, @($resources | ForEach-Object { $_['subscriptionId'] } | Sort-Object -Unique).Count) # --- Beyond the scope: peered networks, the PaaS behind private endpoints, route next hops ---- Update-AACProgress -Id 'beyond' -Indeterminate -Description 'Following peerings, private endpoints and routes beyond the scope' $wanted = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in $resources) { $type = ([string]$row['type']).ToLowerInvariant() if ($type -eq 'microsoft.network/virtualnetworks') { foreach ($peering in @(& $at $row 'properties', 'virtualNetworkPeerings')) { $remote = [string](& $at $peering 'properties', 'remoteVirtualNetwork', 'id'); if ($remote -and -not $known.Contains($remote)) { [void]$wanted.Add($remote) } } } if ($type -eq 'microsoft.network/privateendpoints') { foreach ($connection in @(& $at $row 'properties', 'privateLinkServiceConnections') + @(& $at $row 'properties', 'manualPrivateLinkServiceConnections')) { $target = [string](& $at $connection 'properties', 'privateLinkServiceId') if ($target -and -not $known.Contains($target)) { [void]$wanted.Add($target) } } } } $external = [System.Collections.Generic.List[object]]::new() foreach ($row in @(& $byId @($wanted))) { if ($row -and $known.Add([string]$row['id'])) { $external.Add($row) } } # Route next hops that nothing read so far has. $owned = [System.Collections.Generic.HashSet[string]]::new() foreach ($row in @($resources) + @($external)) { foreach ($match in [regex]::Matches((ConvertTo-Json -InputObject $row['properties'] -Depth 30 -Compress), '"privateIPAddress":"([^"]+)"')) { [void]$owned.Add($match.Groups[1].Value) } } $hops = @(foreach ($row in $resources) { if (([string]$row['type']).ToLowerInvariant() -ne 'microsoft.network/routetables') { continue } foreach ($route in @(& $at $row 'properties', 'routes')) { [string](& $at $route 'properties', 'nextHopIpAddress') } }) | Where-Object { $_ -and -not $owned.Contains($_) } | Select-Object -Unique if ($hops) { $filter = (@($hops | ForEach-Object { "properties contains $(& $quote $_)" })) -join ' or ' $owners = @((& $graph @{ hops = "resources | where type in~ ('microsoft.network/azurefirewalls', 'microsoft.network/networkinterfaces', 'microsoft.network/loadbalancers') | where $filter $columns" } -Everywhere)['hops']) foreach ($row in $owners) { if ($row -and $known.Add([string]$row['id'])) { $external.Add($row) } } # The network the next hop is in, so it's drawn in its subnet. $vnets = @(foreach ($row in $owners) { foreach ($match in [regex]::Matches((ConvertTo-Json -InputObject $row['properties'] -Depth 30 -Compress), '(?i)"id":"(/subscriptions/[^"]+/virtualNetworks/[^/"]+)/subnets/')) { $match.Groups[1].Value } }) | Select-Object -Unique foreach ($row in @(& $byId @($vnets | Where-Object { -not $known.Contains($_) }))) { if ($row -and $known.Add([string]$row['id'])) { $external.Add($row) } } } Update-AACProgress -Id 'beyond' -Complete -Description ('Followed {0:N0} peering(s), private endpoint(s) and route(s) to {1:N0} resource(s) outside the scope' -f $wanted.Count, $external.Count) # --- The topology ---------------------------------------------------------------------------- Update-AACProgress -Id 'map' -Indeterminate -Description 'Working out the hierarchy, roles and connections' $subscriptionNames = @{} foreach ($row in $subscriptions) { $subscriptionNames[([string]$row['subscriptionId']).ToLowerInvariant()] = [string]$row['name'] } $labels = @{} foreach ($key in $asset.Icons.icons.Keys) { $labels[$key] = [string]$asset.Icons.icons[$key].label } $map = ConvertTo-AACResourceMap -Resource $resources.ToArray() -External $external.ToArray() -SubscriptionName $subscriptionNames -IconType $asset.Icons.types -IconLabel $labels -ExcludeType $request.ExcludeType $map = ConvertTo-AACNetworkTopology -Map $map -Resource (@($resources) + @($external)) -Subscription $subscriptions -GroupBy $request.GroupBy $stats = $map.Stats Update-AACProgress -Id 'map' -Complete -Description ('{0:N0} network(s): {1:N0} hub(s), {2:N0} spoke(s); {3:N0} peering(s), {4:N0} connection(s)' -f $stats.Networks, $stats.Hubs, $stats.Spokes, $stats.Peerings, $stats.Connections) $scopeText = if ($request.ManagementGroupId.Count) { "management group $($request.ManagementGroupId -join ', ')" } elseif ($request.SubscriptionId.Count) { @($request.SubscriptionId | ForEach-Object { if ($subscriptionNames.Contains($_.ToLowerInvariant())) { $subscriptionNames[$_.ToLowerInvariant()] } else { $_ } }) -join ', ' } else { 'every subscription you can see' } if ($request.Location.Count) { $scopeText += " ($($request.Location -join ', '))" } $mapTitle = if ($request.Title) { $request.Title } else { "Network topology: $scopeText" } $facts = @( '{0:N0} virtual network(s)' -f $stats.Networks if ($stats.VirtualHubs) { '{0:N0} Virtual WAN hub(s)' -f $stats.VirtualHubs } '{0:N0} hub(s)' -f $stats.Hubs '{0:N0} spoke(s)' -f $stats.Spokes '{0:N0} peering(s)' -f $stats.Peerings '{0:N0} subscription(s)' -f $stats.Subscriptions '{0:N0} region(s)' -f $stats.Regions if ($stats.Firewalls -or $stats.Appliances) { '{0:N0} firewall(s) and appliance(s)' -f ($stats.Firewalls + $stats.Appliances) } if ($stats.PrivateEndpoints) { '{0:N0} private endpoint(s)' -f $stats.PrivateEndpoints } "drawn $((Get-Date).ToString('d MMM yyyy HH:mm'))" ) $layoutName = switch ($request.Layout) { 'HubSpoke' { 'hubspoke' } 'Hierarchy' { 'packed' } default { if ($map.Layout -eq 'hubspoke') { 'hubspoke' } else { 'packed' } } } Update-AACProgress -Id 'html' -Indeterminate -Description 'Writing the diagram' Write-AACResourceMapHtml -Map $map -Path $htmlFullPath -Title $mapTitle -Fact $facts -Direction $(if ($Direction -eq 'TopToBottom') { 'DOWN' } else { 'RIGHT' }) -Theme $Theme.ToLowerInvariant() -NsgView $NsgView.ToLowerInvariant() -Layout $layoutName -Hierarchy packed Update-AACProgress -Id 'html' -Complete -Description "Diagram: $htmlFullPath" @{ Map = $map; Title = $mapTitle } } # --- At the prompt: tiles, hubs and their spokes, findings -------------------------------------- $map = $state.Map $stats = $map.Stats $glyph = Get-AACGlyph $escape = { param($Text) [Spectre.Console.Markup]::Escape([string]$Text) } if (-not $stats.Networks -and -not $stats.VirtualHubs) { Write-AACMarkup '[grey58]No virtual networks were found in that scope; the diagram is empty.[/]' } else { Show-AACTileRow -Tile @( @{ Value = '{0:N0}' -f $stats.Networks; Caption = 'virtual networks'; Color = 'deepskyblue1' } @{ Value = '{0:N0}' -f $stats.Hubs; Caption = 'hubs'; Color = 'mediumpurple2' } @{ Value = '{0:N0}' -f $stats.Spokes; Caption = 'spokes'; Color = 'deepskyblue1' } @{ Value = '{0:N0}' -f $stats.Peerings; Caption = 'peerings'; Color = 'mediumpurple2' } @{ Value = '{0:N0}' -f ($stats.Firewalls + $stats.Appliances); Caption = 'firewalls and appliances'; Color = 'red1' } @{ Value = '{0:N0}' -f $stats.Gateways; Caption = 'gateways'; Color = 'gold1' } ) [Spectre.Console.AnsiConsole]::WriteLine() $vnets = @($map.Clusters | Where-Object { $_.kind -eq 'vnet' }) $hubs = @($vnets | Where-Object { $_.role -eq 'hub' } | Sort-Object -Property { $_.name }) if ($hubs.Count) { $table = [Spectre.Console.Table]::new() $table.Border = [Spectre.Console.TableBorder]::Rounded $table.BorderStyle = [Spectre.Console.Style]::Parse('mediumpurple2') $table.Title = [Spectre.Console.TableTitle]::new("[bold mediumpurple2]$($glyph.Bullet) Hubs and their spokes[/]") foreach ($header in 'Hub', 'Where', 'Transit', 'Spokes') { $null = $table.AddColumn([Spectre.Console.TableColumn]::new("[grey62]$header[/]")) } foreach ($hub in $hubs) { $inside = @($map.Nodes | Where-Object { $_.parent -and ($_.parent -eq $hub.id -or $_.parent -like "$($hub.id)/subnets/*") -and ($_.type -in 'microsoft.network/azurefirewalls', 'microsoft.network/virtualnetworkgateways', 'microsoft.network/vpngateways', 'microsoft.network/expressroutegateways' -or $_.nva) } | ForEach-Object { "$($_.name) ($($_.typeLabel))" }) $spokes = @($vnets | Where-Object { $_.role -eq 'spoke' -and $_.hub -eq $hub.id } | ForEach-Object { $_.name } | Sort-Object) $cells = @( "[bold]$(& $escape $hub.name)[/]$(if ($hub.external) { ' [grey50](outside the scope)[/]' })" "[grey70]$(& $escape $hub.context)[/]" $(if ($inside.Count) { & $escape ($inside -join ', ') } else { '[grey50]-[/]' }) "[deepskyblue1]$($spokes.Count)[/] [grey58]$(& $escape (($spokes | Select-Object -First 8) -join ', '))$(if ($spokes.Count -gt 8) { ", +$($spokes.Count - 8) more" })[/]" ) [Spectre.Console.TableExtensions]::AddRow($table, [Spectre.Console.Rendering.IRenderable[]]@($cells | ForEach-Object { [Spectre.Console.Markup]::new($_) })) | Out-Null } [Spectre.Console.AnsiConsole]::Write($table) [Spectre.Console.AnsiConsole]::WriteLine() } $findings = @($map.Findings) if ($findings.Count) { $colors = @{ High = 'red1'; Medium = 'orange1'; Info = 'grey62' } Write-AACMarkup "[bold]Findings[/] [grey58]($($findings.Count))[/]" foreach ($group in @($findings | Group-Object -Property Severity, Finding)) { $first = $group.Group[0] $names = @($group.Group | ForEach-Object { $_.Resource } | Select-Object -Unique) Write-AACMarkup " [$($colors[$first.Severity])]$($first.Severity.ToUpperInvariant())[/] $(& $escape $first.Finding) [grey58]($($group.Count)): $(& $escape (($names | Select-Object -First 6) -join ', '))$(if ($names.Count -gt 6) { ", +$($names.Count - 6) more" })[/]" } [Spectre.Console.AnsiConsole]::WriteLine() } } if (-not $NoBrowser) { Open-AACFile -Path $htmlFullPath Write-AACMarkup '[grey58]Opened in your browser: switch between Hub & spoke and Hierarchy at the top; Save PNG, JPEG or SVG there too.[/]' } else { Write-AACMarkup "[grey58]Diagram: $(& $escape $htmlFullPath)[/]" } if ($PassThru) { [pscustomobject]@{ PSTypeName = 'AAC.NetworkTopology' Title = $state.Title Path = $htmlFullPath Networks = @($map.Clusters | Where-Object { $_.kind -eq 'vnet' } | ForEach-Object { $hubId = $_.hub [pscustomobject]@{ PSTypeName = 'AAC.NetworkTopologyNetwork' Name = $_.name; Role = $_.role; Hub = $(if ($_.hub) { (@($map.Clusters | Where-Object { $_.id -eq $hubId } | ForEach-Object { $_.name }) | Select-Object -First 1) } else { '' }); Peerings = [int]$_.peerCount Where = $_.context; AddressSpace = (@($_.detail | Where-Object { $_ -match '\d+\.\d+\.\d+\.\d+/' }) | Select-Object -First 1); Outside = [bool]$_.external; Id = $_.id } }) Findings = @($map.Findings) Nodes = @($map.Nodes | ForEach-Object { [pscustomobject]@{ Name = $_.name; Type = $_.type; Product = $_.typeLabel; Appliance = [bool]$_.nva; Parent = $_.parent; Details = ($_.facts -join ' · '); Outside = $_.external; Id = $_.id } }) Edges = @($map.Edges | ForEach-Object { [pscustomobject]@{ Kind = $_.kind; Source = $_.source; Target = $_.target; Label = $_.label; Warning = [bool]$_.warn } }) Stats = [pscustomobject]$stats } } } |