VNet.psm1

class Subnet {
    [string] $CIDR
    [NET.IPAddress] $Start
    [NET.IPAddress] $End

    [string] ToString() {
        return $this.CIDR
    }
}

class Free {
    [NET.IPAddress] $Start
    [NET.IPAddress] $End
    [int] $Size
    [string[]] $CIDRAvailable

    [string] ToString() {
        return $this.Size
    }
}

class VNetSummary {
    [string] $VNetStart
    [string] $VNetEnd
    [free[]] $Available
    [subnet[]] $Subnets
}

<#
.SYNOPSIS
    List subnets for a VNet and show any unallocated gaps

.DESCRIPTION
    Returns a list of subnets in use for a particular VNET and also any gaps available

.PARAMETER ResourceGroup
    The name of the resource group that contains a virtual network

.PARAMETER VNetName
    The name of the virtual network to query for subnets

.NOTES
    This function uses Azure PowerShell cmdlets, so assumes that you've already run Connect-AzAccount to sign in to Azure.

.EXAMPLE
    $result = Find-FreeSubnets -ResourceGroup rg-freesubnet-australiaeast -VNetName vnet-freesubnet-australiaeast

    Displaying $result:

    VNet Start VNet End Available Subnets
    ---------- -------- --------- -------
    10.0.0.0 10.0.255.255 {48, 8} {10.0.0.0/24, 10.0.1.0/28, 10.0.1.64/28, 10.0.1.88/29}

    And the 'Available' property contains:

    Start End Size Available ranges
    ----- --- ---- ----------------
    10.0.1.16 10.0.1.63 48 {10.0.1.16/28, 10.0.1.32/27, 10.0.1.32/28, 10.0.1.48/28}
    10.0.1.80 10.0.1.87 8

    The 'Available ranges' array is a list of one or more CIDR ranges that could utilise the available IP addresses.
#>

function Find-FreeSubnets {
    [CmdletBinding()]
    [OutputType([VNetSummary])]
    param (
        [string]
        $ResourceGroup,
        [string]
        $VNetName
    )

    begin {

        # https://gist.github.com/davidjenni/7eb707e60316cdd97549b37ca95fbe93
        function cidrToIpRange {
            param (
                [string] $cidrNotation
            )

            $addr, $maskLength = $cidrNotation -split '/'
            [int]$maskLen = 0
            if (-not [int32]::TryParse($maskLength, [ref] $maskLen)) {
                throw "Cannot parse CIDR mask length string: '$maskLen'"
            }
            if (0 -gt $maskLen -or $maskLen -gt 32) {
                throw "CIDR mask length must be between 0 and 32"
            }
            $ipAddr = [Net.IPAddress]::Parse($addr)
            if ($ipAddr -eq $null) {
                throw "Cannot parse IP address: $addr"
            }
            if ($ipAddr.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
                throw "Can only process CIDR for IPv4"
            }

            $shiftCnt = 32 - $maskLen
            $mask = -bnot ((1 -shl $shiftCnt) - 1)
            $ipNum = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($ipAddr.GetAddressBytes(), 0))
            $ipStart = ($ipNum -band $mask)
            $ipEnd = ($ipNum -bor (-bnot $mask))

            # return as tuple of strings:
            $bytes = [BitConverter]::GetBytes([Net.IPAddress]::HostToNetworkOrder($ipStart))
            New-Object -TypeName "Net.IPAddress" -argumentList (, $bytes)
            $bytes = [BitConverter]::GetBytes([Net.IPAddress]::HostToNetworkOrder($ipEnd))
            New-Object Net.IPAddress -ArgumentList (, $bytes)
        }

        $vnet = Get-AzVirtualNetwork -Name $VNetName -ResourceGroupName $ResourceGroup

        [Net.IPAddress] $vnetStart, [Net.IPAddress] $vnetEnd = cidrToIpRange $vnet.AddressSpace.AddressPrefixes

        $result = [VNetSummary]::new()
        $result.VNetStart = $vnetStart
        $result.VNetEnd = $vnetEnd

        # Create fake subnet immediately following the end of the VNet
        $ipNum = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($vnetEnd.GetAddressBytes(), 0)) + 1
        $bytes = [BitConverter]::GetBytes([Net.IPAddress]::HostToNetworkOrder($ipNum))
        [Net.IPAddress] $afterLastAvailable = New-Object Net.IPAddress -ArgumentList (, $bytes)

        $afterLastAvailableCidr = "$afterLastAvailable/31"

        $sorted = @($vnet.Subnets.AddressPrefix) + $afterLastAvailableCidr | Sort-Object -Property {
            $addr, $maskLength = $_ -split '/'

            $ip = ([Net.IPAddress] $addr)
            $ipNum = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($ip.GetAddressBytes(), 0))
            $ipNum
        }

        $maskToAddresses = @{ 28 = 16; 27 = 32; 26 = 64; 25 = 128 }
        $addressToStarts = @{
        }

        $maskToAddresses.Values | ForEach-Object {
            $addressToStarts.Add($_, $(for ($i = 0; $i -lt 255; $i += $_) { $i }))
        }

        $nextAvailableNum = 0

        $notFirst = $false

        foreach ($cidr in $sorted) {

            $start, $end = cidrToIpRange $cidr

            $startNum = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($start.GetAddressBytes(), 0))
            if ($notFirst -and $nextAvailableNum -ne $startNum ) {

                $bytes = [BitConverter]::GetBytes([Net.IPAddress]::HostToNetworkOrder($nextAvailableNum))
                $nextAvailable = New-Object Net.IPAddress -ArgumentList (, $bytes)

                $bytes = [BitConverter]::GetBytes([Net.IPAddress]::HostToNetworkOrder($startNum-1))
                $lastAvailable = New-Object Net.IPAddress -ArgumentList (, $bytes)

                $free = [Free]::new()
                $free.Start = $nextAvailable
                $free.End = $lastAvailable
                $free.Size = $startNum - $nextAvailableNum
                $result.Available += $free

                for ($i = $nextAvailableNum; $i -lt $startNum; $i += 8) {
                    $bytes = [BitConverter]::GetBytes([Net.IPAddress]::HostToNetworkOrder($i))
                    $freeIp = New-Object Net.IPAddress -ArgumentList (, $bytes)

                    foreach ($mask in ($maskToAddresses.Keys | Sort-Object)) {
                        $address = $maskToAddresses[$mask]
                        if ($addressToStarts[$address] -contains $bytes[3] ) {
                            $freeCidr = $freeIp.IPAddressToString + "/$mask"

                            # Check this doesn't overlap next
                            $possibleFreeStart, $possibleFreeEnd = cidrToIpRange $freeCidr

                            $possibleFreeEndNum = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($possibleFreeEnd.GetAddressBytes(), 0))
                            if ($possibleFreeEndNum -lt $startNum) {
                                $free.CIDRAvailable += $freeCidr
                            }
                        }
                    }
                }
            }

            $notFirst = $true

            if ($cidr -ne $afterLastAvailableCidr) {
                $subnet = [Subnet]::new()
                $subnet.CIDR = $cidr
                $subnet.Start = $start
                $subnet.End = $end
                $result.Subnets += $subnet
            }

            $nextAvailableNum = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($end.GetAddressBytes(), 0)) + 1

            $bytes = [BitConverter]::GetBytes([Net.IPAddress]::HostToNetworkOrder($ipEnd))
        }

        if ($end -ne $vnetEnd) {
            $bytes = [BitConverter]::GetBytes([Net.IPAddress]::HostToNetworkOrder($nextAvailableNum))
            $nextAvailable = New-Object Net.IPAddress -ArgumentList (, $bytes)
        }

        $result
    }
}