Private/Read-AACNetworkTopology.ps1
|
function Read-AACNetworkTopology { <# .SYNOPSIS Reads the networks in a scope - and the peered networks, the PaaS behind private endpoints and the route next hops beyond it - and builds the network topology's model. Shared by Show-AACNetworkTopology and Export-AACDiagram. .DESCRIPTION Runs inside Invoke-AACProgress (it reports its steps there). Reads Resource Graph: the subscriptions with their management group chain, the network fabric (and, unless -NetworkOnly, NICs, VMs, scale sets and private endpoints); then what is beyond the scope that those refer to. Returns @{ Map (ConvertTo-AACNetworkTopology's result); Title; Facts }. #> [CmdletBinding()] [OutputType([hashtable])] param( [string[]] $ManagementGroupId = @(), [string[]] $SubscriptionId = @(), # Regions, lower case and without spaces. [string[]] $Location = @(), [ValidateSet('ManagementGroup', 'Subscription', 'Region')] [string] $GroupBy = 'ManagementGroup', [switch] $NetworkOnly, [string[]] $ExcludeType = @(), [string] $Title ) $request = @{ ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }) SubscriptionId = @($SubscriptionId | Where-Object { $_ }) Location = @($Location | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() -replace '\s', '' }) NetworkOnly = [bool]$NetworkOnly } $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') $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 @($ExcludeType | Where-Object { $_ }) $map = ConvertTo-AACNetworkTopology -Map $map -Resource (@($resources) + @($external)) -Subscription $subscriptions -GroupBy $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 ', '))" } $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'))" ) @{ Map = $map; Title = $(if ($Title) { $Title } else { "Network topology: $scopeText" }); Facts = $facts } } |