Si estás integrando Dynamics 365 Finance & Operations con terceros, y tu organización o la del tercero están usando un firewall, puede que te hayas encontrado en el escenario de que te pregunten «¿cuál es la dirección IP del entorno de producción/sandbox?».

Pues bien, no lo sabemos. Sabemos que IP tiene ahora, pero no sabemos si tendrá la misma IP en el futuro, tendrás que hacer un monitoreo de esto si planeas abrir IPs únicas. Esto es algo que Dag Calafell escribió en su blog: Static IP not guaranteed for Dynamics 365 for Finance and Operations.

Monitoreo a ojo
Monitoreo a ojo

Así que, ¿qué tengo que hacer si tengo un firewall y necesito permitir el acceso a/desde Dynamics 365 F&O o cualquier otro servicio de Azure? Al equipo de red no le suele gustar la respuesta: si no puedes permitir un FQDN, debes abrir todos los rangos de direcciones para el centro de datos y el servicio al que quieres acceder. Y eso son muchas direcciones que hacen que el equipo de red se ponga triste.

En el post de hoy, os mostraré una forma de monitorear los rangos proporcionados por Microsoft, y espero que nos haga la vida más fácil.

AVISO: a raíz de este comentario en Linkedin quiero aclarar que estos rangos servirían para comunicación entrante a Dynamics 365 o el servicio que fuera. Para la comunicación saliente ver este documento en Learn: For my Microsoft-managed environments, I have external components that have dependencies on an explicit outbound IP safe list. How can I ensure my service is not impacted after the move to self-service deployment?

Rangos IP de Azure: ¿podemos hacer monitoreo?

Microsoft ofrece un archivo JSON que puedes descargar con los rangos para todos sus centros de datos de nube pública y diferentes servicios. Sí, un archivo, no una API.

Pero espera, no te quejes todavía, que SÍ hay una API que podemos usar: la Azure REST API. Y en concreto la sección de Service Tags dentro de Virtual Networks. Los resultados de llamar a esta API o descargar el archivo son un poco diferentes, el JSON está estructurado de manera diferente, pero ambos podrían servir a nuestro propósito.

Mi propuesta: una función Azure

Estaremos llamando a una API REST, por lo que perfectamente podríamos estar utilizando un flow de Power Automate para hacer esto. ¿Por qué estoy haciendo trabajo de más con una función Azure? Porque odio parsear JSON en Power Automate. Esa es la razón principal, pero no la única. También porque me encantan las funciones de Azure.

Autenticación

Para autenticarnos y poder acceder a la API REST de Azure necesitamos crear un registro de aplicación de Azure Active Directory, esto ya lo hemos hecho un millón de veces, ¿verdad? No hace falta repetirlo.

También necesitaremos un secreto para ese app registration. Guarda ambos. Crearemos un service principal usándolos.

Ve a tu suscripción, a la sección «Access control (IAM)» y añade una nueva asignación de rol:

Creamos el service principal
Creamos el service principal

Seleccionamos el rol «contributor»:

Rol contributor
Rol contributor

Clicamos en el texto «Select members» y buscamos el nombre de la app registration que hemos creado antes:

Añadir miembros
Añadir miembros

Por último, hacemos clic en el botón «Select» y en el de «Review + assign» para terminar. Ahora tenemos un service principal con acceso a una suscripción. Vamos a utilizar esto para autenticar y acceder a la API REST de Azure.

La función

La función tendrá un trigger HTTP, y leerá y escribirá datos en una Azure Table. Haremos una llamada POST al endpoint de la función, y eso iniciará el proceso.

Un bonito diagrama
Un bonito diagrama

A continuación, la función descargará el contenido JSON de la API REST de Azure, hará varias cosas, como buscar en la tabla si almacenamos una versión anterior del archivo de direcciones, comparará ambas y guardará la última versión en la tabla. Este es el código de la función:

[FunctionName("CheckRanges")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            log.LogInformation("C# HTTP trigger function processed a request.");

            string ret = string.Empty;

            try
            {
                string body = String.Empty;

                using (StreamReader streamReader = new StreamReader(req.Body))
                {
                    body = await streamReader.ReadToEndAsync();
                }

                if (string.IsNullOrEmpty(body))
                {
                    throw new Exception("No request body found.");
                }

                dynamic data = JsonConvert.DeserializeObject(body);
                string serviceTagRegion = data.serviceTagRegion;
                string region = data.region;

                if (string.IsNullOrEmpty(serviceTagRegion) || string.IsNullOrEmpty(region))
                {
                    throw new Exception("The values in the cannot be empty.");
                }

                // Get token and call the API
                var token = GetToken().Result;

                var latestServiceTag = GetFile(token, region).Result;

                if (latestServiceTag is null)
                {
                    throw new Exception("No tag file has been downloaded.");
                }

                // Download existing file from the blob, if exists, and compare the root changeNumber                
                var existingServiceTagEntity = await ReadTableAsync();

                // If there's a file in the blob container we retrieve it and compare the changeNumber value. If it's the same there's no changes in the file.
                if (existingServiceTagEntity is not null)
                {
                    if (existingServiceTagEntity.ChangeNumber == latestServiceTag.changeNumber)
                    {
                        // Return empty containers in the JSON file
                        AddressChanges diff = new AddressChanges();

                        diff.addedAddresses = Array.Empty<string>();
                        diff.removedAddresses = Array.Empty<string>(); ;

                        ret = JsonConvert.SerializeObject(diff);

                        log.LogInformation("The downloaded file has the same changenumber as the already existing one. No changes.");

                        // Return empty JSON containers
                        return new OkObjectResult(ret);
                    }
                }

                // Process the new file
                var serviceTagSelected = latestServiceTag.values.FirstOrDefault(st => st.name.ToLower() == serviceTagRegion);

                if (serviceTagSelected is not null)
                {
                    ServiceTagAddresses addresses = new ServiceTagAddresses();

                    addresses.rootchangenumber = latestServiceTag.changeNumber;
                    addresses.nodename = serviceTagSelected.name;
                    addresses.nodechangenumber = serviceTagSelected.properties.changeNumber;
                    addresses.addresses = serviceTagSelected.properties.addressPrefixes;

                    // If a file exists in the table get the differences
                    if (existingServiceTagEntity is not null)
                    {
                        string[] existingAddresses = JsonConvert.DeserializeObject<string[]>(existingServiceTagEntity.Addresses);

                        ret = CompareAddresses(existingAddresses, addresses.addresses);
                    }

                    // Finally upload the file with the new addresses
                    var newAddressJson = JsonConvert.SerializeObject(addresses);

                    //await UploadFileAsync(fileName, newAddressJson);
                    await WriteToTableAsync(addresses);
                }
                else
                {
                    AddressChanges diff = new AddressChanges();

                    diff.addedAddresses = Array.Empty<string>();
                    diff.removedAddresses = Array.Empty<string>();

                    ret = JsonConvert.SerializeObject(diff);

                    // Return empty JSON containers
                    return new OkObjectResult(ret);
                }
            }
            catch (Exception ex)
            {
                return new BadRequestObjectResult(ex.Message);
            }

            return new OkObjectResult(ret);
        }

He añadido comentarios al código para hacerlo más claro, pero vamos a repasarlo un poco. Puedes encontrar la solución completa de Visual Studio en el repositorio de GitHub AzureTagsIPWatcher.

Variables de entorno

Encontrarás este trozo de código varias veces en el código. Se utiliza para recuperar las variables de entorno:

Environment.GetEnvironmentVariable("ContainerName")

Significa que una vez que hayas desplegado la función, necesitas crear tantas configuraciones de aplicación diferentes en la función Azure como variables diferentes encontrarás en el código. Las variables se utilizan para guardar el valor de las claves, secretos, etc. para crear las conexiones con el almacenamiento y obtener las credenciales del token. Estas son las que yo he utilizado, y que debes completar con tus propios valores:

  • ContainerConn: la cadena de conexión a tu cuenta de almacenamiento Azure.
  • ContainerName: el nombre de la cuenta de almacenamiento.
  • StorageKey: la clave de tu cuenta de almacenamiento.
  • StorageTable: el nombre de la tabla.
  • appId: el id de registro de tu app.
  • secret: el secreto de tu app.
  • tokenUrl: esta será la URL utilizada para obtener el token. Tienes que cambiar YOUR_TENANT_ID por el id de tu tenant: «https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/token».
  • resource: «https://management.azure.com/», esta es la URL en la que nos autenticaremos.
  • apiUrl: «https://management.azure.com/subscriptions/YOUR_SUBSCRIPTION_ID/providers/Microsoft.Network/locations/YOUR_REGION/serviceTags?api-version=2022-07-01», esta es la URL de la API. Tienes que cambiar YOUR_SUBSCRIPTION_ID por el id de la suscripción en la que has creado el service principal.

La función espera una llamada POST con un cuerpo JSON en su petición, conteniendo el nodo del que queremos obtener las direcciones, y la región:

{
    "serviceTagRegion": "azurecloud.westeurope",
    "region": "westeurope"
}

Respuesta de la función

La función devolverá un archivo JSON con dos contenedores, uno para las direcciones añadidas, si las hay, y otro para las eliminadas:

{
    "removedAddresses":[],
    "addedAddresses":[]
}

Usando la función

Ahora tenemos una función Azure con un trigger HTTP que devuelve una cadena JSON con dos nodos con las direcciones cambiadas, o nodos vacíos si no hay cambios.

¿Cómo podemos lanzar esta función de forma recurrente? Con Power Automate, por supuesto. Podemos utilizar un scheduled cloud flow, que se ejecute una vez al día, o tantas veces como queramos.

Una vez que tengamos un trigger, llamaremos a la función. A mí me gusta crear bloques «Compose» donde «inicializo» el valor de varias «variables». No son variables, pero entiendes lo que quiero decir, ¿verdad?

En este primer bloque compose sólo añado el cuerpo que le pasaremos a la función:

Cuerpo para la función
Cuerpo para la función

Luego añadimos una acción HTTP, la configuramos con el método POST y añadimos la URL de la función, y en el cuerpo la salida del bloque compose anterior:

Llamando a la función de Azure
Llamando a la función de Azure

El siguiente paso será parsear la respuesta de la función. Siempre devolverá los contenedores, llenos o vacíos, así que puedes usarlos como payload para generar el esquema. Y por último añadir el cuerpo de salida de la función:

Parseando la respuesta de la función
Parseando la respuesta de la función

Luego crearé otros dos bloques «Compose» donde guardaré los valores de salida de parsear el JSON, uno para las direcciones añadidas y otro para las eliminadas:

Valores del parseo de JSON
Valores del parseo de JSON

Y ahora añado una condición en la que comprobaremos si hay algo dentro de las matrices nueva o eliminada. ¿Cómo podemos comprobarlo? Utilizaremos una expresión en el campo valor para comprobar si la longitud del contenedor es 0:

Comprobamos que los arrays estén vacíos
Comprobamos que los arrays estén vacíos

Y si es 0 (rama «If yes») no haremos nada, y si contiene elementos (rama «If no») enviaremos un email:

Resultados
Resultados

¡Vamos a probarlo!

Para probarla, entraré en mi Tabla de Azure y editaré la última entrada que tenga, porque ejecuté la función localmente varias veces. Cambiaré el valor de las columnas ChangeNumber y Addresses.

Cambiando valores
Cambiando valores

Y ejecutamos el flow…

Corre flow corre!
Corre flow corre!

Podemos ver que el flow ha pasado por la rama «If no» en la condición, porque los contenedores contenían cambios. Y puedo verlos en el correo electrónico que he recibido:

Email de Flow
Email de Flow

¡Y eso es todo! Como dije al principio, esto también se podría hacer únicamente usando un cloud flow de Power automate, pero preferí usar una función de Azure para hacer el trabajo sucio de comparar valores.

Puedes encontrar el código completo en el proyecto de GitHub AzureTagsIPWatcher.

¡Suscríbete!

Recibe un correo cuando se publique un nuevo post
Author

Microsoft Dynamics 365 Finance & Operations technical architect and developer. Business Applications MVP since 2020.

Write A Comment

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

ariste.info