Tag: APIs

  • 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”…

  • Firewalls and DDNS, Part 2.

    As discussed in the Firewalls and DDNS post, DuckDNS can sometime be slow. Additionally, some corporate DNS resolves do not resolve the DuckDNS domains making them inaccessible. To solve this, I decided to use Cloudflare’s name servers to handle my domains. Contrary to the spirit of free open source software, when it comes to domain registration, it is not free. You may be able to find deals on domain name registration, but they’re usually promotional and will eventually make you pay. I went with Cloudflare and their domain registration services for simplicity and a single control plane since I decided I’d be using their DNS.

    Creating a DDNS automation script

    Create a folder in your home folder names something like .ddns, like so mkdir -p ~/.ddns. Create and edit a file called variables in this .ddns folder with your favorite text editor, I prefer nano, nano ~/.ddns/variables.

    In the variables add your (you guessed it) variables you got from Cloudflare.

    SUBDOMAIN="<subdomain.fdqn.tld>"
    KEY="<key>"
    ZONE_ID="<zone_id>"

    You need to protect this file. This file contains information that will allow changing your DNS records in your Cloudflare account.

    chmod 500 ~/.ddns
    cd ~/.ddns
    chmod 400 ~/.ddns/variables
    chmod 500 ~/.ddns/ddns.sh
    #!/bin/bash
    
    # This script updates the Cloudflare's A DNS records for your domain.
    # It requires the DNS edit token key and the DNS Zone ID.
    # The script uses curl for the API calls and awk parse the responses.
    # Be sure to create the subdomain A DNS record in the Cloudflare portal with a dummy routable IP address first.
    # Create a file in the same folder where you place this script and name the file variables
    # Put these lines in to the file variables
    #SUBDOMAIN="<subdomain.fdqn.tld>"
    #KEY="<key>"
    #ZONE_ID="<zone_id>"
    # Make sure the user who will be executing this script can read the file
    
    # Function to validate IP address
    validate_ip() {
        local ip=$1
    
        # Regular expression to match valid IPv4 addresses
        local valid_ip_regex="^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$"
    
        # Regular expressions to exclude non-routable IP addresses
        local non_routable_regexes=(
            "^0\.([0-9]{1,3}\.){2}[0-9]{1,3}$"
            "^10\.([0-9]{1,3}\.){2}[0-9]{1,3}$"
            "^100\.(6[4-9]|7[0-9]|1[0-1][0-9]|12[0-7])\.([0-9]{1,3}\.)[0-9]{1,3}$"
            "^127\.([0-9]{1,3}\.){2}[0-9]{1,3}$"
            "^169\.254\.([0-9]{1,3}\.)[0-9]{1,3}$"
            "^172\.(1[6-9]|2[0-9]|3[0-1])\.([0-9]{1,3}\.)[0-9]{1,3}$"
            "^192\.0\.0\.([0-9]{1,3})$"
            "^192\.0\.2\.([0-9]{1,3})$"
            "^192\.88\.99\.([0-9]{1,3})$"
            "^192\.168\.([0-9]{1,3}\.)[0-9]{1,3}$"
            "^198\.(1[8-9])\.([0-9]{1,3}\.)[0-9]{1,3}$"
            "^198\.51\.100\.([0-9]{1,3})$"
            "^203\.0\.113\.([0-9]{1,3})$"
            "^224\.([0-9]{1,3}\.){2}[0-9]{1,3}$"
            "^(24[0-9]|25[0-5])\.([0-9]{1,3}\.){2}[0-9]{1,3}$"
        )
    
        # Check if the IP address matches the valid IP regex
        if [[ $ip =~ $valid_ip_regex ]]; then
            # Check if the IP address matches any of the non-routable IP regexes
            for regex in "${non_routable_regexes[@]}"; do
                if [[ $ip =~ $regex ]]; then
                    echo "Invalid IP address: Non-routable IP address"
                    return 1
                fi
            done
            echo "$ip is a valid IP address"
            return 0
        else
            echo "Invalid IP address: Does not match IPv4 format"
            return 1
        fi
    }
    
    # Make sure that the ddns.sh and variables files are in the same directory
    
    source ./variables
    
    # Get old IP address
    OLD_IP=$(host $SUBDOMAIN | awk '/has address/ { print $4 }')
    if validate_ip "$OLD_IP"; then
      echo "Old IP for $SUBDOMAIN is $OLD_IP"
    else
      echo "Failed getting old IP"
      exit 1
    fi
    
    # Get new IP address
    NEW_IP=$(curl -s --connect-timeout 20 https://checkip.amazonaws.com)
    if validate_ip "$NEW_IP"; then
      echo "New IP for $SUBDOMAIN is $NEW_IP"
    else
      echo "Failed getting new IP"
      exit 1
    fi
    
    # Compare the IP addresses
    if [ "$NEW_IP" == "$OLD_IP" ]; then
        echo "IP is already correct"
    else
    
    # Get Subdomain DNS Record from Cloudflare
    DNS_RECORD_ID=$(curl -s --connect-timeout 20 --request GET \
        --url https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records \
        --header 'Content-Type: application/json' \
        --header "Authorization: Bearer $KEY" \
            | awk -v RS='{"' -F: '/^id/ && /'"$SUBDOMAIN"'/{print $2}' | tr -d '"' | sed 's/,.*//')
    echo "Got Record ID"
    
    # Update IP Address
    curl -s -o /dev/null --connect-timeout 20 --request PUT \
      --url https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$DNS_RECORD_ID \
      --header 'Content-Type: application/json' \
      --header "Authorization: Bearer $KEY" \
      --data "{\"type\":\"A\",\"name\":\"$SUBDOMAIN\",\"content\":\"$NEW_IP\",\"ttl\":1,\"proxied\":false}" 
    echo "IP address for $SUBDOMAIN is updated to $NEW_IP"
    
    fi
    
    exit 0

    This script queries DNS record for your domain and compares the IP address with the one obtained from https://checkip.amazonaws.com. If they match, it does nothing. If they don’t match, the script updates the Cloudflare’s DNS record. The script uses curl for the API calls and awk to parse the responses.

    Now create a cron job (with crontab -e command) to run this script every <X> minutes (adjust to taste). Change <user> to the username where you stored the script.

    */<X> * * * * /home/<user>/.ddns/ddns.sh >/dev/null 2>&1

    Obtaining the DNS token

    To get the Zone ID and the API key, go to your Cloudflare portal, select the domain you need. In the overview scroll down and select “Click to copy” underneath the Zone ID field and paste it into the variables file for the ZONE_ID variable. Then click “Get your API token” to create your API token.

    Now, click on the blue “Create Token” button.

    Next, click the “Use template” button next to the “Edit zone DNS”.

    There edit the Token Name. It’ll be important to keep track of all your tokens if you create many. In the “Zone Resources” select “Specific zone” and select the domain you’re creating the token for. And then click “Continue to summary button”.

    Not much to do here but to click the “Create Token” button.

    This is where you finally get your token. Make sure you copy it into the variables file for the KEY variable as you won’t be able to view it again and will need to recreate it.

    This is it. This is the method I use to track my IP addresses for the devices with the dynamic public IPs. I then query the DNS record to get the IP and update the allow lists on my firewall (as described in Firewalls and DDNS). Of course, there’s a delay in the DNS record propagation, plus the frequency at which you run the script, so it may not be fully suitable for the mission critical applications. But if you’re running a mission critical application, you’re probably not relying on dynamic IPs and use a static IP anyways…