Tag: Python

  • Meraki Dashboard APIs, Part 2.

    Using Meraki Dashboard APIs for Local DNS

    Problem

    The goal in IPsec between Meraki and pfSense was to enable access from a LAN behind the Meraki Z4 to access an internal https server in a LAN behind the pfSense. IPsec allows that. But. What does an https server use to provide the “s” to “http”? That’s right, a TLS certificate. If your client validates the certificates, when you access an https resource that uses a self signed certificate or a certificate issued by an authority not in the client’s chain of trust, you’ll get a warning. That warning is annoying and, depending on the client, can even prevent further access to the server.

    Solution

    So we have 2 solutions:

    • Add the signing certificate authority to the client’s chain of trust. This solves the warning. But not all clients support that (I am looking at you, IoT). And you’d need to do that for every device that will be accessing the server.
    • Use a certificate signed by an authority that the devices already trust. Perfect! I use Let’s Encrypt and it is trusted by most devices.

    Let’s Encrypt validates domain ownership to issue a certificate. It needs to be a publicly accessible domain (so no .local or anything like that). And, lucky, for us, we do have a public domain set up for our DynDNS access. Great! (Maybe some time in the future I’ll write about Let’s Encrypt, but it’s really not complicated. We’ll see.)

    Problem. Part 2

    TLS encryption works by the client submitting a Server Name Indication (SNI) request to the server. The SNI is the domain name that the client is trying to access. It is important because the https server provides a response and the certificate based on the SNI. Even though the underlying communication happens on the IP layer, the client must access the https server by the domain name. If the client accesses the https sever by the server’s IP address, the client will get a warning and we’ll be back where we started.

    Solution. Part 2

    So the solution is obvious. Create an A (or AAAA, if you’re fancy) DNS record for your domain that will point to the https server. The DNS record can point to a private IP address, no problem. But how do you do it with a Meraki Security Appliance (or a Teleworker Gateway)? We have a few options:

    • Run our own DNS server, like Pi-hole. I love Pi-hole and run it on the pfSense LAN. But I’d need an additional device on the Z4 LAN just to serve a single DNS record. I’m not a fan of this option.
    • Create a DNS record in a public DNS resolver. Since I do have a domain with Cloudflare, I can easily create a DNS record with them. This way a Cloudflare’s server will be queried to resolve the domain. I’m not a huge fan of this option either. This way we’re advertising our internal server’s IP address to the entire internet. Yes, the IP is internal and is not globally routable, but still…

    Luckily, Meraki provides just the solution for this problem in the form of Local DNS records. What it does is you can create a DNS record and an MX (or a Z) will respond to the query with the IP address configured. As of September 2025, this function is not available through the Dashboard and must be configured through the Dashboard APIs. There are additional requirements for this to work1:

    • MX (Z) running firmware 19.1+
    • DNS namservers setting under DHCP Settings must be configured as “Proxy to Upstream DNS”
    • The Network must not be a part of a Template.

    So, let’s get to it.

    Local DNS with Meraki Dashboard APIs script

    Now, we’re finally got to the core of the post. Let’s set up the script and the functions for us to execute the API calls.

    On a high level, to set up Local DNS records we need:

    1. Create an organization’s Local DNS Profile
    2. Create a Local DNS record in the profile we created
    3. Assign the profile to a network

    We’ll expand the code we started in Part 1. We’ll need a name for a profile we’ll be creating. For simplicity, I use the same name as the network we’ll be assigning the profile to. We’ll need a fully qualified domain name and the IP address that we’ll be using for the record.

    import requests
    import json
    
    API_KEY = "<API_KEY_HERE>"
    Organization = "<ORGANIZAION_NAME_HERE>"
    Network = "<NETWORK_NAME_HERE>"
    
    URL = "https://api.meraki.com/api/v1"
    
    Profile = Network
    Domain = "<fullyqualified.domain[.]tld>"
    IP_addr = "<192.168.12.34>"

    Now, let’s create our functions.

    First, we need to get our Organization ID. We could get it from the Dashboard, but it’s easier to just make an API call. The function needs the Organization’s name. It’ll then query the API using the API key and will return the matching Organization ID, or nothing (None) if an organization with such name is not found. So, pay attention to the organization’s name.

    #Fuction to get Organization ID
    def getOrganizations(target_name):
        payload = None
        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Accept": "application/json"
        }
        response = requests.request('GET', f"{URL}/organizations/", headers=headers, data = payload)
        json_str = response.content.decode('utf-8')
        data = json.loads(json_str)
        for organization in data:
            if organization.get("name") == target_name:
                return organization.get("id")
        return None

    Next, we need a function to get the Network ID. Similar to the function above:

    #Function to get Network ID 
    def getOrganizationNetworks(target_name):
        payload = None
        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Accept": "application/json"
        }
        response = requests.request('GET', f"{URL}/organizations/{Org_ID}/networks", headers=headers, data = payload)
        json_str = response.content.decode('utf-8')
        data = json.loads(json_str)
        for network in data:
            if network.get("name") == target_name:
                return network.get("id")
        return None 

    Next, we create an organization Local DNS profile. It needs the Organization ID and the name for the profile (we use the same name as the network).

    #Function to Create an Organization Local DNS Profile
    def createOrganizationApplianceDnsLocalProfile(Org_ID, prof_name):
        payload = f'''{{ "name": "{prof_name}" }}'''
        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }
        response = requests.request('POST', f"{URL}/organizations/{Org_ID}/appliance/dns/local/profiles", headers=headers, data = payload)
        json_str = response.content.decode('utf-8')
        data = json.loads(json_str)
        return data

    After that, we need a function to get the Profile ID for the profile we just created. Technically, you can get the ID at the time of the profile creation, but you’d need this function if you ever add records to an already existing profile.

    #Function to get Profile ID for a particular Profile
    def getOrganizationApplianceDnsLocalProfilesSpecific(ProfName):
        payload = None
        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Accept": "application/json"
        }
        response = requests.request('GET', f"{URL}/organizations/{Org_ID}/appliance/dns/local/profiles", headers=headers, data = payload)
        json_str = response.content.decode('utf-8')
        data = json.loads(json_str)
        for item in data.get('items', []):
            if item.get('name') == ProfName:
                return item.get('profileId')
        return None

    Now, we need a function to create the Local DNS record. It needs the Organization ID, Profile ID, FQDN, and the IP address.

    #Function to Create a DNS Local Record
    def createOrganizationApplianceDnsLocalRecord(Org_ID,ProfID,Dom,IP):
        payload = f'''{{
        "hostname": "{Dom}",
        "address": "{IP}",
        "profile": {{ "id": "{ProfID}" }}
        }}'''
        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }
        response = requests.request('POST', f"{URL}/organizations/{Org_ID}/appliance/dns/local/records", headers=headers, data = payload)
        json_str = response.content.decode('utf-8')
        data = json.loads(json_str)
        return data

    Finally, we need a function to assign the profile to the network. This what enables the security appliance (or the teleworker gateway) to actually resolve the domain name into the IP address for its clients. It requires the Organization ID, Network ID, and the Profile ID.

    #Fuction to assign the DNS Records Profile to a Network
    def bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate(Org_ID,Net,Prof):
        payload = f'''{{
        "items": [
            {{
                "network": {{ "id": "{Net}" }},
                "profile": {{ "id": "{Prof}" }}
            }}
        ]
        }}'''
        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }
        response = requests.request('POST', f"{URL}/organizations/{Org_ID}/appliance/dns/local/profiles/assignments/bulkCreate", headers=headers, data = payload)
        json_str = response.content.decode('utf-8')
        data = json.loads(json_str)
        return data

    After defining the functions, we need to call them in the following order to create the Local DNS record in our network.

    Org_ID = getOrganizations(Organization)
    Netw = getOrganizationNetworks(Network)
    createOrganizationApplianceDnsLocalProfile(Org_ID,Profile)
    ProfNumb = getOrganizationApplianceDnsLocalProfilesSpecific(Profile)
    createOrganizationApplianceDnsLocalRecord(Org_ID,ProfNumb,Domain,IP_addr)
    bulkOrganizationApplianceDnsLocalProfilesAssignmentsCreate(Org_ID,Netw,ProfNumb)

    This should be it. To validate that the record is created you’ll need one more function.

    #Function to get Local DNS Records
    def getOrganizationApplianceDnsLocalRecords(Org_ID):
        payload = None
        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Accept": "application/json"
        }
        response = requests.request('GET', f"{URL}/organizations/{Org_ID}/appliance/dns/local/records", headers=headers, data = payload)
        json_str = response.content.decode('utf-8')
        data = json.loads(json_str)
        return data

    After all of this, you should have this beautiful banner at the top of your Appliance Status page indicating that this appliance has the local DNS record. And the devices on my Z4’s LAN successfully resolve the domain name into the IP address across the IPsec tunnel. And no annoying certificate warnings!

    Providing additional functionality through APIs only instead of the Dashboard is, in my opinion, a bit antithetical to the Meraki’s idea of simplicity. But, perhaps, the Local DNS is a too niche of a requirement. Anyways, beggars can’t be choosers and I pick having the option, even through the APIs, over not having the option at all. And APIs are totally Enterprise. So, I am not complaining…

    1. https://documentation.meraki.com/MX/Local_DNS_Service_on_MX ↩︎
  • Meraki Dashboard APIs.

    Expanding Meraki capabilities with the help of APIs.

    Some of you noticed a banner on top of the Appliance Status page in the IPsec between Meraki and pfSense post. The banner read “Local DNS has been enabled via API on this network. For more info see documentation“. This is a post about Meraki Dashboard APIs and how they expand what the platform can do.

    Meraki Dashboard APIs

    Meraki utilizes RESTful APIs to expand the device capabilities and allow for automation of networking tasks. For example, you can create provisioning scripts, or scripts to read the logs. This is done, in part, to get around some of the limitation of the GUI Meraki Dashboard. Meraki has a pretty good documentation in general, and for APIs in particular. You can find it here.

    However, while the documentation describes the operation of each API call, request and response schemas, and even provides code snippets, it does not go in detail over the order of operations to accomplish tasks. For example, in order to enable VLANs in a security appliance, one needs to go to Security Appliance & SD-WAN -> Addressing & VLANs. There click on “VLANs” and then create a VLAN. What seems like one operation in the Dashboard is actually accomplished by two API calls. The first one is to enable VLANs with the “updateNetworkApplianceVlansSettings” operation ID and then create a new VLAN. It makes sense but can be confusing when doing it the first time.

    For most API calls you need either an Organization ID or a Network ID. The Organization ID can be found at the bottom of every page of the Meraki Dashboard. Finding the Network ID is not trivial and the easiest way for me is to run an API call with “getOrganizationNetworks” operation ID.

    Setting up API calls

    But before you can even run an API call, you’ll need the API key. To obtain the API key, in the Meraki Dashboard, go to “My Profile” (top right hand corner), scroll about half way down and click “Generate new API key” under the “API access” section. Save this key in a safe place. Protect this key as it allows for unrestricted (up to the permission level of the account for which the API key has been created) modification access to your organization. Do NOT share this key. It is bad!

    There are many methods of making API calls. You can get as low level as cURL, use Python, use Meraki Python Library, use Postman, use Ansible or Terraform. I use Python with requests and json modules. “Why not use Meraki Python Library?”, you ask. That’s a fair question. I, to a certain degree, subscribe to the idea of “living off the land“. If I don’t have to install an additional package, I prefer not to. Also, I prefer to understand what’s going on and feel the Meraki Python Library abstracts to much away. There’s nothing wrong with using Meraki Python Library, I just prefer not to.

    “So, why not cURL, then?”. Again, fair point. I use cURL for API calls in my DynDNS script, and use awk to parse the response. However, it gets messy pretty quick and we need to have a more robust solution. Thus we’ll be using json module for parsing the API responses and requests to make the API calls. So, let’s start building our API Python script.

    Below is the beginning of the script. We import requests and json modules. We add our API key. Again, make sure you protect it. Do NOT share your script with the API key in it. It is bad! You can store it in a different file and then read it from there or have it entered interactively. We input our organization name. This is done if you manage multiple organizations and need to select which one you’ll be working on. And we specify the name of the network we’ll be operating on. We’ll use this name to pull the Network ID that we’ll use for most of the calls. And we enter the base URL for the API calls. This base will be appended to to performa various functions.

    import requests
    import json
    
    API_KEY = "<API_KEY_HERE>"
    Organization = "<ORGANIZAION_NAME_HERE>"
    Network = "<NETWORK_NAME_HERE>"
    
    URL = "https://api.meraki.com/api/v1"

    I think I’ll stop here for now. Next time we’ll explore the Local DNS feature. Why I use it and how to set it up. APIs are very enterprise. So we’re getting even closer to living “Enterprise. At home”…