Public/Invoke-O365EndpointService.ps1

function Invoke-O365EndpointService {
    <#
    .SYNOPSIS
        Retrieves the current Office 365 worldwide endpoints from the Microsoft web service.

    .DESCRIPTION
        Queries the Office 365 IP Address and URL web service (https://endpoints.office.com)
        for the selected service instance (Worldwide by default) and returns one EndpointSet
        object per URL/IP and port combination.

        A client request ID and the last seen version number are cached per instance in
        %LOCALAPPDATA%\pwsh\O365EndpointFunctions\endpoints_clientid_latestversion_<Instance>.txt.
        On each run the cached version is compared against the latest published version:

          - If a newer version is available (or -ForceLatest is used), the endpoints are
            downloaded, flattened into EndpointSet objects and returned. The cache is only
            updated after the endpoints have been fetched successfully.
          - If the cached version is already current, nothing is downloaded and $null is
            returned.

    .PARAMETER tenantName
        The Office 365 tenant name used to replace the placeholders in the URLs returned
        by the web service (for example "contoso" for contoso.onmicrosoft.com).

    .PARAMETER Instance
        The Microsoft 365 service instance to query. One of Worldwide (default), China,
        USGovDoD, or USGovGCCHigh. Each instance is version-cached independently.

    .PARAMETER ServiceAreas
        One or more service areas to return: Common, Exchange, SharePoint, and/or Skype.
        The web service always includes Common (it is a prerequisite for the others). If
        omitted, all service areas are returned. Because the local cache tracks only the
        version number - not the filter - use -ForceLatest when changing this filter between
        runs, otherwise an unchanged version returns nothing.

    .PARAMETER IPv6
        Include IPv6 address ranges in the results. By default only IPv4 ranges are
        requested (the service is called with NoIPv6=true).

    .PARAMETER ForceLatest
        Download and return the endpoints even when the cached version number indicates
        the local data is already up to date.

    .OUTPUTS
        EndpointSet. One object per URL/IP and port combination, or $null when the cached
        version is already current.

    .EXAMPLE
        Invoke-O365EndpointService -tenantName 'contoso'

        Returns the current endpoints for the 'contoso' tenant, or $null if the local
        cache is already up to date.

    .EXAMPLE
        Invoke-O365EndpointService -tenantName 'contoso' -ForceLatest -IPv6

        Forces a fresh download including IPv6 ranges, regardless of the cached version.

    .EXAMPLE
        Invoke-O365EndpointService -tenantName 'contoso' -Instance USGovGCCHigh

        Returns the endpoints for the US Government GCC High cloud instance.

    .EXAMPLE
        Invoke-O365EndpointService -tenantName 'contoso' -ServiceAreas Exchange, SharePoint -ForceLatest

        Returns only the Exchange and SharePoint endpoints (Common is always included by the
        service). -ForceLatest ensures a download because the cache does not track the filter.

    .EXAMPLE
        Invoke-O365EndpointService -tenantName 'contoso' |
            Where-Object category -eq 'Optimize'

        Retrieves the endpoints and filters them to the 'Optimize' connectivity category.

    .LINK
        https://learn.microsoft.com/microsoft-365/enterprise/microsoft-365-ip-web-service
    #>

    [cmdletbinding()]
    [OutputType('EndpointSet')]
    param(

        # The tenant name will be used to replace placeholders in the url returned from the service
        [Parameter(Mandatory=$true)]
        [string]$tenantName,

        # The Microsoft 365 service instance to query
        [Parameter(Mandatory=$false)]
        [ValidateSet('Worldwide','China','USGovDoD','USGovGCCHigh')]
        [string]$Instance = 'Worldwide',

        # One or more service areas to return (Common is always included by the service)
        [Parameter(Mandatory=$false)]
        [ValidateSet('Common','Exchange','SharePoint','Skype')]
        [string[]]$ServiceAreas,

        # Parameter help description
        [Parameter(Mandatory=$false)]
        [switch]$IPv6,

        # Parameter help description
        [Parameter(Mandatory=$false)]
        [switch]$ForceLatest
    )

    # webservice root URL
    $webserviceEndpointUrl = "https://endpoints.office.com"

    # path where client ID and latest version number will be stored
    #$datapath = $Env:TEMP + "\endpoints_clientid_latestversion.txt"
    # $datafile = "endpoints_clientid_latestversion.txt"
    # $dataDir = $Env:LOCALAPPDATA + "\pwsh\O365EndpointFunctions\Invoke-O365EndpointService\"
    # $datapath = $dataDir + $datafile
    $dataDir  = Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'pwsh/O365EndpointFunctions'
    # cache the version per instance so a different instance's version is never compared against
    $datapath = Join-Path -Path $dataDir -ChildPath ('endpoints_clientid_latestversion_{0}.txt' -f $Instance)

    # fetch client ID and version if data file exists; otherwise create new file
    if (Test-Path -Path $datapath) {
        $clientRequestContent = Get-Content $datapath
        $clientRequestId = $clientRequestContent[0]
        $lastVersion = $clientRequestContent[1]
    }
    else {
        if (!(Test-Path -Path $dataDir)) {
            New-Item -ItemType directory -Path $dataDir | Out-Null
        }

        $clientRequestId = [GUID]::NewGuid().Guid
        $lastVersion = "0000000000"
        @($clientRequestId, $lastVersion) | Out-File $datapath
    }

    # guard against a corrupt or truncated cache file so a valid request can still be built
    if ([string]::IsNullOrWhiteSpace($clientRequestId)) {
        $clientRequestId = [GUID]::NewGuid().Guid
    }
    if ([string]::IsNullOrWhiteSpace($lastVersion)) {
        $lastVersion = "0000000000"
    }

    # call version method to check the latest version, and pull new data if version number is different
    try {
        $webserviceEndpointVersion = [EndpointSet]::InvokeRestRequest(("{0}/version/{1}?clientRequestId={2}" -f $webserviceEndpointUrl, $Instance, $clientRequestId))
    }
    catch {
        throw ("Unable to determine the latest Office 365 endpoint version. {0}" -f $_.Exception.Message)
    }

    # make sure the service actually returned a version to compare against
    if (($null -eq $webserviceEndpointVersion) -or [string]::IsNullOrWhiteSpace($webserviceEndpointVersion.latest)) {
        throw "The Office 365 version web service returned no 'latest' version number."
    }

    if (($webserviceEndpointVersion.latest -gt $lastVersion) -or ($ForceLatest.IsPresent)) {
    # Write-EventLog -LogName "Application" -Source $eventSource -EventId $eventNewEndpoints -EntryType Information -Message "New version of Office 365 worldwide commercial service instance endpoints detected" -Category 1

        # Use the NoIPv6 switch to create a string for the rest call
        if($IPv6.IsPresent){
            $NoIPv6 = "false"
        }
        else{
            $NoIPv6 = "true"
        }

        # invoke endpoints method to get the new data (URL-encode the tenant name so special characters are safe)
        $encodedTenantName = [uri]::EscapeDataString($tenantName)
        $endpointsUri = "{0}/endpoints/{1}?format=JSON&clientRequestId={2}&TenantName={3}&NoIPv6={4}" -f $webserviceEndpointUrl, $Instance, $clientRequestId, $encodedTenantName, $NoIPv6

        # optionally restrict the result to specific service areas (Common is always returned)
        if ($ServiceAreas) {
            $endpointsUri += "&ServiceAreas={0}" -f ($ServiceAreas -join ',')
        }

        try {
            $endpointSets = [EndpointSet]::InvokeRestRequest($endpointsUri)
        }
        catch {
            throw ("Unable to download the Office 365 endpoints. {0}" -f $_.Exception.Message)
        }

        # Use List[object] rather than List[EndpointSet]: after a module re-import the class
        # gets a new type identity, and a list bound to the previous EndpointSet type rejects
        # the freshly constructed instances with "Cannot find an overload for Add". Collecting
        # as [object] sidesteps that while still returning EndpointSet objects.
        $endpoints = [System.Collections.Generic.List[object]]::new()
        foreach($endpointSet in $endpointSets){

            if ($null -ne $endpointSet.urls) {
                foreach ($endpointUrl in $endpointSet.urls) {

                    if ($null -ne $endpointSet.tcpPorts) {
                        foreach ($endpointTcpPort in $endpointSet.tcpPorts.Split(",")) {

                            $endpoint = [EndpointSet]::new(
                                $endpointSet.id,
                                $endpointSet.serviceArea,
                                $endpointSet.serviceAreaDisplayName,
                                "url",
                                $endpointUrl,
                                $endpointTcpPort,
                                "",
                                $endpointSet.category,
                                $endpointSet.expressRoute,
                                $endpointSet.required,
                                $endpointSet.notes
                            )

                            $endpoints.Add($endpoint)

                        }
                    }
                    if ($null -ne $endpointSet.udpPorts) {
                        foreach ($endpointUdpPort in $endpointSet.udpPorts.Split(",")) {

                            $endpoint = [EndpointSet]::new(
                                $endpointSet.id,
                                $endpointSet.serviceArea,
                                $endpointSet.serviceAreaDisplayName,
                                "url",
                                $endpointUrl,
                                "",
                                $endpointUdpPort,
                                $endpointSet.category,
                                $endpointSet.expressRoute,
                                $endpointSet.required,
                                $endpointSet.notes
                            )

                            $endpoints.Add($endpoint)

                        }
                    }

                }
            }
            foreach ($endpointIP in $endpointSet.ips) {
                if ($null -ne $endpointSet.tcpPorts) {
                    foreach ($endpointTcpPort in $endpointSet.tcpPorts.Split(",")) {

                        $endpoint = [EndpointSet]::new(
                            $endpointSet.id,
                            $endpointSet.serviceArea,
                            $endpointSet.serviceAreaDisplayName,
                            "ip",
                            $endpointIP,
                            $endpointTcpPort,
                            "",
                            $endpointSet.category,
                            $endpointSet.expressRoute,
                            $endpointSet.required,
                            $endpointSet.notes
                        )

                        $endpoints.Add($endpoint)

                    }
                }
                if ($null -ne $endpointSet.udpPorts) {
                    foreach ($endpointUdpPort in $endpointSet.udpPorts.Split(",")) {

                        $endpoint = [EndpointSet]::new(
                            $endpointSet.id,
                            $endpointSet.serviceArea,
                            $endpointSet.serviceAreaDisplayName,
                            "ip",
                            $endpointIP,
                            "",
                            $endpointUdpPort,
                            $endpointSet.category,
                            $endpointSet.expressRoute,
                            $endpointSet.required,
                            $endpointSet.notes
                        )

                        $endpoints.Add($endpoint)

                    }
                }
            }
        }

        # only cache the new version number after the endpoints were fetched successfully,
        # otherwise a failed endpoints call would leave the cache marked "up-to-date"
        @($clientRequestId, $webserviceEndpointVersion.latest) | Out-File $datapath

        return $endpoints
    }
    else {
    # Write-EventLog -LogName "Application" -Source $eventSource -EventId $eventNoNewEndpoints -EntryType Information -Message "Office 365 worldwide commercial service instance endpoints are up-to-date" -Category 1
        return $null
    }
}