Create-BuildingMappingFromADSites.ps1

<#PSScriptInfo
 
.VERSION 1.1.2-rc1
.GUID 0cf0112f-96e2-4612-bea7-083ef943c249
.AUTHOR martrin@microsoft.com
.COMPANYNAME
.COPYRIGHT
.TAGS
.LICENSEURI
.PROJECTURI
.ICONURI
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
Added support for using subnet and site description as building name.
 
#>


<#
.SYNOPSIS
Retrieves AD Site and Subnet information and creates a subnet mapping file for Call Quality Dashboard (CQD). Performs Check for overlap and duplicates.
 
.DESCRIPTION
The Create-BuildingMappingFromADSites script iterates through all AD Sites and Subnets. All valid IPv4 addresses are exported into a CSV-style format, all other formats like IPv6 as skipped as CQD doesn't support them today.
You can use the optional parameters to specify additional information for the mapping file, including City, Country, Region and all other data fields that CQD supports.
 
The script requires the computer to be domain joined so that it can leverage an AD context to retrieve data. Computers joined to Azure Active Directory aren't supported.
 
The script also performs check for overlap (aka supernetting) and duplicate subnet information. This check can be run without connection to AD if an input file is specified through -InputFileName parameter.
 
.EXAMPLE
Create-BuildingMappingFromADSites.ps1
Read AD site and subnet information and create building mapping file.
 
.EXAMPLE
Create-BuildingMappingFromADSites.ps1 -BuildingNameSource SubnetDescription
Read AD site and subnet information and create building mapping file, uses site description as a name for the building.
 
.EXAMPLE
Create-BuildingMappingFromADSites.ps1 -OutputFileName 'MyFile.csv' -BuildingOfficeType 'CompanyOwned'
Read AD site and subnet information, use OutputFileName MyFile.csv and assign BuildingOfficeType = CompanyOwned
 
.EXAMPLE
Create-BuildingMappingFromADSites.ps1 -ExpressRoute
Read AD site and subnet information, mark all sites to be connected via ExpressRoute
 
.Example
Create-BuildingMappingFromADSites.ps1 -InputFileName MyCQDFile.csv
Reads existing CQD building file and performs check for overlapping or duplicate subnets. Accepts both tab and comma delimited file (.tsv and .csv).
 
.Link
https://docs.microsoft.com/en-us/SkypeForBusiness/using-call-quality-in-your-organization/turning-on-and-using-call-quality-dashboard#upload-building-information
 
.NOTES
File Name: Create-BuildingMappingFromADSites.ps1
Author: Martin Rinas (martrin@microsoft.com)
 
You need to run this script from a Domain joined machine to read data from Active Directory.
#>

[cmdletbinding()]            
param(
    [Parameter(Mandatory=$false)]
    [string]$OutputFileName='.\BuildingFile.csv',
    [Parameter(Mandatory = $false)]
    [ValidateSet('SiteName','SubnetDescription','SiteDescription')]
    [string] $BuildingNameSource = 'SiteName',
    [Parameter(Mandatory=$false)]
    [string]$NetworkName='',    
    [Parameter(Mandatory=$false)]
    [string]$OwnershipType='',
    [Parameter(Mandatory=$false)]
    [string]$BuildingType='',
    [Parameter(Mandatory=$false)]
    [string]$BuildingOfficeType='',
    [Parameter(Mandatory=$false)]
    [string]$City='',
    [Parameter(Mandatory=$false)]
    [string]$ZipCode='',
    [Parameter(Mandatory=$false)]
    [string]$Country='',
    [Parameter(Mandatory=$false)]
    [string]$State='',
    [Parameter(Mandatory=$false)]
    [string]$Region='',
    [Parameter(Mandatory=$false)]
    [Switch]$ExpressRoute,
    [Parameter(Mandatory=$false)]
    [string]$InputFileName
)            

function DottedToBinary 
{
    <#
    .SYNOPSIS
    Converts a dotted IPv4 address into binary
    #>
 
    param
    (
        # The dotted IPv4 address to convert, e.g. 192.168.1.1
        [Parameter(Mandatory=$true)]
        [string]$DottedIP
    )

    $DottedIP.split(".") | ForEach-Object {$BinaryIP += $([convert]::ToString($_,2).padleft(8,"0"))}
    Return [string]$BinaryIP   
}

function Compare-IPSubnets {
    <#
    .SYNOPSIS
    Compares two IPv4 subnets and tells if the subnets are identical or if one is a subnet to another.
    #>

    [CmdletBinding()]
    param
    (
        # this functions expects two dottend IPAddresses (e.g. 192.168.1.1) and the subnet mask bits (e.g. 24)
        [Parameter(Mandatory=$true)]
        [string]$FirstIPAddressDotted,
        [Parameter(Mandatory=$true)]
        [int]$FirstSubnetMaskBits,
        [Parameter(Mandatory=$false)]
        [string]$SecondIPAddressDotted,
        [Parameter(Mandatory=$true)]
        [int]$SecondSubnetMaskBits       
    )
    
    $Result = New-Object System.Object
 
    $Result | Add-Member -MemberType NoteProperty -Name CompareStatus -TypeName string -value $null        
    $Result | Add-Member -MemberType NoteProperty -Name FirstIPAddressDotted -TypeName string  -value $FirstIPAddressDotted
    $Result | Add-Member -MemberType NoteProperty -Name SecondIPAddressDotted -TypeName string  -value $SecondIPAddressDotted
    $Result | Add-Member -MemberType NoteProperty -Name CompareDetails -TypeName string -value $null
    $Result | Add-Member -MemberType NoteProperty -Name IsSubnetSupernet -TypeName Bool -value $null
    $Result | Add-Member -MemberType NoteProperty -Name Match -TypeName bool  -value $false
       
    # check for identical subnets, can shorten the process in this case
    if(($FirstIPAddressDotted -eq $SecondIPAddressDotted) -and ($FirstSubnetMaskBits -eq $SecondSubnetMaskBits) )
    {
        $message = "Subnet and Mask are identical: "
        $message += "$FirstIPAddressDotted/$FirstSubnetMaskBits and $SecondIPAddressDotted/$SecondSubnetMaskBits"
        Write-Debug $message
        $Result.IsSubnetSupernet = $false
        $Result.CompareDetails = $message
        $result.CompareStatus = $strIdenticalSubnets
        $result.match = $true
        
        return $Result

    }

    # Identify smaller subnet and larger supernet
    if($SecondSubnetMaskBits -gt $FirstSubnetMaskBits)
    {
        $SupernetDotted = $FirstIPAddressDotted
        $SupernetMaskBits = $FirstSubnetMaskBits
        
        $SubnetDotted = $SecondIPAddressDotted
        $SubnetMaskBits = $SecondSubnetMaskBits
        
    }
    else {

        $SupernetDotted = $SecondIPAddressDotted
        $SupernetMaskBits = $SecondSubnetMaskBits
        
        $SubnetMaskBits = $FirstSubnetMaskBits
        $SubnetDotted = $FirstIPAddressDotted
    }

    # Convert dotted network to binary and identify the actual network ID
    $SubnetBinary = DottedToBinary($SubnetDotted)
    $SubnetBinaryNetworkdID = $SubnetBinary.Substring(0,$SubnetMaskBits).PadRight(32,"0")

    $SupernetBinary = DottedToBinary($SupernetDotted)
    $SupernetBinaryNetworkID = $supernetBinary.Substring(0,$SupernetMaskBits).PadRight(32,"0")

    # compare if supernet network ID is the same as the subnet network ID if we apply the supernet mask to the subnet ID
    if($SubnetBinaryNetworkdID.Substring(0,$SupernetMaskBits) -eq $SupernetBinaryNetworkID.substring(0,$SupernetMaskBits))
    {
        if($SupernetMaskBits -eq $SubnetMaskBits)
        {
            $message = "Subnet and Mask are identical: $SubnetDotted/$SubnetMaskBits is same Subnet as $SupernetDotted/$SupernetMaskBits"
            Write-Verbose $message
            
            $Result.IsSubnetSupernet = $false
            $Result.CompareDetails = $message
            $result.CompareStatus = $strIdenticalSubnets
            $result.match = $true
            
        }
        else 
        {
            $message = "Overlapping Subnet/Supernet found: $SubnetDotted/$SubnetMaskBits is a subnet to $SupernetDotted/$SupernetMaskBits"
            Write-Verbose $message
            
            $Result.IsSubnetSupernet = $true
            $Result.CompareDetails = $message
            $result.CompareStatus = $strOverlapFound
            $result.match = $true
            
        }
        return $Result
    }
    # no overlap found, returning empty result set
    return $Result
}

function Get-CQDSubnetsFromAD
{
    $SubnetMappingFile = @()
    
    # get all Sites from currenct AD Forest
    try {
        Write-Verbose 'Connecting to current AD forest'
        $CurrentForest = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest()  
        $CurrentForestName = $CurrentForest.Name
        $Sites = $CurrentForest.Sites                   
        $ConfigurationPartitionDN = $forest.Schema.name.Substring(10)
        Write-Verbose "Connected to $CurrentForestName"
    }
    catch {
        # This is a fatal error. Cannot continue without connectivity to AD.
        Write-error -Message 'Could not connect to Active Directory.'
        Write-Warning $_.Exception.Message
        Write-Warning 'Please make sure that this computer is member of an Active Directory domain and you are logged on to the domain. Azure Active Directory joined computers are not supported at this time.'
        Write-Verbose ('error: {0}' -f $_.Exception)
        break
    }

    Write-Verbose ('Processing {0} sites from {1}' -f $sites.count, $CurrentForestName)

    # Looping through all sites
    foreach ($Site in $Sites) {            

        write-verbose ('Processing site: {0}' -f $site.name)
        write-verbose ('Found {0} subnets' -f $site.subnets.count)
        
        # Processing all subnets within the same site
        foreach ($subnet in $site.Subnets)
        {
            write-verbose ('Processing subnet: {0}' -f $subnet.name)
            
            #split Subnet into Address and mask
            $SplitSubnet = $subnet.name.split("/")

            if ($SplitSubnet.count -ne 2)
            {
                # testing if split was successufl
                write-verbose ('Cannot split {0} into Subnet ID and mask, skipping..."' -f $Subnet.name)
                continue
            }
            
            # is this a valid IPv4 address? CQD doesn't support IPv6
            if(!($SplitSubnet[0] -match $IPv4Regex))
            {
                # not a IPv4 address, continue with next subnet for same site
                write-verbose ('Skipping subnet, not a valid IPv4 address: {0}' -f $SplitSubnet)
                continue
            }
            
            switch ($BuildingNameSource) {
                SiteName { $BuildingName = ([string]$Subnet.Site) }
                SubnetDescription 
                {
                    $ADSIPath = ('LDAP://CN={0},CN=Subnets,CN=Sites,{1}' -f ([string]$Subnet).replace('/','\/'), $ConfigurationPartitionDN )
                    Write-Verbose "Retrieving Site Description from Description of Subnet: $ADSIPath"
                    $objSubnet = [ADSI]$ADSIPath
                    [string]$BuildingName = $objSubnet.description

                    if([string]::IsNullOrEmpty($BuildingName))
                    {
                        Write-Verbose ('No description found for subnet {0}, using Sitename {1} instead' -f [string]$Subnet, [string]$subnet.Site)
                        $BuildingName = [string]$Subnet.Site
                    }
                }
                SiteDescription 
                {
                    $ADSIPath = ('LDAP://CN={0},CN=Sites,{1}' -f [string]$Subnet.Site, $ConfigurationPartitionDN )
                    Write-Verbose "Retrieving Site Description from Description of Site: $ADSIPath"
                    $objSites = [ADSI]$ADSIPath
                    [string]$BuildingName = $objSites.description
                    if([string]::IsNullOrEmpty($BuildingName))
                    {
                        Write-Verbose ('No description found for site {0}, using Sitename {1} instead' -f [string]$Subnet.site, [string]$subnet.Site)
                        $BuildingName = [string]$Subnet.Site
                    }
                }
            }

            # Fallback if Description is empty


            # create and populate our object
            $Row = New-Object System.Object
            
            # replacing any comma character ',' in the sting with an underscore '_' so that the comma doesn't mess up with the CSV export
            # AD doesn't allow any special characters in the site name, no special handling required there.
            $Row | Add-Member -MemberType NoteProperty -Name Network -Value ([string]$SplitSubnet[0])
            $Row | Add-Member -MemberType NoteProperty -Name NetworkName -Value  $NetworkName.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name NetworkRange -Value ([int]$SplitSubnet[1])
            $Row | Add-Member -MemberType NoteProperty -Name BuildingName -Value $BuildingName.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name OwnershipType -Value $OwnershipType.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name BuildingType -Value $BuildingType.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name BuildingOfficeType -Value $BuildingOfficeType.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name City -Value $City.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name ZipCode -Value $ZipCode.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name Country -Value $Country.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name State -Value $State.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name Region -Value $Region.replace(',','_')
            $Row | Add-Member -MemberType NoteProperty -Name InsideCorp -Value '1'
            $Row | Add-Member -MemberType NoteProperty -Name ExpressRoute -Value ([int]$ExpressRoute)

            # Add results to mapping file
            $SubnetMappingFile += $Row
        }
    }
    return $SubnetMappingFile
}

#region define variables and prepare for execution

# a few strings used that'll be used in the issues report
New-Variable -name strIdenticalSubnets -Value 'IdenticalSubnetsFound' -Option ReadOnly
New-Variable -Name strOverlapFound -Value 'OverlappingSubnetsFound' -Option ReadOnly

# RegEx to detect a single IPv4 Octet
New-Variable -Name Octet -Value  '(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9]{2}|2[0-5][0-5]|2[0-4][0-9])' -Option ReadOnly
# and we need four of them for a valid IPv4 address, combine them into a RegEx
[regex] $IPv4Regex = "^(?:$Octet\.){3}$Octet$"

# Cannot process Switch parameter during export, have to convert it into bool.
if($ExpressRoute)
{
    [bool]$ExpressRoute = $true
}
else 
{
    [bool]$ExpressRoute = $false
        
}

# This file will collect overlap and duplicats, if there are any.
$IssuesFileName = 'Issues.csv'

# Define colum header if we need to read from a CQD input file as this file doesn't contain any headers
$CQDHeader = "Network", "NetworkName" , "NetworkRange" , "BuildingName", "OwnershipType", "BuildingType", "BuildingOfficeType", "City", "ZipCode", "Country", "State", "Region", "InsideCorp", "ExpressRoute"
New-Variable -Name CQDDelimiter -Value `t

# Create the empty array as a container for the mapping file
$SubnetMappingFile = @()

#endregion

#region main script

# check if an input file is given, don't need to fetch data from AD in this case
if([string]::IsNullOrEmpty($InputFileName))
{
    # no input file given, fetch data from AD
    
    # Test if output file already exist, let's not override any existing file
    if(Test-Path($OutputFileName))
    {
        $ExistingFile = get-item $OutputFileName
        Write-Error ('File {0} already exists, please remove existing file.' -f $ExistingFile.FullName)
        break
    }
    Write-Host
    Write-Host "Reading subnet data from AD"
    Write-Host
    $SubnetMappingFile = Get-CQDSubnetsFromAD
    if($SubnetMappingFile.Length -lt 1)
    {
        # No subnets in AD found
        Write-Error ('No IPv4 Subnets in Forest {0} found.' -f [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest().Name)
        break
    }

}
else 
{
    # file found, lets import
    Write-Host 
    Write-Host "Reading input file $InputFileName"
    Write-Host

    # Check if file is comma or tab separated
    $FirstLine = get-content -Path $InputFileName
    if(!$FirstLine.Contains($CQDDelimiter))
    {
        # doesn't contain tab, assuming comma as separator
        $CQDDelimiter = ','
    }

    $SubnetMappingFile = Import-Csv -Path $InputFileName -Delimiter $CQDDelimiter -Header $CQDHeader -ErrorAction Stop
}

# retrieved all subnets

Write-Host ('Found {0} IPv4 subnets' -f $SubnetMappingFile.Length)

# loop through all records, compare subnet[i] with all subnet[n>i] and store results
# report on results (if resultset not empty, call out issues found)

$FirstSiteIndex=0
$SiteCount = $SubnetMappingFile.Count

write-host "Checking $SiteCount subnets for overlap and duplicates. This may take a while for a large amount of subnets."
Write-Host

$results = @()
foreach ($FirstSite in $SubnetMappingFile)
{
    Write-Progress -Activity "Checking $SiteCount Subnets for overlap and duplicates" -Status ('Processing network {0}' -f $Firstsite.Network) -PercentComplete (($FirstSiteIndex+1)/$SiteCount*100)
    write-verbose "Processing FirstSite: $FirstSiteIndex of $SiteCount"
    
    if ($FirstSiteIndex -lt $SiteCount-1)
    {
        foreach ($SecondSiteIndex in ($FirstSiteIndex+1)..($SiteCount-1))
        {
            $SecondSite = $SubnetMappingFile[$SecondSiteIndex]
            
            write-debug "Comparing FirstSite: $FirstSiteIndex with SecondSite: $SecondSiteIndex"
            Write-debug ('First site: {0} second site: {1}' -f $FirstSite.Network, $SecondSite.Network)
            
            $result = New-Object System.Object
            $result = Compare-IPSubnets -FirstIPAddressDotted $FirstSite.Network -FirstSubnetMaskBits $FirstSite.NetworkRange -SecondIPAddressDotted $SecondSite.Network -SecondSubnetMaskBits $SecondSite.NetworkRange
            if($result.match)
            {
                # found match, needs to be fixed
                $results += $result
            }
        }
    }
    
    $FirstSiteIndex++
    write-debug "Next first site"
}

# Are there any issues?
if($results.Length -gt 0 ) 
{
    Write-Warning ('{0} Overlapping or duplicate subnets found. Please check output in {1} and resolve issues before uploading to CQD. You may use the -InputFile paramater to check the adjusted file.' -f $results.Length, $IssuesFileName)
    Write-Host
    $results | Export-csv -NoTypeInformation -Path $IssuesFileName
}
else 
{
    Write-Host "No overlapping or duplicate subnets found." -ForegroundColor Green
    Write-Host
}

# Export the file only if no input file was given.
if([string]::IsNullOrEmpty($InputFileName))
{
    # Convert into CSV format
    $SubnetMappingFile = $SubnetMappingFile | convertto-csv -NoTypeInformation -Delimiter ','

    # CQD doesn't expect quotes in the text, so we have to remove them
    $SubnetMappingFile = $SubnetMappingFile.Replace('"','')

    Write-Host ('Saving building mapping file: {0}' -f $OutputFileName ) -ForegroundColor Green

    # CDQ also doesn't expect column headings, so we remove them and save the remaining content to disk
    $SubnetMappingFile[1..$SubnetMappingFile.Length] | Set-Content -Path $OutputFileName -ErrorAction Stop
}

Write-Host
Write-Host 'Please validate the accuracy, adjust and complete as needed.' -ForegroundColor Green
Write-Host 'You may use the graphical interface of the Network Planner at https://aka.ms/MyAdvisor for easier editing and to start bandwith planning.'
Write-Host 'Upload the file to CQD at https://cqd.lync.com to complete the process.'
Write-Host

#endregion