AzNetworkDiagram.psm1

<#
  .SYNOPSIS
  Creates a Network Diagram of your Azure networking infrastructure.
 
  .DESCRIPTION
  The Get-AzNetworkDiagram (Powershell)Cmdlet visualizes Azure networking utilizing Graphviz and the "DOT", diagram-as-code language to export a PDF and PNG with a network digram containing:
  - VNets, including:
    - VNet peerings
    - Subnets
        - Special subnet: AzureBastionSubnet and associated Azure Bastion resource
        - Special subnet: GatewaySubnet and associated resources, incl. Network Gateways, Local Network Gateways and connections with the static defined remote subnets. But excluding Express Route Cirtcuits.
        - Special subnet: AzureFirewallSubnet and associated Azure Firewall Policy
        - Associated Route Tables
        - A * will be added to the subnet name, if a subnet is delegated. Commonly used delegations will be given a proper icon
        - A # will be added to the subnet name, in case an NSG is associated
 
  IMPORTANT:
  Icons in the .\icons\ folder is necessary in order to generate the diagram. If not present, they will be downloaded to the output directory during runtime.
   
  .PARAMETER OutputPath
  -OutputPath specifies the path for the DOT-based output file. If unset - current working directory will be used.
 
  .PARAMETER Subscriptions
  -Subscriptions "subid1","subid2","..."** - a list of subscriptions in scope for the digram. Default is all available subscriptions.
 
  .PARAMETER EnableRanking
  -EnableRanking $true ($true/$false) - enable ranking (equal hight in the output) of certain resource types. For larger networks, this might be worth a shot. **Default: $true**
 
  .INPUTS
  None. It will however require previous authentication to Azure
 
  .OUTPUTS
  None. .\Get-AzNetworkDiagram.psm1 doesn't generate any output (Powershell-wise). File based out will be save in the OutputPath
 
  .EXAMPLE
  PS> Get-AzNetworkDiagram [-Subscriptions "subid1","subid2","..."] [-OutputPath C:\temp\] [-EnableRanking $true]
  PS> .\Get-AzNetworkDiagram
 
  .LINK
   https://github.com/dan-madsen/AzNetworkDiagram
#>


# Change Execution Policy for current process, if prohibited by policy
# Set-ExecutionPolicy -scope process -ExecutionPolicy bypass

##### Global runtime vars #####
#Rank (visual) in diagram
$global:rankrts = @()
#$global:ranksubnets = @()
$global:rankvnetaddressspaces = @()


##### Functions for standard definitions #####
function Export-dotHeader {
    $Data = "digraph G {
    fontname=`"Arial,sans-serif`"
    node [fontname=`"Arial,sans-serif`"]
    edge [fontname=`"Arial,sans-serif`"]
     
    # Ability fot peerings arrows/connections to end at border
    compound = true;
     
    # Rank (height in picture) support
    newrank = true;
    rankdir = TB;
    "

    Export-CreateFile -Data $Data
}

function Export-dotFooterRanking {
    Export-AddToFile -Data "`n ##########################################################################################################"
    Export-AddToFile -Data " ##### RANKS"
    Export-AddToFile -Data " ##########################################################################################################`n"
    Export-AddToFile -Data " ### AddressSpace ranks"
    $rankvnetaddressspacesdata = " { rank=same; "
    
    $global:rankvnetaddressspaces | ForEach-Object {
        $vnetaddresspacename = $_
        $rankvnetaddressspacesdata += $vnetaddresspacename + "; ";
    }

    Export-AddToFile -Data "$rankvnetaddressspacesdata }"

    Export-AddToFile -Data "`n ### Subnets ranks (TODO!)"
    Export-AddToFile -Data "`n ### Route table ranks"
    $rankroutedata = " { rank=same; "
    
    $rankrts | ForEach-Object {
        $routename = $_
        $rankroutedata += $routename + "; ";
    }
    Export-AddToFile -Data "$rankroutedata }"
}

function Export-dotFooter {
    Export-AddToFile -Data "}" #EOF
}

function Export-CreateFile {
    param([string]$Data)
    $Data | Out-File -Encoding ASCII $OutputPath\AzNetworkDiagram.dot
}

function Export-AddToFile {
    param([string]$Data)
    $Data | Out-File -Encoding ASCII  -Append $OutputPath\AzNetworkDiagram.dot
}

function Export-SubnetConfig {
    Param
    (
        [Parameter(Mandatory = $true, Position = 0)]
        [PSCustomObject[]] $subnetconfig
    )

    $data = ""

    #Loop over subnets
    $subnetconfig | ForEach-Object {
        $subnetconfigobject = $_
        $id = $_.id.replace("-", "").replace("/", "").replace(".", "").ToLower()
        $name = $_.Name
        $AddressPrefix = $_.AddressPrefix

        # vNet
        $vnetid = $_.id
        $vnetid = $vnetid -split "/subnets/"
        $vnetid = $vnetid[0].replace("-", "").replace("/", "").replace(".", "").ToLower()
     
        ##########################################
        ##### Special subnet characteristics #####
        ##########################################
                
        ### NSG ###
        $nsgid = $_.NetworkSecurityGroupText.ToLower()
        if ($nsgid -ne "null") { $nsgid = ($_.NetworkSecurityGroupText | ConvertFrom-Json).id.replace("-", "").replace("/", "").replace(".", "").ToLower() }
        if ($nsgid -ne "null") { $name += " #" }
        
        ### Route Table ###
        $routetableid = $_.RouteTableText.ToLower()
        if ($routetableid -ne "null" ) { $routetableid = (($_.RouteTableText | ConvertFrom-Json).id).replace("-", "").replace("/", "").replace(".", "").ToLower() }
        if ($routetableid -ne "null" ) { $data += " $id -> $routetableid" + "`n" }
        # Moved route table association from just before NATGW

        ### Private subnet - ie. no default outbound internet access ###
        $subnetDefaultOutBoundAccess = $subnetconfigobject.DefaultOutboundAccess #(false if activated)
        if ($subnetDefaultOutBoundAccess -eq $false ) { $name += " *" }


        ##############################################
        ##### Special subnet characteristics END #####
        ##############################################
        
        # Support for different types of subnets (AzFW, Bastion etc.)
        # DOT
        switch ($name) {
            "AzureFirewallSubnet" { 
                $AzFW = $subnetconfigobject.IpConfigurationsText | ConvertFrom-Json
                if ($AzFW -ne "[]") {
                    $AzFWid = (($AzFW.id -split ("/azureFirewallIpConfigurations/"))[0]).replace("-", "").replace("/", "").replace(".", "").ToLower()
                    $AzFWname = $AzFW.id.split("/")[8].ToLower()
                    $AzFWrg = $AzFW.id.split("/")[4]
                    $AzFWobject = Get-AzFirewall -Name $AzFWname -ResourceGroupName $AzFWrg
                    $AzFWpolicyName = $AzFWobject.FirewallPolicy.id.split("/")[8]
                
                    #Private IPs
                    $AzFWPrivateIP = ($AzFWobject.IpConfigurationsText | ConvertFrom-Json).privateIPaddress

                    #Public IPs
                    $AzFWPublicIPsArray = ($AzFWobject.IpConfigurationsText | ConvertFrom-Json).PublicIpAddress
                    $AzFWPublicIPs = ""
                    $AzFWPublicIPsArray.id | ForEach-Object {
                        $rgname = $_.split("/")[4]
                        $ipname = $_.split("/")[8]
                        $publicip = (Get-AzPublicIpAddress -ResourceName $ipname -ResourceGroupName $rgname).IpAddress
                        $AzFWPublicIPs += "$ipname : $publicip \n"
                    }
                    
                    $data = $data + " $id [label = `"\n$name\n$AddressPrefix\n\nName: $AzFWname\nPolicy name: $AzFWpolicyName\n\nPrivate IP : $AzFWPrivateIP\n\nPublic IP(s):\n$AzFWPublicIPs`" ; color = lightgray;image = `"$OutputPath\icons\afw.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];"
                } else { 
                    $data = $data + " $id [label = `"\n$name\n$AddressPrefix`" ; color = lightgray;image = `"$OutputPath\icons\afw.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];"
                }
                
            }
            "AzureBastionSubnet" { 
                $AzBastionName = $subnetconfigobject.IpConfigurationsText | ConvertFrom-Json
                if ($AzBastionName -ne "[]") { 
                    $AzBastionName = ($subnetconfigobject.IpConfigurationsText | ConvertFrom-Json).id.split("/")[8]
                }
                $AzBastionName = $AzBastionName.ToLower()
                $data = $data + " $id [label = `"\n\n$name\n$AddressPrefix\nName: $AzBastionName`" ; color = lightgray;image = `"$OutputPath\icons\bas.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];" 
            }
            "GatewaySubnet" { 
                $data = $data + " $id [label = `"\n\n$name\n$AddressPrefix`" ; color = lightgray;image = `"$OutputPath\icons\vgw.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];" 
                $data += "`n"
                
                #GW DOT
                if ($subnetconfigobject.IpConfigurationsText -ne "[]" ) {
                    $gws = $subnetconfigobject.IpConfigurationsText | ConvertFrom-Json
                    
                    #Multi GW scenearios
                    $gws | ForEach-Object {
                        $gwid = (($_.id -split ("/ipConfigurations/"))[0]).replace("-", "").replace("/", "").replace(".", "").ToLower()
                        $gwname = ($_.id-split "/").split("/")[8].ToLower()
                        $gwrg = ($_.id -split "/").split("/")[4]
                        $gw = Get-AzVirtualNetworkGateway -ResourceGroupName $gwrg -ResourceName $gwname
                        $gwtype = $gw.Gatewaytype

                        # ER vs VPN GWs are handled differently
                        if ($gwtype -eq "Vpn" ) {
                            $gwipobjetcs = $gw.IpConfigurations.PublicIpAddress
                            $gwips = ""
                            $gwipobjetcs.id | ForEach-Object {
                                $rgname = $_.split("/")[4]
                                $ipname = $_.split("/")[8]
                                $publicip = (Get-AzPublicIpAddress -ResourceName $ipname -ResourceGroupName $rgname).IpAddress
                                $gwips += "$ipname : $publicip \n"
                            
                            }
                            $data += " $gwid [color = lightgray;label = `"\n\nName: $gwname`\n\nPublic IP(s):\n$gwips`";image = `"$OutputPath\icons\vgw.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];"
                        } elseif ($gwtype -eq "ExpressRoute") {
                            $data += " $gwid [color = lightgray;label = `"\nName: $gwname`";image = `"$OutputPath\icons\ergw.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];"
                        }
                        $data += "`n"
                        $data += " $id -> $gwid"
                        $data += "`n"
                    }
                }
            }
            default { 
                ##### Subnet delegations #####
                # Might be moved to subnet switch "default" ???
                # Just change the icon, or maybe a line with "Delegation info" ?
                # ((get-azvirtualNetwork| Get-AzVirtualNetworkSubnetConfig).Delegations).Name
                $subnetDelegationName = $subnetconfigobject.Delegations.Name
                
                if ( $null -ne $subnetDelegationName ) {
                    # Delegated

                    $iconname = ""
                    switch ($subnetDelegationName) {
                        "Microsoft.Web/serverFarms" { $iconname = "asp" }
                        "Microsoft.Sql/managedInstances" { $iconname = "sqlmi" } 
                        "Microsoft.Network/dnsResolvers" { $iconname = "dnspr" }
                        Default { $iconname = "snet" }
                        }
                    $data = $data + " $id [label = `"\n\n$name\n$AddressPrefix\n\nDelegated to:\n$subnetDelegationName`" ; color = lightgray;image = `"$OutputPath\icons\$iconname.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];" 
                } else {
                    # No Delegation
                    $data = $data + " $id [label = `"\n$name\n$AddressPrefix`" ; color = lightgray;image = `"$OutputPath\icons\snet.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];" 
                }
            }
        }
        
        $data += "`n"
        
        # DOT VNET->Subnet
        $data = $data + " $vnetid -> $id"
        $data += "`n"
    
        #NATGW
        if ( $null -ne $subnetconfigobject.NatGateway ) {
            #Define NAT GW
            $NATGWID = $subnetconfigobject.NatGateway.id.replace("-", "").replace("/", "").replace(".", "").ToLower()
            
            $name = $subnetconfigobject.NatGateway.id.split("/")[8]
            $rg = $subnetconfigobject.NatGateway.id.split("/")[4]
            $NATGWobject = Get-AzNatGateway -Name $name -ResourceGroupName $rg
            
            #Public IPs associated
            $ips = $NATGWobject.PublicIpAddresses
            $ipsstring = ""
            $ips.id | ForEach-Object {
                $rgname = $_.split("/")[4]
                $ipname = $_.split("/")[8]
                $publicip = (Get-AzPublicIpAddress -ResourceName $ipname -ResourceGroupName $rgname).IpAddress
                $ipsstring += "$ipname : $publicip \n"
            }

            #Public IP prefixes associated
            $ipprefixes = $NATGWobject.PublicIpPrefixes
            $ipprefixesstring = ""
            $ipprefixes.id | ForEach-Object {
                $rgname = $_.split("/")[4]
                $ipname = $_.split("/")[8]
                $prefix = (Get-AzPublicIpPrefix -ResourceName $ipname -ResourceGroupName $rgname).IPPrefix
                $ipprefixesstring += "$ipname : $prefix \n"
            }
        
            $data += " $NATGWID [color = lightgrey;label = `"\n\nName: $name\n\nPublic IP(s):\n$ipsstring\nPublic IP Prefix(es):\n$ipprefixesstring`";image = `"$OutputPath\icons\ng.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];"
            $data += " $id -> $NATGWID" + "`n"

        }
    }

    return $data
}

function Export-vnet {
    param ([PSCustomObject[]]$vnet)
    $vnetname = $vnet.Name
    
    $id = $vnet.id.replace("-", "").replace("/", "").replace(".", "").ToLower()
    
    $vnetAddressSpaces = $vnet.AddressSpace.AddressPrefixes
    $subnetconfig = $vnet | Get-AzVirtualNetworkSubnetConfig
    
    $global:rankvnetaddressspaces += $id

    $header = "
    # $vnetname - $id
    subgraph cluster_$id {
        style = solid;
        color = black;
        node [color = white;];
    "


    # Convert addressSpace prefixes from array to string
    $vnetAddressSpacesString = ""
    $vnetAddressSpaces | ForEach-Object {
        $vnetAddressSpacesString = $vnetAddressSpacesString + $_ + "\n"
    }

    $vnetdata = " $id [color = lightgray;label = `"\nAddress Space(s):\n$vnetAddressSpacesString`";image = `"$OutputPath\icons\vnet.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];`n"

    # Subnets
    if ($subnetconfig) {
        $subnetdata = Export-SubnetConfig $subnetconfig
    }
    $footer = "
        label = `"$vnetname`";
    }
    "


    $alldata = $header + $vnetdata + $subnetdata + $footer
    Export-AddToFile -Data $alldata

    # Peerings
    $vnetPeerings = $vnet.VirtualNetworkPeerings.RemoteVirtualNetworkText
    if ($vnetPeerings) {
        $vnetPeerings = $vnet.VirtualNetworkPeerings.RemoteVirtualNetworkText  | ConvertFrom-Json
    
        $vnetPeerings | ForEach-Object {
            $peering = $_.id.replace("-", "").replace("/", "").replace(".", "").ToLower()
            # DOT
            $data = " $id -> $peering [ltail = cluster_$id; lhead = cluster_$peering;];"
            Export-AddToFile -Data $data
        }
    }
}

function Export-RouteTable {
    param ([PSCustomObject[]]$routetable)
    $routetableName = $routetable.Name
    $id = $routetable.id.replace("-", "").replace("/", "").replace(".", "").ToLower()

    $global:rankrts += $id

    $header = "
    subgraph cluster_$id {
        style = solid;
        color = black;
         
        $id [shape = none;label = <
            <TABLE border=`"1`" style=`"rounded`">
            <TR><TD colspan=`"3`" border=`"0`">$routetableName</TD></TR>
            <TR><TD>Route</TD><TD>NextHopType</TD><TD>NextHopIpAddress</TD></TR>
            "

    
    # Individual Routes
    $data = ""
    $routetable.Routes | ForEach-Object {
        $route = $_
        $addressprefix = $route.AddressPrefix
        $nexthoptype = $route.NextHopType
        $nexthopip = $route.NextHopIpAddress
        $data = $data + "<TR><TD>$addressprefix</TD><TD>$nexthoptype</TD><TD>$nexthopip</TD></TR>"
    }

    # End table
    $footer = "
            </TABLE>>;
            ];
    }
            "

    $alldata = $header + $data + $footer
    Export-AddToFile -Data $alldata
}

function Export-VPNConnection {
    param ([PSCustomObject[]]$connection)
    $name = $connection.Name
    $lgwid = $connection.LocalNetworkGateway2Text.replace("-", "").replace("/", "").replace(".", "").replace("`"", "").ToLower()
    $vpngwid = $connection.VirtualNetworkGateway1Text.replace("-", "").replace("/", "").replace(".", "").replace("`"", "").ToLower()
    
    $data = ""
    $lgwname = $connection.LocalNetworkGateway2Text.split("/")[8].replace("/", "").replace(".", "").replace("`"", "").ToLower()
    $lgwrg = $connection.LocalNetworkGateway2Text.split("/")[4].replace("/", "").replace(".", "").replace("`"", "").ToLower()
    $lgwconnectionname = $name
    $lgwobject = (Get-AzLocalNetworkGateway -ResourceGroupName $lgwrg -name $lgwname)
    $lgwip = $lgwobject.GatewayIpAddress
    $lgwsubnetsarray = $lgwobject.addressSpaceText | ConvertFrom-Json
    $lgwsubnets = ""
    $lgwsubnetsarray.AddressPrefixes | ForEach-Object {
        $prefix = $_
        $lgwsubnets += "$prefix \n"
    }

    #DOT
    $data += " $lgwid [color = lightgrey;label = `"\n\nLocal GW: $lgwname\nConnection Name: $lgwconnectionname\nPeer IP:$lgwip\n\nStatic remote subnet(s):\n$lgwsubnets`";image = `"$OutputPath\icons\lgw.png`";imagepos = `"tc`";labelloc = `"b`";height = 1.5;];"
    $data += " $vpngwid -> $lgwid"
    Export-AddToFile -Data $data
}

function Confirm-Prerequisites {
    $ErrorActionPreference = "Stop"

    if (! (Test-Path $OutputPath)) {}

    # dot.exe executable
    try {
        $dot = (get-command dot.exe -errorAction SilentlyContinue).Path
        if ($null -eq $dot) {
            Write-Output "dot.exe executable not found - please install Graphiz (https://graphviz.org), and/or ensure `"dot.exe` is in `"`$PATH`" !"
            return
        }
    }
    catch {
        Write-Output "dot.exe executable not found - please install Graphiz (https://graphviz.org), and/or ensure `"dot.exe` is in `"`$PATH`" !"
        return
    }
    
    # Load Powershell modules
    try {
        import-module az.network -DisableNameChecking
        import-module az.accounts
    }
    catch {
        Write-Output "Please install the following PowerShell modules, using install-module: Az.Network + Az.Accounts"
        return
    }


    # Azure authentication verification
    $context = Get-AzContext 
    if ($null -eq $context) { 
        Write-Output "Please make sure you are logged in to Azure using Login-AzAccount, and that permissions are granted to resources within scope."
        Write-Output "A login window should appear - hint: they may hide behind active windows!"
        Login-AzAccount
        return
    }

    # Icons available?
    if (! (Test-Path "$OutputPath\icons") ) { Write-Output "Downloading icons to $OutputPath\icons\ ... " ; New-Item -Path "$OutputPath" -Name "icons" -ItemType "directory" | Out-null }
    $icons =  @(
        "afw.png",
        "asp.png",
        "bas.png",
        "dnspr.png",
        "ergw.png",
        "lgw.png",
        "LICENSE",
        "ng.png",
        "snet.png",
        "sqlmi.png",
        "vgw.png",
        "vnet.png"
    )
    
    $icons | ForEach-Object {
        if (! (Test-Path "$OutputPath\icons\$_") ) { Invoke-WebRequest "https://github.com/dan-madsen/AzNetworkDiagram/raw/refs/heads/main/icons/$_" -OutFile "$OutputPath\icons\$_" }
    }
}

function Get-AzNetworkDiagram {
    # Parameters
    param (
        [string]$OutputPath = $pwd,
        [string[]]$Subscriptions,
        [bool]$EnableRanking = $true
    )

    # Reset global vars
    $global:rankrts = @()
    #$global:ranksubnets = @()
    $global:rankvnetaddressspaces = @()

    Write-Output "Checking prerequisites ..."
    Confirm-Prerequisites

    Write-Output "Gathering information ..."

    ##### Data collection / Execution #####

    # Run program and collect data through powershell commands
    Export-dotHeader

    # Set subscriptions to every accessible subscription, if unset
    if ( $null -eq $Subscriptions ) { $Subscriptions = (Get-AzSubscription).Id } 

    $Subscriptions | ForEach-Object {
        # Set Context
        $context = $_
        Set-AzContext $context | Out-null
        $subname = (Get-AzContext).Subscription.Name
        Export-AddToFile "`n ##########################################################################################################"
        Export-AddToFile " ##### $subname "
        Export-AddToFile " ##########################################################################################################`n"

        ### RTs
        Export-AddToFile " ##### $subname - Route Tables #####"
        $routetables = Get-AzRouteTable | Where-Object { ($_.SubnetsText -ne "[]") }
        $routetables | ForEach-Object {
            $routetable = $_
            Export-RouteTable $routetable
        }

        ### vNets (incl. subnets)
        Export-AddToFile " ##### $subname - Virtual Networks #####"
        $vnets = Get-AzVirtualNetwork
        $vnets | ForEach-Object {
            $vnet = $_
            Export-vnet $vnet
        }

        #VPN Connections
        Export-AddToFile " ##### $subname - VPN Connections #####"
        $VPNConnections = Get-AzResource | Where-Object { $_.ResourceType -eq "Microsoft.Network/connections" }
        $VPNConnections | ForEach-Object {
            $connection = $_
            $resname = $connection.Name
            $rgname = $connection.ResourceGroupName
            $connection = Get-AzVirtualNetworkGatewayConnection -name $resname -ResourceGroupName $rgname
            Export-VPNConnection $connection
        }

        Export-AddToFile "`n ##########################################################################################################"
        Export-AddToFile " ##### $subname "
        Export-AddToFile " ##### END"
        Export-AddToFile " ##########################################################################################################`n"
    }

    if ( $EnableRanking ) { Export-dotFooterRanking }
    Export-dotFooter

    ##### Generate diagram #####
    # Generate diagram using Graphviz
    Write-Output "Generating $OutputPath\AzNetworkDiagram.pdf ..."
    dot -Tpdf $OutputPath\AzNetworkDiagram.dot -o $OutputPath\AzNetworkDiagram.pdf
    Write-Output "Generating $OutputPath\AzNetworkDiagram.png ..."
    dot -Tpng $OutputPath\AzNetworkDiagram.dot -o $OutputPath\AzNetworkDiagram.png
} 

Export-ModuleMember -Function Get-AzNetworkDiagram