Introduction

I was recently in a Twitter conversation with a few network engineers, in that feed was great dialogue on how often folks upgrade their network environments. Whether its when a massive vulnerability hits or just keeping up with vendor recommended releases. I mentioned that the Cisco Support APIs are an incredible resource. These APIs allow users with the appropriate access to get information about their devices. This could be recommended software releases, EoX dates, last day of support, vulnerabilities, and others.

I wanted to see if there was a way to incorporate a few of these APIs together to create meaningful documentation for the operator in the field. This documentation could then be used to plan out device upgrades, replacements, or just a way to track software versions of the entire device fleet. I hope you find something useful or maybe take bits and pieces so it may help you in your environment.

Cisco Support APIs

As mentioned earlier, these APIs really are an incredible resource. If you are a Cisco Smart Net Total Care customer or a Cisco Partner Support Services partner, you most likely have access to these APIs. If you would like to follow along you will have to register an application to get started. It sounds more daunting than it really is, check out this guide to get that process started.

Handling Support API Authentication

When interacting with APIs there are usually a few options to choose from. You may use a username/password combination, token, or a combination of client IDs and secrets. I chose the latter. The default timer for tokens used for the support APIs is good for about one hour. This will require the user to reauthenticate every hour. I tried to keep this simple by having a local credentials.yaml file in the repo, which is then added to the .gitignore file.

credentials.yaml example

support_client_id: SomeClientId
support_client_secret: SomeClientSecret

Once you have the client ID and client secret from the app that is registered. You can execute the auth.py script in the repo. Repository is linked at the bottom of this post!

auth.py

import requests
import yaml


URL = "https://cloudsso.cisco.com/as/token.oauth2"

with open("./credentials.yaml", encoding="utf-8") as file:
    myvars = yaml.safe_load(file)

payload = f"grant_type=client_credentials&client_id={myvars['support_client_id']}&client_secret={myvars['support_client_secret']}"

headers = {
    "Content-Type": "application/x-www-form-urlencoded",
}

response = requests.request("POST", URL, headers=headers, data=payload)

if response.status_code == 200:
    with open("credentials.yaml", encoding="utf-8") as f:
        creds = yaml.load(f, Loader=yaml.SafeLoader)

    creds["support_auth_token"] = response.json()["access_token"]

    # Rewriting credentials.yaml with new token variable
    with open("credentials.yaml", "w", encoding="utf-8") as f:
        yaml.dump(creds, f)
        print("Auth token updated successfully!!!")
else:
    print("Failed to update token, please check client ID or client secret")

At the top of the script we are importing requests to interact with APIs and yaml for working with yaml files. This will then load the current client secret and client ID from our credentials.yaml file. On line 16 we run our API POST call to retrieve a token. This token can then be used to interact with the support APIs. The second half of the script will essentially recreate the credentials.yaml file, but with the token now in place. Example below!

credentials.yaml

support_auth_token: SomeToken
support_client_id: SomeClientId
support_client_secret: SomeClientSecret

Chicken and Egg Problem

Now that we have a working token, we could use this to run some kind of API GET to gather some data about a device or platform based on the serial number or part ID. Here is the main issue I ran into. The support APIs require either a serial number or part ID to gather relevant information. At some point you will need an inventory or a list of serial numbers to feed into the APIs to gather information for you. I am cheating a bit and started with an inventory file of three devices. This inventory only included the device IP addresses and their platform for handling connections. Example inventory below.

hosts.yaml

---
# Please note, the cisco_ios group is only used to set the device platform type
some-router-01:
  hostname: "192.168.10.10"
  groups:
    - cisco_ios

some-router-02:
  hostname: "192.168.10.11"
  groups:
    - cisco_ios

some-other-node:
  hostname: "192.168.10.12"
  groups:
    - cisco_ios

Imports and CSVs

We will be using Nornir and NAPALM for the initial connection to our devices. The csv import is used for creating our file with all of our information. The yaml import is used to interact with our credentials.yaml file and load the token. You may notice a lot of imports from “tools”, these are a few neat functions I made for this small project. More on those in a bit.

info.py imports and auth

import csv
import yaml
from nornir import InitNornir
from nornir_napalm.plugins.tasks import napalm_get
from tools import manu_year_cisco, nornir_set_creds, product_info, software_release, eox


with open("./credentials.yaml", encoding="utf-8") as file:
    myvars = yaml.safe_load(file)

my_token = myvars["support_auth_token"]

Moving a bit down on the script is the initial interaction with a csv file. We will use the CSV library to create our file and write a few headers that will soon be populated with relevant information.

info.py snippet

with open("device_info.csv", mode="w", encoding="utf-8") as csv_file:
    fieldnames = [
        "Name",
        "Platform",
        "Model",
        "Base_PID",
        "Replacement",
        "Serial",
        "EoS",
        "EoSM",
        "LDoS",
        "EoCR",
        "Manufacture Year",
        "Current SW",
        "Recommended SW",
    ]
    writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
    writer.writeheader()

Nornir and NAPALM

This script utilizes Nornir and NAPALM for the initial device connection. This is only used for a very small run to use a NAPALM getter. In this case we are using the “get_facts” getter. This will retrieve a ton of useful information. For my case I will be capturing the hostname, model, version, serial, and platform. Snippet below of this process.

info.py snipper

def all_the_things(task):
    """It does all the things"""
    task1_result = task.run(
        name=f"Get facts for {task.host.name}!", task=napalm_get, getters=["get_facts"]
    )

    name = task1_result[0].result["get_facts"]["hostname"]
    model = task1_result[0].result["get_facts"]["model"]
    ver = task1_result[0].result["get_facts"]["os_version"]
    version = ver.split(",")[1]
    serial = task1_result[0].result["get_facts"]["serial_number"]
    platform = task1_result[0].result["get_facts"]["vendor"]

Support APIs and Functions

Now that we have the device serial number and model, we can utilize the support APIs to get any other relevant information. When I initially started getting my thoughts into code, the script started getting rather long. Too long for the actual process I was implementing. I eventually split out the API calls to functions within the “tools.py” file. Remember all those imports? I’ll go over one that sets the credentials (thanks Kirk Byers) and one that interacts with the support APIs. The support APIs all follow the same general flow.

I was having a bit of trouble elegantly setting the device username and password without being in any file. I eventually saw a simple function by Kirk, one of the creators of Nornir.

info.py main

def main():
    """Used to run all the things"""
    norn = InitNornir(config_file="config/config.yaml")
    nornir_set_creds(norn)
    norn.run(task=all_the_things)
# Nornir is initialized as "norn"
# The "norn" object is then passed into the nornir_set_creds function

The version I went with will look for environment variables to be set, you could set this to prompt as well but I got tired of typing.

Setting Credentials

def nornir_set_creds(norn, username=None, password=None):
    """
    Handler so credentials are not stored in cleartext.
    Thank you Kirk!
    """
    if not username:
        username = os.environ.get("NORNIR_USER")
    if not password:
        password = os.environ.get("NORNIR_PASS")

    for host_obj in norn.inventory.hosts.values():
        host_obj.username = username
        host_obj.password = password

I’ll do my best to breakdown the most involved function for the support APIs, EoX! This API returns so much useful information that I made the function just return a dictionary of useful things that can be passed to our CSV. The function takes a serial number as the first parameter and a token as the second. These values will then be passed to the headers of the API call as well as the URL. I run a simple check to see if there’s even been an announcement for that serial number. If not, then set all the values to “N/A”. It gets really interesting after the else statement. You probably notice “Dq” all over the place. “Dq”, short for dictionary query, allows you to query a dictionary in really useful ways. This tool comes from the popular Genie library. In all honesty, I just really wanted to play with it! It really does make finding relevant information in a dictionary a lot shorter. I’ll give you an example after the script.

eox function

def eox(serial: str, token: str):
    """Returns all eox information and replacement option from serial number"""
    device = {}
    headers = {"Authorization": f"Bearer {token}"}
    url = f"https://api.cisco.com/supporttools/eox/rest/5/EOXBySerialNumber/1/{serial}"
    req = requests.request("GET", url, headers=headers)
    content = req.json()
    if content["EOXRecord"][0]["EOXExternalAnnouncementDate"]["value"] == "":
        device["eos"] = "N/A"
        device["eosm"] = "N/A"
        device["ldos"] = "N/A"
        device["eocr"] = "N/A"
        device["replacement"] = "N/A"
    else:
        device["eos"] = Dq(content).contains("EndOfSaleDate").get_values("value", 0)
        device["eosm"] = (
            Dq(content).contains("EndOfSWMaintenanceReleases").get_values("value", 0)
        )
        device["ldos"] = (
            Dq(content).contains("LastDateOfSupport").get_values("value", 0)
        )
        device["eocr"] = (
            Dq(content).contains("EndOfServiceContractRenewal").get_values("value", 0)
        )
        device["replacement"] = (
            Dq(content)
            .contains("MigrationProductId")
            .get_values("MigrationProductId", 0)
        )
    return device

Dq Example

Lets say we had the following dictionary to parse through. Our goal is to get the state value of the “10.10.10.10” OSPF neighbor.

Example dictionary

>>> device = {
...     "router": {
...         "ospf": {
...             "neighbors": [
...                 {
...                     "10.10.10.10": {
...                         "state": "up"
...                     }
...                 },
...                 {
...                     "20.20.20.20": {
...                         "state": "down"
...                     }
...                 }
...             ]
...         }
...     }
... }
>>> device["router"]["ospf"]["neighbors"][0]["10.10.10.10"]["state"]
'up'

What you see above is one way of doing it. Mind you, in my example this dictionary is only going so many levels deep. Imagine if you were going down a deeply nested dictionary. Below is how you would do the same using Dq.

Dq Example

>>> Dq(device).contains("10.10.10.10").get_values("state")
['up']
>>> # Notice that Dq returns a list, below is how to get only the string
>>> Dq(device).contains("10.10.10.10").get_values("state", 0)
'up'

Send Data to CSV

Now that we execute all of our functions to hit the support APIs, we have all the information we need to build our CSV. At the top we are setting variables to the result of what is returned from our functions. Some functions only require a serial, like calculating the manufacture year, and others require a serial number and token. After that we use the CSV library to write all the data to the file we created earlier, in this call we are opening the file in append mode with the mode=’a' set.

info.py snippet

    manu_year = manu_year_cisco(serial)
    base_pid = product_info(serial, my_token)
    upgrade = software_release(base_pid, my_token)
    my_eox = eox(serial, my_token)

    with open("device_info.csv", mode="a", encoding="utf-8") as myfile:
        write = csv.DictWriter(myfile, fieldnames=fieldnames)
        write.writerow(
            {
                "Name": name,
                "Model": model,
                "Current SW": version,
                "Serial": serial,
                "Platform": platform,
                "Manufacture Year": manu_year,
                "Base_PID": base_pid,
                "Recommended SW": upgrade,
                "EoS": my_eox["eos"],
                "EoSM": my_eox["eosm"],
                "LDoS": my_eox["ldos"],
                "EoCR": my_eox["eocr"],
                "Replacement": my_eox["replacement"],
            }
        )

Example CSV

Below is a snip from the CSV example in the repo. If you want to test this out, feel free to clone down the repository and modify your hosts file.

Support Info Table

Thank you all for reading this far! This was a really fun tool to create and allowed me to learn a bit about the support APIs, working with CSVs (with Python), and using Dq! I hope you found this even a bit useful. Something that could be a possibility with a script like this is populating a tool like Netbox!