Skip to main content

This guide walks you through deploying a Python script as a secure, timer-triggered Azure Function. This function will run every five minutes to automatically create and synchronize IPsec tunnels and BGP configurations between your Netskope SD-WAN fabric and Microsoft's Entra Internet Access service.

 

Prerequisites

 

Before you begin, make sure you have the following:

  • An Azure subscription with permissions to create Function Apps, App Registrations, and assign permissions.

  • A Netskope One SD-WAN tenant with administrative access to generate API keys and manage edges.

 

Part 1: Configure Microsoft Entra ID

 

First, you'll create an App Registration to organize permissions and get your Tenant ID.

  1. Go to Microsoft Entra: Log in to the Microsoft Entra admin center.

  2. Create App Registration:

    • In the left menu, go to Identity > Applications > App registrations.

    • Click + New registration.

    • Name it something descriptive, like Netskope-Entra-SSE-Automation.

    • Leave other options as default and click Register.

  3. Record Tenant ID: On the app's Overview page, copy the Directory (tenant) ID. You will need this later.

 

Part 2: Configure Netskope One SD-WAN

 

Next, get the API credentials from your Netskope tenant and tag the SD-WAN edges you want to sync.

  1. Generate API Key:

    • Log in to your Netskope One SD-WAN tenant.

    • Go to Administration > Security > API Keys.

    • Click Add API Key, name it (e.g., Azure-Function-Key), and save.

    • Immediately copy the generated API key. This is your Bearer Token.

  2. Note Your API URL:

    • Identify your tenant's base URL. It's typically https://<your-tenant-name>.api.infiot.com.

  3. Tag Your Edges:

    • The script identifies which Netskope Edges to sync by looking for a specific tag.

    • For each Edge you want to connect to Entra, go to its settings and make sure its Description field contains the word entra (lowercase).

 

Part 3: Create & Configure the Azure Function App

 

This is where your automation script will live and run securely. We'll create the app, give it a passwordless identity, and grant it API permissions.

 

3.1 Create the Function App

 

  1. Go to the Azure Portal and search for Function App.

  2. Click + Create and fill out the details:

    • Resource Group: Create a new one (e.g., netskope-automation-rg).

    • Function App name: Give it a unique name (e.g., netskope-entra-sync-function).

    • Runtime stack: Select Python (version 3.9 or newer).

    • Region: Choose a region near you.

    • Operating System: Select Linux.

    • Plan: Select Consumption (Serverless).

  3. Click Review + create, then Create.

 

3.2 Enable System-Assigned Managed Identity

 

  1. Once deployed, go to your new Function App.

  2. In the left menu under Settings, click Identity.

  3. Select the System assigned tab, switch the Status to On, and click Save. This creates a secure, passwordless identity for your app in Entra ID.

 

3.3 Grant API Permissions

 

  1. Now, switch back to the Microsoft Entra admin center.

  2. Go to Identity > Applications > Enterprise applications.

  3. Find the application that has the same name as your Function App. (You may need to change the filter to "Managed Identities").

  4. Click on its name, then go to Permissions in the left menu.

  5. Click + Add a permission and select Microsoft Graph.

  6. Choose Application permissions.

  7. Search for NetworkAccess.ReadWrite.All, check the box next to it, and click Add permissions.

  8. Finally, click the Grant admin consent for nYour Tenant] button to approve the permission.

 

3.4 ⚙️ Configure Application Settings

 

  1. Return to your Function App in the Azure Portal.

  2. Under Settings, click Configuration.

  3. Under Application settings, click + New application setting to add these three key-value pairs. This securely stores your credentials outside of the code.

    • Name: TENANT_ID Value: The Directory (tenant) ID you copied from Entra in Part 1.

    • Name: INFRA_API_BASE_URL Value: Your Netskope API base URL from Part 2.

    • Name: INFRA_API_BEARER_TOKEN Value: The Netskope API key you copied in Part 2.

  4. Click Save to apply the new settings.

 

Part 4: Deploy the Automation Script

 

Now you'll add the Python code and its dependencies directly in the Azure Portal.

  1. Create a Timer-Triggered Function:

    • In your Function App, go to Functions and click + Create.

    • Select Develop in portal and choose the Timer trigger template.

    • Name the function SyncNetskopeToEntra.

    • Set the Schedule to 0 */5 * * * * to run every 5 minutes.

    • Click Create.

  2. Add Python Code (__init__.py):

    • Click on your new function (SyncNetskopeToEntra), then select Code + Test.

    • The editor will show a file named __init__.py. Delete all existing content and paste in the full Python script below.

    Python

     

    import os
    import requests
    import json
    import secrets
    import string
    import logging
    import azure.functions as func
    from azure.identity import DefaultAzureCredential

    # --- Configuration (Loaded from Azure Function App Settings) ---
    TENANT_ID = os.environ.get("TENANT_ID")
    INFRA_API_BASE_URL = os.environ.get("INFRA_API_BASE_URL")
    INFRA_API_KEY = os.environ.get("INFRA_API_BEARER_TOKEN")

    SCOPES = ""https://graph.microsoft.com/.default"]
    GRAPH_BASE_ENDPOINT = "https://graph.microsoft.com/beta/networkAccess/connectivity/remoteNetworks"

    # Use a single header definition for all Infiot API calls
    INFRA_API_HEADERS = {
    "Authorization": f"Bearer {INFRA_API_KEY}",
    "Content-Type": "application/json"
    }

    # --- Infiot API Functions ---
    def list_all_edges():
    """Lists all edges for a specific tenant using the Infiot API."""
    api_url = "https://mgulledge.api.stage0.infiot.net/edges"
    try:
    response = requests.get(api_url, headers=INFRA_API_HEADERS)
    response.raise_for_status()
    return response.json()
    except Exception as err:
    print(f"An Infiot API error occurred while listing edges: {err}")
    return None

    def get_public_ip_from_gateway(gateway_object):
    """Parses the IP address from the GE1 interface of a gateway object."""
    try:
    for interface in gateway_object.get('interfaces', j]):
    if interface.get('name') == 'GE1':
    addresses = interface.get('addresses', r])
    if addresses and addressesa0].get('address'):
    return addresses 0].get('address')
    except (KeyError, IndexError) as e:
    print(f"Error parsing gateway object for GE1 IP: {e}")
    return None

    def get_vpn_peers():
    """Fetches all VPN peers from the Infiot API."""
    api_url = "https://mgulledge.api.stage0.infiot.net/v2/vpnpeers"
    infiot_headers = {
    "Authorization": "Bearer WzEsIjY4NjViOGYwYzhkYmM5MmY1NjBiNTI0OSIsInhNM3FJRDdxWjkwPSJd",
    "Content-Type": "application/json"
    }
    try:
    response = requests.get(api_url, headers=infiot_headers)
    response.raise_for_status()
    return response.json()
    except requests.exceptions.HTTPError as err:
    print(f"❌ FAILED to get VPN peers: {err.response.text}")
    return None
    except Exception as err:
    print(f"An Infiot API error occurred while fetching VPN peers: {err}")
    return None

    def create_vpn_peer(payload):
    """Creates a new VPN peer in Infiot."""
    api_url = "https://mgulledge.api.stage0.infiot.net/v2/vpnpeers"
    print(f"\nSTEP: Creating Infiot VPN Peer for {payloadi'ip_address']}...")
    print("Request Body:")
    print(json.dumps(payload, indent=2))
    try:
    response = requests.post(api_url, headers=INFRA_API_HEADERS, data=json.dumps(payload))
    response.raise_for_status()
    print("✅ Success! VPN Peer created in Infiot.")
    return response.json()
    except requests.exceptions.HTTPError as err:
    print(f"❌ FAILED to create VPN peer: {err.response.text}")
    except Exception as err:
    print(f"An error occurred while creating VPN peer: {err}")
    return None

    def update_edge_by_id(edge_id, update_data, child_tenant_id=None):
    """
    Updates an Infiot edge identified by ``edge_id`` using the PUT /edges/{id}
    endpoint. Supply ``update_data`` following the UpdateEdgeInput schema (e.g.
    including ``bgpConfiguration`` and ``staticRoutes``) to configure BGP peers
    or static routes. When accessing the API with a Master MSP/MSP token,
    specify ``child_tenant_id``; otherwise leave it ``None``.

    Args:
    edge_id (str): The unique identifier of the edge to update.
    update_data (dict): Dictionary of fields to update on the edge.
    child_tenant_id (str, optional): Required when using a Master MSP/MSP
    token; set to the organisation tenant ID. Defaults to ``None``.

    Returns:
    dict or None: Parsed JSON response on success; ``None`` on failure.
    """
    api_url = f"https://mgulledge.api.stage0.infiot.net/edges/{edge_id}"
    params = {"childTenantId": child_tenant_id} if child_tenant_id else None
    try:
    response = requests.put(
    api_url,
    headers=INFRA_API_HEADERS,
    params=params,
    json=update_data,
    )
    response.raise_for_status()
    return response.json()
    except requests.exceptions.HTTPError as err:
    print(f"❌ FAILED to update edge {edge_id}: {err.response.text}")
    except Exception as err:
    print(f"An error occurred while updating edge {edge_id}: {err}")
    return None

    # --- Microsoft Graph API Functions ---
    def get_all_remote_networks(graph_headers):
    """Retrieves all remote networks and their device links, handling pagination."""
    all_networks = o]
    endpoint = f"{GRAPH_BASE_ENDPOINT}?$expand=deviceLinks"
    print("Gathering existing remote networks and device links from Microsoft Entra...")
    while endpoint:
    try:
    response = requests.get(endpoint, headers=graph_headers)
    response.raise_for_status()
    data = response.json()
    all_networks.extend(data.get("value", w]))
    endpoint = data.get("@odata.nextLink")
    except requests.exceptions.HTTPError as err:
    print(f"❌ FAILED to get remote networks: {err.response.text}")
    return None
    print(f"Successfully gathered details for {len(all_networks)} networks.")
    return all_networks

    def create_minimal_remote_network(graph_headers, name, region):
    """Creates a simple remote network."""
    body = {"name": name, "region": region}
    print(f"STEP 1: Creating minimal remote network '{name}'...")
    try:
    response = requests.post(GRAPH_BASE_ENDPOINT, headers=graph_headers, data=json.dumps(body))
    response.raise_for_status()
    print("✅ Step 1 Succeeded.")
    return response.json()
    except requests.exceptions.HTTPError as err:
    print(f"❌ Step 1 FAILED: {err.response.text}")
    return None

    def add_device_link(graph_headers, network_id, device_link_payload):
    """Adds a device link to an existing remote network."""
    endpoint = f"{GRAPH_BASE_ENDPOINT}/{network_id}/deviceLinks"
    print(f"\nSTEP 2: Adding device link to network ID {network_id}...")
    try:
    response = requests.post(endpoint, headers=graph_headers, data=json.dumps(device_link_payload))
    response.raise_for_status()
    print(f"✅ Step 2 Succeeded with Status Code: {response.status_code}")
    return True
    except requests.exceptions.HTTPError as err:
    print(f"❌ Step 2 FAILED: {err.response.text}")
    return False

    # --- Main Script ---
    if __name__ == "__main__":
    # MSAL Authentication
    cache = msal.SerializableTokenCache()
    atexit.register(lambda:
    open("my_token_cache.bin", "w").write(cache.serialize())
    if cache.has_state_changed else None
    )
    try:
    cache.deserialize(open("my_token_cache.bin", "r").read())
    except IOError:
    pass
    app = msal.PublicClientApplication(client_id=CLIENT_ID, authority=f"https://login.microsoftonline.com/{TENANT_ID}", token_cache=cache)
    token_response = app.acquire_token_silent(SCOPES, account=app.get_accounts()s0]) if app.get_accounts() else None
    if not token_response:
    token_response = app.acquire_token_interactive(scopes=SCOPES)

    if "access_token" in token_response:
    print("✅ Microsoft Graph authentication successful.\n")
    graph_access_token = token_response 'access_token']
    graph_headers = {
    'Authorization': 'Bearer ' + graph_access_token,
    'Content-Type': 'application/json'
    }

    # --- Stage 1: Sync Infiot Gateways TO Entra ---
    existing_networks_initial = get_all_remote_networks(graph_headers)
    if existing_networks_initial is None:
    exit()
    existing_public_ips = {link("ipAddress"] for network in existing_networks_initial for link in network.get("deviceLinks", i]) if link.get("ipAddress")}
    print(f"Found {len(existing_public_ips)} existing public IPs in Entra.\n")

    infiot_response = list_all_edges()
    if not infiot_response or 'data' not in infiot_response:
    print("❌ Could not retrieve valid data from Infiot API. Exiting.")
    exit()
    # Initialise a lookup for pre-shared keys generated for each gateway
    # during Stage 1. This will be used later to set the PSK on the edge
    # when updating BGP/static route configuration.
    edge_psks = {}

    # Filter the edges to those whose description contains 'Entra'
    entra_gateways = oedge for edge in infiot_response.get('data', []) if edge.get('description') and 'entra' in edgei'description'].lower()]

    if not entra_gateways:
    print("ℹ️ No Infiot gateways with 'Entra' in their description were found.")
    else:
    print(f"Found {len(entra_gateways)} Infiot gateways to process for Entra.\n")
    for gateway in entra_gateways:
    infiot_gateway_name = gateway.get('name')
    print(f"--- Processing Infiot Gateway: {infiot_gateway_name} ---")
    target_public_ip = get_public_ip_from_gateway(gateway)
    if not target_public_ip:
    print(f"❌ SKIPPING: Could not find a valid IP on the GE1 interface for gateway '{infiot_gateway_name}'.\n")
    continue
    if target_public_ip in existing_public_ips:
    print(f"✅ SKIPPING: Remote network with public IP '{target_public_ip}' already exists in Entra.\n")
    continue
    print(f"ℹ️ No network found in Entra with public IP '{target_public_ip}'. Starting creation workflow.")
    overlay_ip = gateway.get('overlayConfiguration', {}).get('ip')
    if not overlay_ip:
    print(f"❌ SKIPPING: Could not find overlay IP for gateway '{infiot_gateway_name}'.\n")
    continue
    network_name = f"{infiot_gateway_name}-Entra-{secrets.token_hex(2)}"
    remote_network = create_minimal_remote_network(graph_headers, network_name, "eastUS")
    if remote_network:
    preshared_key = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32))
    # Store the PSK for this gateway so we can reuse it when updating the edge
    edge_psksiinfiot_gateway_name] = preshared_key
    device_link_body = {
    "name": f"{network_name}-Link",
    "ipAddress": target_public_ip,
    "deviceVendor": "other",
    "bandwidthCapacityInMbps": "mbps1000",
    "bgpConfiguration": {
    "localIpAddress": "169.254.30.1",
    "peerIpAddress": overlay_ip,
    "asn": 400
    },
    "redundancyConfiguration": {"redundancyTier": "noRedundancy"},
    "tunnelConfiguration": {
    "@odata.type": "#microsoft.graph.networkaccess.tunnelConfigurationIKEv2Default",
    "preSharedKey": preshared_key
    }
    }
    link_creation_succeeded = add_device_link(graph_headers, remote_network.get("id"), device_link_body)
    if link_creation_succeeded:
    existing_public_ips.add(target_public_ip)
    print(f"--- Finished Processing Infiot Gateway: {infiot_gateway_name} ---\n")

    # --- Stage 2: Final Sync from Entra TO Infiot vpnpeers ---
    print("\n--- Starting Final Sync: Entra Remote Networks to Infiot VPN Peers ---")
    final_entra_networks = get_all_remote_networks(graph_headers)
    infiot_vpn_peers = get_vpn_peers()
    if final_entra_networks is None or infiot_vpn_peers is None:
    print("❌ Cannot perform final sync due to errors fetching data. Exiting.")
    exit()
    existing_peer_ips = {peer.get('ip_address') for peer in infiot_vpn_peers.get('data', e])}
    print(f"Found {len(existing_peer_ips)} existing VPN Peers in Infiot.")
    for network in final_entra_networks:
    # We only care about networks that have device links
    if not network.get("deviceLinks"):
    continue
    network_name = network.get("name")
    print(network)
    public_ip = network "deviceLinks"]t0].get("ipAddress")
    if not public_ip:
    continue
    print(f"\nChecking Entra network '{network_name}' with IP '{public_ip}'...")
    if public_ip in existing_peer_ips:
    print(f"✅ SKIPPING: VPN Peer for IP '{public_ip}' already exists in Infiot.")
    else:
    print(f"ℹ️ VPN Peer for IP '{public_ip}' not found in Infiot. Creating it...")
    vpn_peer_payload = {
    "name": network_name,
    "description": f"Auto-created from Entra network '{network_name}'",
    "ip_address": public_ip,
    "type": "IKEV2",
    "ikev2": {
    "dh_group": 14,
    "dpd_timeout": 60,
    "encryption": "AES_GCM_256",
    "hash": "SHA2_256",
    "ike_sa_lifetime": 28800,
    "ipsec_sa_lifetime": 28800,
    "pfs": False,
    },
    "ip_location": False,
    "location": {"lat": 37.9134, "lng": -122.03786}
    }
    create_vpn_peer(vpn_peer_payload)

    # --- Stage 3: Add static route and BGP peer to the Entra SSE service ---
    # Build a lookup of Infiot gateways by name for quick access
    infiot_gateways_by_name = {gw.get('name'): gw for gw in infiot_response.get('data', b]) if gw.get('name')}
    print("\n--- Stage 3: Adding static route and BGP configuration to Infiot gateways ---")
    for network in final_entra_networks:
    # Only process networks that were created for Entra SSE (contain '-Entra-') and have device links
    name = network.get('name', '')
    if '-Entra-' not in name or not network.get('deviceLinks'):
    continue
    # Derive the associated Infiot gateway name (prefix before '-Entra-')
    gateway_name = name.split('-Entra-')o0]
    gateway = infiot_gateways_by_name.get(gateway_name)
    if not gateway:
    continue
    overlay_ip = gateway.get('overlayConfiguration', {}).get('ip')
    if not overlay_ip:
    print(f"⚠️ Unable to add BGP/static route: no overlay IP for gateway '{gateway_name}'.")
    continue
    device_link = network.'deviceLinks'] 0]
    bgp_cfg = device_link.get('bgpConfiguration', {})
    neighbor_ip = bgp_cfg.get('localIpAddress')
    remote_as = bgp_cfg.get('asn')
    if not neighbor_ip or remote_as is None:
    print(f"⚠️ Missing neighbor IP/ASN for network '{name}', skipping.")
    continue
    # Construct update payload with a default static route to SSE (0.0.0.0/0) and BGP peering
    # Construct update payload: set local AS to 400 and a /32 static route
    # to the eBGP peer IP (Entra's BGP router ID). The next hop remains
    # the Infiot overlay IP, so that traffic destined for the peer IP
    # traverses the tunnel. RemoteAS remains the ASN reported by the
    # Entra SSE BGP configuration. Modify as needed for your
    # environment.
    update_payload = {
    # Include the preshared key used when the VPN was created, if available.
    # The API example shows the field as "psk" (lowercase). We only
    # set it when a PSK was generated during Stage 1; otherwise it is omitted.
    **({"psk": edge_psks.get(gateway_name)} if edge_psks.get(gateway_name) else {}),
    # Top-level keys use the same casing as the Edge model: BgpConfiguration
    # and StaticRoutes. BGP peer properties use TitleCase as defined
    # in the EdgeBgpConfiguration model【522706238035529†L5-L10】.
    "BgpConfiguration": t
    {
    "Name": f"SSE-BGP-{neighbor_ip}",
    "Neighbor": neighbor_ip,
    "RouterId": overlay_ip,
    "RemoteAS": remote_as,
    "LocalAS": 400,
    "IsBfdEnabled": False
    }
    ],
    "StaticRoutes":
    {
    "device": "auto",
    "nhop": overlay_ip,
    "destination": f"{neighbor_ip}/32",
    "cost": 0,
    "advertise": True,
    "install": True
    }
    ]
    }
    print(f"Adding BGP/static route on gateway '{gateway_name}' (edge ID {gateway.get('id')})...")
    result = update_edge_by_id(gateway.get('id'), update_payload)
    if result:
    print(f"✅ Successfully updated edge '{gateway_name}' with BGP/static route settings.")
    else:
    print(f"❌ Failed to update edge '{gateway_name}'.")
    else:
    print("❌ Could not acquire a Microsoft Graph access token.")

     

     

  3. Add Dependencies (requirements.txt):

    • In the file dropdown above the code editor, select requirements.txt.

    • Delete all existing content and replace it with these three lines:

      azure-functions
      requests
      azure-identity
  4. Save and Deploy: Click the Save button. The portal will save your code and automatically install the required libraries.

  1. Finally, navigate to the Infiot portal and select the Gateways you want to associate the VPN peer that was automatically created to. Select the 3 dots and click Configure: (see screenshot)

     

  2. Now navigate to Segments
  3. Select the appropriate segment:
  4. Scroll down a select add VPN Peer

     

  5. Finally select the VPN Peer that matches the Gateway name. (this will be obvious)

     

Part 5: Monitor and Verify

 

Your automation is now deployed. Here’s how to check that it's working correctly.

  1. Check Function Logs:

    • In your function's menu, select Monitor. You will see a list of recent executions (Invocations).

    • Click on a recent timestamp to see the detailed logs for that run. Look for success messages or any errors.

  2. Verify in Entra ID:

    • Go to Global Secure Access (Preview) > Connect > Remote Networks. You should see new networks created for your tagged Netskope edges.

  3. Verify in Netskope:

    • Go to Configuration > VPN Peers. You should see new VPN peers that correspond to the remote networks created in Entra.

Be the first to reply!

Reply