Planet Collab

🔒
❌ About FreshRSS
There are new available articles, click to refresh the page.
Before yesterdayCisco Converged Communications

Automated CUCM CDR Exception Analyser

So Covid-19 containment has resulted in some spare time that would otherwise have been spent commuting, so I finally got around to creating something I've been meaning to do ever since I left AT&T Global Network Services, which is try to replicate their CDR exception analysis reporting. For customer's managed CUCM deployments AT&T would do an analysis of the CDR/CMR files & pick out repeated instances of non-normal call termination cause codes, as well as instances of poor call quality.
My automated version doesn't do quite the same depth of analysis, but does provide an interesting insight into the surprisingly varied & many things going wrong behind the scenes in a CUCM deployment. A CDR exception being:
  • For a given source device, all instances of a particular source cause code
  • For a given source device, all instances of a particular destination cause code
  • For a given destination device, all instances of a particular source cause code
  • For a given destination device, all instances of a particular destination cause code
  • For a given source device, all instances of poor MoS or CCR
  • For a given destination device, all instances of poor MoS or CCR

 Project hosted on my GitHub

Some Open Source Python Networking Tools

I've been tinkering on & off for a while now with using Python to create tools to automate repetitive tasks & occasionally posting the source code on this blog. I've now published them all on GitHub under the BSD 3-clause licence, so that hopefully others can use & adapt them. Links below:

Data Tools

Voice Tools

Long term I'm thinking about putting together a front end using Flask to tie this all together into a self-service portal, but motivation terms & conditions apply (*need recertify my CCIE again this year).

Converting Monitor Capture Dump to PCAP

Most recent Cisco platforms support the Embedded Packet Capture feature, which allows us to take packet captures directly on a router or switch without having to use SPAN to an attached device. Detailed instructions here: Embedded Packet Capture for Cisco IOS and IOS-XE Configuration Example

Normally you can export the resulting packet capture to an FTP or TFTP server, however if this is blocked by firewalls or you don't have access to a suitable server, this is a workaround. Using the show monitor capture buffer dump command we can view the raw packet capture data, but it isn't in a format that Wireshark understands:

router1#show monitor capture buffer CAP dump
03:57:20.288 EST Nov 27 2019 : IPv4 LES CEF : Gi0/0 None

45ED3640: 3890A5D2 BDE07486 0BAD7BC0 08004500 8.%R=`t..-{@..E.
45ED3650: 0028928F 40003106 8B850A52 7A1E0A53 .(..@.1....Rz..S
45ED3660: 9CF8AD60 00161620 FB112DB5 2E9C5010 .x-`... {.-5..P.
45ED3670: F88C7092 00000000 00000000 00       x.p..........

03:57:20.288 EST Nov 27 2019 : IPv4 LES CEF : Gi0/0 None

45ED3640: 3890A5D2 BDE07486 0BAD7BC0 08004500 8.%R=`t..-{@..E.
45ED3650: 00289290 40003106 8B840A52 7A1E0A53 .(..@.1....Rz..S
45ED3660: 9CF8AD60 00161620 FB112DB5 2E9C5010 .x-`... {.-5..P.
45ED3670: F88C7092 00000000 00000000 00       x.p..........


If you copy this raw data to a text file, there is an open source tool to help with this: ciscoText2pcap

First pipe the saved output into ciscoText2pcap & in turn pipe its output into another text file:

cat input.txt | ./ciscoText2pcap.pl > output.txt

Then use Wireshark's text2pcap tool to convert it to a valid PCAP file:

text2pcap -d output.txt output.pcap

Note that you'll need both Perl & Wireshark installed to be able to do this.

UPDATE: Alternatively I knocked together a Python version that can also handle timestamps, source code below.

#!/usr/bin/env python
# (c) 2019, Chris Perkins
# Converts Cisco EPC "show monitor capture buffer dump" into format usable by text2pcap
# Use text2pcap -d -t "%Y-%m-%d %H:%M:%S." to convert output to PCAP whilst showing parsing info
# Based on ciscoText2pcap https://github.com/mad-ady/ciscoText2pcap

# v1.1 - added time stamp handling, converts into UTC
# v1.0 - initial release

import sys, re, pytz, datetime

if __name__ == "__main__":
    # Parse command line parameters
    if len(sys.argv) != 3:
        print("Please specify source & destination files as parameters.")
        sys.exit(1)
    # Parse input file via regex
    try:
        with open(sys.argv[1]) as in_file:
            with open(sys.argv[2], 'w') as out_file:
                packet_start = 0
                for line in in_file:
                    # Regex to find timestamp, then manipulate into format text2pcap can use, as %z or %Z is failing
                    time_date = re.search(r"^(\d\d:\d\d:\d\d\.\d+) (\w+) ([\w ]+) : ", line)
                    if time_date:
                        # Use pytz to parse timezone, then make datetime object TZ aware & convert into UTC
                        try:
                            tz = pytz.timezone(time_date.group(2))
                            dt = datetime.datetime.strptime(f"{time_date.group(3).rstrip()} {time_date.group(1).rstrip()}",
                                "%b %d %Y %H:%M:%S.%f")
                            dt = dt.replace(tzinfo=tz)
                            dt = dt.astimezone(tz=datetime.timezone.utc)
                            out_file.write(f"{dt.strftime('%Y-%m-%d %H:%M:%S.%f')}\n")
                        except IndexError:
                            pass
                        # Continue to next line in input file
                        continue
                    # Regex to find valid blocks of hexadecimal
                    hex_dump = re.search(r"^[0-9A-F]+:\s+((?:[0-9A-F]+ ){1,4}) (.+)\n", line)
                    if hex_dump:
                        # Iterate through each block of hex & split into sets of 2 digits with spaces inbetween
                        char_list = hex_dump.group(1).split()
                        for chars in char_list:
                            packet_hex = ''
                            for i in range(1,len(chars),2):
                                packet_hex += f"{chars[i-1:i+1]} "
                            packet_hex = packet_hex.rstrip()
                            # Output packet as offset (8 hex digits) + hex string
                            out_file.write(f"{packet_start:08X} {packet_hex}\n")
                            packet_start += len(chars) // 2
                    else:
                        # End of packet
                        packet_start = 0
    except FileNotFoundError:
        print(f"Unable to open file {sys.argv[1]}")
        sys.exit(1)
    except OSError:
        print(f"Unable to write file {sys.argv[2]}")
        sys.exit(1)

Nexus Switch PTP Intervals

There's a number of timers you can adjust to control the frequency of various PTP messages on Nexus switches. However the values to configure are somewhat obtuse in Cisco's documentation. For example if you read the NX-OS config guide it says:

ptp announce [interval log seconds | timeout count]
Configures the interval between PTP announce messages on an interface or the number of PTP intervals before a timeout occurs on an interface.
The range for the PTP announcement interval is from 0 to 4 seconds, and the range for the interval timeout is from 2 to 10.

ptp delay request minimum interval log seconds
Configures the minimum interval allowed between PTP delay-request messages when the port is in the master state.
The range is from log(-6) to log(1) seconds.

ptp sync interval log seconds
Configures the interval between PTP synchronization messages on an interface.
The range for the PTP synchronization interval is from -3 log second to 1 log second

But how to interpret the log seconds values? It is described as the logarithmic mean interval in seconds. Which basically means number of seconds between PTP messages = 2 ^ interval value. For example 2 ^ -3 = 0.125s between messages, or 8 messages a second. So some common values are:

Interval 2 = 1 packet every 4 seconds
Interval 1 = 1 packet every 2 seconds
Interval 0 = 1 packet every second
Interval -1 = 1 packet every second
Interval -2 = 4 packets every second
Interval -3 = 8 packets every second


On a related note, by default Nexus switches can act as a grandmaster clock & Cisco best practice is to disable this functionality via "no ptp grandmaster-capable". As it is default configuration you'll need to use  "show run all | inc ptp grandmaster-capable" in order to see the default command enabling it. Some further reading https://www.cisco.com/c/en/us/support/docs/ip/network-time-protocol-ntp/212139-Configure-and-troubleshoot-PTP-in-Nexus.html

External Phone Number Mask Checker

Overview
Tool to check the external phone number mask on the primary line of phones (tkclass=1) & device profiles (tkclass=254). Incorrect masks can then optionally be fixed via import from a CSV files.
It shares dial plan configuration in dialplan.json with the Dial Plan Analyser tool.
Requires Python 3 to run, many Linux distros have Python installed by default. For Windows the easiest install is the official Python Windows version, or Miniconda works fine too:
Miniconda distribution of Python: https://conda.io/miniconda.html
Official Python distribution: https://www.python.org/downloads/

The lxml, Requests, urllib3 and Zeep libraries are required to work.

Version History
Written by Chris Perkins in 2019:
v1.1 - fixed CSV output to UTF-8, fixed E.164 mask handling.
v1.0 – initial release.

All testing was done using Windows with CUCM v11.5.

Using the Tool
It connects to CUCM via the AXL API, so the AXL schema for the version of CUCM in use is required, this is downloaded from CUCM via Application > Plugins > Cisco AXL Toolkit. The required files contained within the .zip file are AXLAPI.wsdl, AXLEnums.xsd and AXLSoap.xsd.
Different CUCM servers are defined in JSON formatted files, allowing for multiple CUCM clusters running different versions (and thus different AXL schemas). Load the CSV file via File > Load AXL:

It will then prompt for the password:

If you wish to save the output in a CSV file, enter the filename into the text box:

Click Check Number Masks, the results will be displayed & optionally saved.

To fix external phone number masks, first review the outputted CSV file. Remove any rows that should be left alone & optionally adjust the New Number Mask if desired, then save it.

If you wish to save the failed updates to a CSV file, enter the filename into the text box. Then click Update Number Masks, it will prompt for the CSV file of updates to make. Any failed updates will be displayed & optionally saved.


Customising the Tool
The direct dial ranges to search for can be customised, so that the tool can be used for any CUCM cluster. These settings are stored in dialplan.json (shared with the Dial Plan Analyser) in JSON format, for example:
[
{
"range_start": "87300",
"range_end": "87399",
"partition": "lon_line_pt",
"mask": "0203100XXXX",
"description": "London 020310073XX"
}
]

The JSON file starts with [ and ends with ].
Each direct dial range is enclosed within { } and contains parameters for the description, range start, range end, mask and partition. The field headings and values must be enclosed within “”.
The range end must be greater than the range start.
The direct dial ranges must have a comma after each, except for the last one.

So to add another range to the above example:
[
{
"range_start": "87300",
"range_end": "87399",
"partition": "lon_line_pt",
"mask": "0203100XXXX",
"description": "London 020310073XX"
},
{
"range_start": "80501",
"range_end": "80700",
"partition": "lon_line_pt",
"mask": "0207170XXXX",
"description": "London 02071700[5-7]XX"
}
]

The parameters for using AXL are also stored in JSON format:
[
{
"fqdn": "cucm-emea-pub.somewhere.com",
"username": "AppAdmin",
"wsdl_file": "file://C://temp//AXLAPI.wsdl"
}
]

“fqdn” should be the FQDN or IP address of the target CUCM publisher.
“username” is an application or end user with the Standard AXL API Access role.
“wsdl_file” points to the location of the AXL schema, note the slightly different path syntax for Windows.

Source Code

#!/usr/bin/env python
# v1.1 - written by Chris Perkins in 2019
# Finds & fixes primary DN's in specified range(s) with an External Phone Number Masks that doesn't match the approved list

# v1.1 - fixed CSV output to UTF-8, fixed E.164 mask handling
# v1.0 – initial release

# Original AXL SQL query code courtesy of Jonathan Els - https://afterthenumber.com/2018/04/27/serializing-thin-axl-sql-query-responses-with-python-zeep/

# To Do:
# Improve the GUI

import sys, json, csv
import tkinter as tk
import requests
from tkinter import ttk
from tkinter import filedialog, simpledialog, messagebox
from collections import OrderedDict
from zeep import Client
from zeep.cache import SqliteCache
from zeep.transports import Transport
from zeep.plugins import HistoryPlugin
from zeep.exceptions import Fault
from zeep.helpers import serialize_object
from requests import Session
from requests.auth import HTTPBasicAuth
from urllib3 import disable_warnings
from urllib3.exceptions import InsecureRequestWarning
from lxml import etree

# GUI and main code
class GUIFrame(tk.Frame):

    def __init__(self, parent):
        """Constructor checks parameters and initialise variables"""
        self.axl_input_filename = None
        self.axl_password = ""
        self.csv_input_filename = None

        try:
            with open("dialplan.json") as f:
                self.json_data = json.load(f)
                for range_data in self.json_data:
                    try:
                        if len(range_data['range_start']) != len(range_data['range_end']):
                            tk.messagebox.showerror(title="Error", message="The first and last numbers in range must be of equal length.")
                            sys.exit()
                        elif int(range_data['range_start']) >= int(range_data['range_end']):
                            tk.messagebox.showerror(title="Error", message="The last number in range must be greater than the first.")
                            sys.exit()
                    except (TypeError, ValueError, KeyError):
                        tk.messagebox.showerror(title="Error", message="Number range parameters incorrectly formatted.")
                        sys.exit()
                    try:
                        if not range_data['mask']:
                            tk.messagebox.showerror(title="Error", message="Number mask must be specified.")
                            sys.exit()
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="Number mask must be specified.")
                        sys.exit()
                    try:
                        if not range_data['partition']:
                            tk.messagebox.showerror(title="Error", message="Partition must be specified.")
                            sys.exit()
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="Partition must be specified.")
                        sys.exit()
        except FileNotFoundError:
            messagebox.showerror(title="Error", message="Unable to open JSON file.")
            sys.exit()
        except json.decoder.JSONDecodeError:
            messagebox.showerror(title="Error", message="Unable to parse JSON file.")
            sys.exit()

        tk.Frame.__init__(self, parent)
        parent.geometry("320x480")
        self.pack(fill=tk.BOTH, expand=True)
        menu_bar = tk.Menu(self)
        file_menu = tk.Menu(menu_bar, tearoff=0)
        file_menu.add_command(label="Load AXL", command=self.open_json_file_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="Exit", command=self.quit)
        menu_bar.add_cascade(label="File", menu=file_menu)
        parent.config(menu=menu_bar)
        tk.Label(self, text="Output Filename:").place(relx=0.2, rely=0.0, height=22, width=200)
        self.output_csv_text = tk.StringVar()
        tk.Entry(self, textvariable=self.output_csv_text).place(relx=0.2, rely=0.05, height=22, width=200)
        tk.Button(self, text="Check Number Masks", command=self.check_masks).place(relx=0.08, rely=0.12, height=22, width=135)
        tk.Button(self, text="Update Number Masks", command=self.update_masks).place(relx=0.52, rely=0.12, height=22, width=135)
        self.results_count_text = tk.StringVar()
        self.results_count_text.set("Results Found: ")
        tk.Label(self, textvariable=self.results_count_text).place(relx=0.20, rely=0.18, height=22, width=210)
        list_box_frame = tk.Frame(self, bd=2, relief=tk.SUNKEN)
        list_box_scrollbar_y = tk.Scrollbar(list_box_frame)
        list_box_scrollbar_x = tk.Scrollbar(list_box_frame, orient=tk.HORIZONTAL)
        self.list_box = tk.Listbox(list_box_frame, xscrollcommand=list_box_scrollbar_x.set, yscrollcommand=list_box_scrollbar_y.set)
        list_box_frame.place(relx=0.02, rely=0.22, relheight=0.75, relwidth=0.96)
        list_box_scrollbar_y.place(relx=0.94, rely=0.0, relheight=1.0, relwidth=0.06)
        list_box_scrollbar_x.place(relx=0.0, rely=0.94, relheight=0.06, relwidth=0.94)
        self.list_box.place(relx=0.0, rely=0.0, relheight=0.94, relwidth=0.94)
        list_box_scrollbar_y.config(command=self.list_box.yview)
        list_box_scrollbar_x.config(command=self.list_box.xview)

    def element_list_to_ordered_dict(self, elements):
        """Convert list to OrderedDict"""
        return [OrderedDict((element.tag, element.text) for element in row) for row in elements]


    def sql_query(self, service, sql_statement):
        """Execute SQL query via AXL and return results"""
        try:
            axl_resp = service.executeSQLQuery(sql=sql_statement)
            try:
                return self.element_list_to_ordered_dict(serialize_object(axl_resp)["return"]["rows"])
            except KeyError:
                # Single tuple response
                return self.element_list_to_ordered_dict(serialize_object(axl_resp)["return"]["row"])
            except TypeError:
                # No SQL tuples
                return serialize_object(axl_resp)["return"]
        except requests.exceptions.ConnectionError as e:
            tk.messagebox.showerror(title="Error", message=str(e))
            return None

    def sql_update(self, service, sql_statement):
        """Execute SQL update via AXL and return rows updated"""
        try:
            axl_resp = service.executeSQLUpdate(sql=sql_statement)
            return serialize_object(axl_resp)["return"]["rowsUpdated"]
        except requests.exceptions.ConnectionError as e:
            tk.messagebox.showerror(title="Error", message=str(e))
            return None

    def read_axl(self, output_filename):
        """Check configuration via AXL SQL query"""
        try:
            self.list_box.delete(0, tk.END)
            self.results_count_text.set("Results Found: ")
            with open(self.axl_input_filename) as f:
                axl_json_data = json.load(f)
                for axl_json in axl_json_data:
                    try:
                        if not axl_json['fqdn']:
                            tk.messagebox.showerror(title="Error", message="FQDN must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="FQDN must be specified.")
                        return
                    try:
                        if not axl_json['username']:
                            tk.messagebox.showerror(title="Error", message="Username must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="Username must be specified.")
                        return
                    try:
                        if not axl_json['wsdl_file']:
                            tk.messagebox.showerror(title="Error", message="WSDL file must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="WSDL file must be specified.")
                        return
        except FileNotFoundError:
            messagebox.showerror(title="Error", message="Unable to open JSON file.")
            return
        except json.decoder.JSONDecodeError:
            messagebox.showerror(title="Error", message="Unable to parse JSON file.")
            return

        axl_binding_name = "{http://www.cisco.com/AXLAPIService/}AXLAPIBinding"
        axl_address = "https://{fqdn}:8443/axl/".format(fqdn=axl_json['fqdn'])
        session = Session()
        session.verify = False
        session.auth = HTTPBasicAuth(axl_json['username'], self.axl_password)
        transport = Transport(cache=SqliteCache(), session=session, timeout=60)
        history = HistoryPlugin()
        try:
            client = Client(wsdl=axl_json['wsdl_file'], transport=transport, plugins=[history])
        except FileNotFoundError as e:
            tk.messagebox.showerror(title="Error", message=str(e))
            return
        axl = client.create_service(axl_binding_name, axl_address)

        # List each primary DN in specified range(s) with an External Phone Number Mask that doesn't match the approved list
        cntr = 0
        result_list = [["DN", "Partition", "Device Name", "Device Description", "Number Mask", "New Number Mask", "pkid"]]
        self.list_box.insert(tk.END, "DN, Partition, Device Name, Device Description, Number Mask, New Number Mask, pkid\n")
        sql_statement = "SELECT n.dnorpattern, p.name AS pname, d.name, d.description, dnmap.e164mask, dnmap.pkid FROM device d INNER JOIN devicenumplanmap dnmap ON dnmap.fkdevice=d.pkid INNER JOIN numplan n ON dnmap.fknumplan=n.pkid LEFT JOIN routepartition p ON n.fkroutepartition=p.pkid WHERE (d.tkclass=1 OR d.tkclass=254) AND dnmap.numplanindex=1 ORDER BY n.dnorpattern"
        try:
            for row in self.sql_query(service=axl, sql_statement=sql_statement):
                try:
                    # Handle None results
                    if row['pkid'] is None:
                        dnmap_pkid = ""
                    else:
                        dnmap_pkid = row['pkid']
                    if row['e164mask'] is None:
                        dnmap_e164mask = ""
                    else:
                        dnmap_e164mask = row['e164mask']
                    if row['description'] is None:
                        d_description = ""
                    else:
                        d_description = row['description']
                    if row['name'] is None:
                        d_name = ""
                    else:
                        d_name = row['name']
                    if row['pname'] is None:
                        p_name = ""
                    else:
                        p_name = row['pname']
                    if row['dnorpattern'] is None:
                        n_dnorpattern = ""
                    else:
                        n_dnorpattern = row['dnorpattern']

                    is_valid_mask = False
                    is_in_range = False
                    correct_mask = ""
                    for range_data in self.json_data:
                        try:
                            range_start = int(range_data['range_start'])
                            range_end = int(range_data['range_end'])
                            dn = int(n_dnorpattern)
                            if p_name.upper() == range_data['partition'].upper() and dn >= range_start and dn <= range_end:
                                if dnmap_e164mask.upper() == range_data['mask'].upper():
                                    is_valid_mask = True
                                    is_in_range = True
                                    break
                                else:
                                    is_in_range = True
                                    correct_mask = range_data['mask']
                                    break
                        except TypeError:
                            continue

                    if is_in_range == True and is_valid_mask == False:
                        self.list_box.insert(tk.END, n_dnorpattern + ', ' + p_name + ', ' + d_name + ', ' + d_description + ', ' + dnmap_e164mask + ', ' + correct_mask + ', ' + dnmap_pkid)
                        result_list.append([n_dnorpattern, p_name, d_name, d_description, dnmap_e164mask, correct_mask, dnmap_pkid])
                        cntr += 1
                except TypeError:
                    continue
        except TypeError:
            pass
        except Fault as thin_axl_error:
            tk.messagebox.showerror(title="Error", message=thin_axl_error.message)
            return

        self.results_count_text.set("Results Found: " + str(cntr))
        # Output to CSV file if required
        try:
            if len(output_filename) != 0:
                with open(output_filename, 'w', newline='', encoding='utf-8-sig') as csv_file:
                    writer = csv.writer(csv_file)
                    writer.writerows(result_list)
        except OSError:
            tk.messagebox.showerror(title="Error", message="Unable to write CSV file.")

    def write_axl(self, output_filename):
        """Update configuration via AXL SQL query"""
        try:
            self.list_box.delete(0, tk.END)
            self.results_count_text.set("Updates Made: ")
            with open(self.axl_input_filename) as f:
                axl_json_data = json.load(f)
                for axl_json in axl_json_data:
                    try:
                        if not axl_json['fqdn']:
                            tk.messagebox.showerror(title="Error", message="FQDN must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="FQDN must be specified.")
                        return
                    try:
                        if not axl_json['username']:
                            tk.messagebox.showerror(title="Error", message="Username must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="Username must be specified.")
                        return
                    try:
                        if not axl_json['wsdl_file']:
                            tk.messagebox.showerror(title="Error", message="WSDL file must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="WSDL file must be specified.")
                        return
        except FileNotFoundError:
            messagebox.showerror(title="Error", message="Unable to open JSON file.")
            return
        except json.decoder.JSONDecodeError:
            messagebox.showerror(title="Error", message="Unable to parse JSON file.")
            return

        axl_binding_name = "{http://www.cisco.com/AXLAPIService/}AXLAPIBinding"
        axl_address = "https://{fqdn}:8443/axl/".format(fqdn=axl_json['fqdn'])
        session = Session()
        session.verify = False
        session.auth = HTTPBasicAuth(axl_json['username'], self.axl_password)
        transport = Transport(cache=SqliteCache(), session=session, timeout=60)
        history = HistoryPlugin()
        try:
            client = Client(wsdl=axl_json['wsdl_file'], transport=transport, plugins=[history])
        except FileNotFoundError as e:
            tk.messagebox.showerror(title="Error", message=str(e))
            return
        axl = client.create_service(axl_binding_name, axl_address)

        # Update External Phone Number Masks contained in CSV file
        cntr = 0
        result_list = [["DN", "Partition", "Device Name", "Device Description", "Number Mask", "New Number Mask", "pkid"]]
        self.list_box.insert(tk.END, "DN, Partition, Device Name, Device Description, Number Mask, New Number Mask, pkid\n")

        # Parse input CSV file & make updates based on the content
        try:
            with open(self.csv_input_filename, encoding='utf-8-sig') as f:
                reader = csv.reader(f)
                header_row = next(reader)
                if header_row[5] != "New Number Mask" or header_row[6] != "pkid":
                    tk.messagebox.showerror(title="Error", message="Unable to parse CSV file.")
                    return
                for row in reader:
                    try:
                        # Check replacement mask has only valid characters
                        is_valid = True
                        for mask_char in row[5]:
                            if mask_char not in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'X', '+']:
                                self.list_box.insert(tk.END, row[0] + ', ' + row[1] + ', ' + row[2] + ', ' + row[3] + ', ' + row[4] + ', ' + row[5] + ', ' + row[6])
                                result_list.append(row)
                                is_valid = False
                                break
                        if is_valid == False:
                            continue

                        sql_statement = "UPDATE devicenumplanmap SET e164mask='" + row[5] + "' WHERE pkid='" + row[6] + "'"
                        num_results = self.sql_update(service=axl, sql_statement=sql_statement)
                        # List updates that failed
                        if num_results < 1:
                            self.list_box.insert(tk.END, row[0] + ', ' + row[1] + ', ' + row[2] + ', ' + row[3] + ', ' + row[4] + ', ' + row[5] + ', ' + row[6])
                            result_list.append(row)
                        else:
                            cntr += 1
                    except TypeError:
                        continue
                    except Fault as thin_axl_error:
                        tk.messagebox.showerror(title="Error", message=thin_axl_error.message)
                        break
        except KeyError:
            tk.messagebox.showerror(title="Error", message="Unable to parse CSV file.")
            pass
        except FileNotFoundError:
            tk.messagebox.showerror(title="Error", message="Unable to open CSV file.")
            return

        self.results_count_text.set("Updates Made: " + str(cntr) + " (failures below)")
        # Output to CSV file if required
        try:
            if len(output_filename) != 0:
                with open(output_filename, 'w', newline='', encoding='utf-8-sig') as csv_file:
                    writer = csv.writer(csv_file)
                    writer.writerows(result_list)
        except OSError:
            tk.messagebox.showerror(title="Error", message="Unable to write CSV file.")

    def check_masks(self):
        """Validate parameters and then call AXL query"""
        if not self.axl_input_filename:
            tk.messagebox.showerror(title="Error", message="No AXL file selected.")
            return

        output_string = self.output_csv_text.get()
        if len(output_string) == 0:
            self.read_axl('')
        else:
            self.read_axl(output_string)

    def update_masks(self):
        """Validate parameters and then call AXL update"""
        if not self.axl_input_filename:
            tk.messagebox.showerror(title="Error", message="No AXL file selected.")
            return

        self.open_csv_file_dialog()
        if not self.csv_input_filename:
            tk.messagebox.showerror(title="Error", message="No CSV file selected.")
            return

        output_string = self.output_csv_text.get()
        if len(output_string) == 0:
            self.write_axl('')
        else:
            self.write_axl(output_string)

    def open_json_file_dialog(self):
        """Dialogue to prompt for JSON file to open and AXL password"""
        self.axl_input_filename = tk.filedialog.askopenfilename(initialdir="/", filetypes=(("JSON files", "*.json"),("All files", "*.*")))
        self.axl_password = tk.simpledialog.askstring("Input", "AXL Password?", show='*')

    def open_csv_file_dialog(self):
        """Dialogue to prompt for CSV file to open"""
        self.csv_input_filename = tk.filedialog.askopenfilename(initialdir="/", filetypes=(("CSV files", "*.csv"),("All files", "*.*")))

if __name__ == "__main__":
    disable_warnings(InsecureRequestWarning)
    # Initialise TKinter GUI objects
    root = tk.Tk()
    root.title("External Number Mask Checker v1.1")
    GUIFrame(root)
    root.mainloop()

Fixing Configuration After a Reboot Using EEM

Over the years there's been various bugs with IOS XE platforms that result the device in question losing/not applying configuration after a reboot, some examples are DHCP snooping trust, certificates or voice dial-peer configuration. One way to workaround around this is to use the Embedded Event Manager (EEM) to trigger execution of certain commands, or even a TCL script.
The first hurdle is to avoid having to bake credentials in, so that you can get into configuration mode without exposing passwords in clear text. Fortunately since EEM 3.1 you can specifically bypass AAA via adding authorization bypass to the applet. The example below uses this to re-apply some DHCP snooping configuration to 2 interfaces after a reload & puts a message into syslog:

event manager applet DHCP-Snoop-Fix authorization bypass
 event syslog pattern "SYS-5-RESTART"
 action 1.0 cli command "enable"
 action 1.1 cli command "configure terminal"
 action 2.0 cli command "interface range GigabitEthernet1/0/24 , GigabitEthernet2/0/48"
 action 2.1 cli command "ip dhcp snooping trust"
 action 3.0 syslog msg "Reapplied DHCP Snooping Config!"

To test you can use event timer countdown time 10 instead of event syslog pattern "SYS-5-RESTART" to trigger it after 10s, whilst enabling debug event manager action cli to watch the script execute.

Some further reading from Cisco:

Adventures With Multicast Dial-peers

After quite a bit of tinkering we managed to get a multicast dial-peer working so that a call into a specific DN is then sent out to a specified multicast group. However there's a few moving parts to be considered, apart from the obvious enabling of multicast end to end between source & destinations. The Cisco Configure Land Mobile Radio (LMR) / Hoot and Holler Over IP on IOS-XE Voice Gateways document is very useful & includes various troubleshooting steps.

Summary of Steps

An analogue port has to be used to source the audio, attempting to use a SIP dial-peer to connect to the multicast dial-peer results in a 404 back from the gateway & a voice IEC syslog messsage about incompatible protocols:

%VOICE_IEC-3-GW: C SCRIPTS: Internal Error (Incompatible protocols): IEC=1.1.47.11.23.0 on callID

To get around this an FXS port was connected back to back with an FXO port, which is configured for PLAR to automatically dial the multicast dial-peer. So the call flow is: DN associated with FXS -> FXO -> multicast.

The multicast dial-peer must be set to a single specific CODEC, because there's no call control to negotiate CODECs.

A Vif interface is required with a /31 or larger mask to source the multicast from. Otherwise the source IP address will end up as nonsense like 0.0.0.1 or 255.255.255.255, resulting in downstream routers dropping the multicast traffic due to it failing the RPF check. The source IP address is actually the Vif interface IP address minus 1, e.g. .2 interface IP = .1 multicast source IP. It does appear to loop around so .1 interface IP = .255 multicast source IP with a /24 subnet mask.

On 4300/4400 series routers ip pim sparse-mode must be enabled on the Service-Engine interface that corresponds to the voice-port, e.g. voice-port 0/1/0 = service-engine0/1/0. Otherwise the audio won't be forwarded over the router's backplane & then out the egress interface.

4300/4400 series routers also have a bug CSCvk02072 that means the multicast RTP stream has DSCP BE (0) instead of EF (46). This can be re-marked via a suitable policy-map applied to the gateway's egress interface.

Lastly at least on IOS XE 16.3.5, for show rtp connection detail & show call active voice brief  the transmit packet counters never increment, staying at zero & giving the impression that the audio isn't being sent when it actually is.

Config Snipppets

ip multicast-routing
!
ip access-list extended VOICE-RTP
 permit udp any host 239.1.1.1 range 8000 48198
!
class-map match-any VOICE
 match access-group name VOICE-RTP
!
policy-map MCAST-QOS
 class VOICE
  set dscp ef
!
interface Service-Engine0/1/0

 description voice-port 0/1/0 backplane
 ip pim sparse-mode
!
interface GigabitEthernet0/0/0
 description Egress Interface
 ip address 192.168.1.1 255.255.255.0
 ip pim sparse-mode
 service-policy output MCAST-QOS
!
interface Vif1

 description Multicast Source Interface
 ip address 172.16.0.255 255.255.255.254
 ip pim sparse-mode
!
voice-port 0/1/0
 connection plar 12345
!
dial-peer voice 100 voip
 destination-pattern 12345
 session protocol multicast
 session target ipv4:239.1.1.1:16384
 codec g711ulaw
 vad aggressive
!
dial-peer voice 200 pots
 incoming called-number .T
 direct-inward-dial
 port 0/1/0

CEF Forwarding Decisions

Sometimes it's useful to know which interface a packet will be forwarded out of when there's more than one path to the destination, e.g. troubleshooting asymmetric routing when there's firewalls in the path.
The show ip cef exact-route command provides this information - you pass it the source & destination IP address, plus optionally the source & destination port for platforms where CEF hashes using the ports also. Note that not all platforms support this, but most current Cisco devices do such as the 3850, 4331, Sup8E, etc.
The example below is a 3850 switch with 2 equal cost default routes via 2 different SVIs:

ufs1#sh ip route
Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP
       D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area
       N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
       E1 - OSPF external type 1, E2 - OSPF external type 2
       i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
       ia - IS-IS inter area, * - candidate default, U - per-user static route
       o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
       a - application route
       + - replicated route, % - next hop override, p - overrides from PfR

Gateway of last resort is 10.83.240.244 to network 0.0.0.0

O*E2  0.0.0.0/0 [110/1] via 10.83.240.244, 2d14h, Vlan12
                [110/1] via 10.83.240.242, 2d14h, Vlan11


ufs1#show ip cef exact-route 10.84.190.1 10.83.232.10
10.84.190.1 -> 10.83.232.10 =>IP adj out of Vlan11, addr 10.83.240.242

ufs1#show ip cef exact-route 10.84.190.1 10.83.251.1
10.84.190.1 -> 10.83.251.1 =>IP adj out of Vlan12, addr 10.83.240.244

CUCM Finding Incorrect DMS Recording Configuration

Overview
Tool to check the recording configuration for a list of DNs specified in a CSV file, such as a user export from the call recording application.
Requires Python 3 to run, many Linux distros have Python installed by default. For Windows the easiest install is the official Python Windows version, or Miniconda works fine too:
Miniconda distribution of Python 3: https://conda.io/miniconda.html
Official Python distribution: https://www.python.org/downloads/

The lxml, Requests, urllib3 and Zeep libraries are required to work.

Version History
Written by Chris Perkins in 2018:
v1.0 – initial release.
v1.1 – fixes some edge cases.

All testing was done using Windows with CUCM v11.5.

Using the Tool
For a list of DNs in a CSV file, the tool finds phones (tkclass=1) & device profiles (tkclass=254) where the built-in bridge isn’t on or privacy isn’t off, automatic call recording isn't enabled, the recording profile doesn't match & recording media source isn't phone preferred. It can optionally output the results to another CSV file.
The input CSV file should contain the list of DNs in a single column, with no header, like the below:

It connects to CUCM via the AXL API, so the AXL schema for the version of CUCM in use is required, this is downloaded from CUCM via Application > Plugins > Cisco AXL Toolkit. The required files contained within the .zip file are AXLAPI.wsdl, AXLEnums.xsd and AXLSoap.xsd.
Different CUCM servers are defined in JSON formatted files, allowing for multiple CUCM clusters running different versions (and thus different AXL schemas). Load the CSV file via File > Load AXL:
 
It will then prompt for the password:

It will then prompt for the input CSV file:

If you wish to save the output in a CSV file, enter the filename into the text box:

Click Check Recording Config, the results will be displayed & optionally saved.

Customising the Tool
The configuration for connecting via AXL to a CUCM cluster & what recording profile(s) to check against are stored in JSON format, for example:
[
{
    "fqdn": "cucm-emea-pub.somewhere.com",
    "username": "AppAdmin",
    "wsdl_file": "file://C://temp//AXLAPI.wsdl",
    "subquery": "(dnmap.fkrecordingprofile!=(SELECT rp.pkid FROM recordingprofile rp WHERE rp.name LIKE 'NICE_NTR_ABITL_RP') AND dnmap.fkrecordingprofile!=(SELECT rp.pkid FROM recordingprofile rp WHERE rp.name LIKE 'NICE_NTR_RP'))"
}
]


  • The JSON file starts with [ and ends with ].
  • “fqdn” should be the FQDN or IP address of the target CUCM publisher.
  • “username” is an application or end user with the Standard AXL API Access role.
  • “wsdl_file” points to the location of the AXL schema, note the slightly different path syntax for Windows.
  • “subquery” is an SQL query that specifies the name of the recording profile, simply paste it into the quotes after LIKE.
It is possible to simultaneously check against multiple recording profiles by joining 2 sub-queries via AND:
"subquery": "(dnmap.fkrecordingprofile!=(SELECT rp.pkid FROM recordingprofile rp WHERE rp.name LIKE 'NICE_NTR_RP') AND dnmap.fkrecordingprofile!=(SELECT rp.pkid FROM recordingprofile rp WHERE rp.name LIKE 'RED_BOX_RP'))"

If you've adjusted the CallManager service parameters so that built-in bridge is on by default & privacy is off by default, change the SQL queries as follows:

            # Check for phones (tkclass=1)
            sql_statement = "SELECT d.name, d.description, n.dnorpattern, n.description AS ndescription FROM device d INNER JOIN devicenumplanmap dnmap ON dnmap.fkdevice=d.pkid INNER JOIN numplan n ON dnmap.fknumplan=n.pkid " \
                "INNER JOIN deviceprivacydynamic dpd ON dpd.fkdevice=d.pkid INNER JOIN recordingdynamic rd ON rd.fkdevicenumplanmap=dnmap.pkid WHERE (d.tkclass=1 AND n.dnorpattern='" \
                + dn + \
                "') AND (d.tkstatus_builtinbridge=0 OR dpd.tkstatus_callinfoprivate=1 OR " \
                + axl_json['subquery'] + \
                " OR dnmap.fkrecordingprofile IS NULL OR dnmap.tkpreferredmediasource!=2 OR rd.tkrecordingflag!=1) ORDER BY d.name"


            # Check for device profiles (tkclass=254)
            sql_statement = "SELECT d.name, d.description, n.dnorpattern, n.description AS ndescription FROM device d INNER JOIN devicenumplanmap dnmap ON dnmap.fkdevice=d.pkid INNER JOIN numplan n ON dnmap.fknumplan=n.pkid " \
                "INNER JOIN deviceprivacydynamic dpd ON dpd.fkdevice=d.pkid INNER JOIN recordingdynamic rd ON rd.fkdevicenumplanmap=dnmap.pkid WHERE (d.tkclass=254 AND n.dnorpattern='" \
                + dn + \
                "') AND (dpd.tkstatus_callinfoprivate=1 OR " \
                + axl_json['subquery'] + \
                " OR dnmap.fkrecordingprofile IS NULL OR dnmap.tkpreferredmediasource!=2 OR rd.tkrecordingflag!=1) ORDER BY d.name"


Source Code

#!/usr/bin/env python
# v1.1 - written by Chris Perkins in 2018
# For a list of DNs in a CSV file, find phones (tkclass=1) & device profiles (tkclass=254) where built-in bridge isn’t on or privacy isn’t off, automatic call recording isn't enabled,
# recording profile doesn't match & recording media source isn't phone preferred. Optionally output to another CSV file

# v1.1 - fixes some edge cases
# v1.0 - original release

# Original AXL SQL query code courtesy of Jonathan Els - https://afterthenumber.com/2018/04/27/serializing-thin-axl-sql-query-responses-with-python-zeep/

# To Do:
# Improve the GUI

import sys, json, csv
import tkinter as tk
import requests
from tkinter import ttk
from tkinter import filedialog, simpledialog, messagebox
from collections import OrderedDict
from zeep import Client
from zeep.cache import SqliteCache
from zeep.transports import Transport
from zeep.plugins import HistoryPlugin
from zeep.exceptions import Fault
from zeep.helpers import serialize_object
from requests import Session
from requests.auth import HTTPBasicAuth
from urllib3 import disable_warnings
from urllib3.exceptions import InsecureRequestWarning
from lxml import etree

# GUI and main code
class DNRecordingCheckerFrame(tk.Frame):

    def __init__(self, parent):
        """Constructor checks parameters and initialise variables"""
        self.axl_input_filename = None
        self.axl_password = ""
        self.csv_input_filename = None
        tk.Frame.__init__(self, parent)
        parent.geometry("320x480")
        self.pack(fill=tk.BOTH, expand=True)
        menu_bar = tk.Menu(self)
        file_menu = tk.Menu(menu_bar, tearoff=0)
        file_menu.add_command(label="Load AXL", command=self.open_json_file_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="Exit", command=self.quit)
        menu_bar.add_cascade(label="File", menu=file_menu)
        parent.config(menu=menu_bar)
        tk.Label(self, text="Output Filename:").place(relx=0.2, rely=0.0, height=22, width=200)
        self.output_csv_text = tk.StringVar()
        tk.Entry(self, textvariable=self.output_csv_text).place(relx=0.2, rely=0.05, height=22, width=200)
        tk.Button(self, text="Check Recording Config", command=self.check_recording).place(relx=0.265, rely=0.12, height=22, width=160)
        self.results_count_text = tk.StringVar()
        self.results_count_text.set("Results Found: ")
        tk.Label(self, textvariable=self.results_count_text).place(relx=0.35, rely=0.18, height=22, width=110)
        list_box_frame = tk.Frame(self, bd=2, relief=tk.SUNKEN)
        list_box_scrollbar_y = tk.Scrollbar(list_box_frame)
        list_box_scrollbar_x = tk.Scrollbar(list_box_frame, orient=tk.HORIZONTAL)
        self.list_box = tk.Listbox(list_box_frame, xscrollcommand=list_box_scrollbar_x.set, yscrollcommand=list_box_scrollbar_y.set)
        list_box_frame.place(relx=0.02, rely=0.22, relheight=0.75, relwidth=0.96)
        list_box_scrollbar_y.place(relx=0.94, rely=0.0, relheight=1.0, relwidth=0.06)
        list_box_scrollbar_x.place(relx=0.0, rely=0.94, relheight=0.06, relwidth=0.94)
        self.list_box.place(relx=0.0, rely=0.0, relheight=0.94, relwidth=0.94)
        list_box_scrollbar_y.config(command=self.list_box.yview)
        list_box_scrollbar_x.config(command=self.list_box.xview)

    def element_list_to_ordered_dict(self, elements):
        """Convert list to OrderedDict"""
        return [OrderedDict((element.tag, element.text) for element in row) for row in elements]


    def sql_query(self, service, sql_statement):
        """Execute SQL query via AXL and return results"""
        try:
            axl_resp = service.executeSQLQuery(sql=sql_statement)
            try:
                return self.element_list_to_ordered_dict(serialize_object(axl_resp)["return"]["rows"])
            except KeyError:
                # Single tuple response
                return self.element_list_to_ordered_dict(serialize_object(axl_resp)["return"]["row"])
            except TypeError:
                # No SQL tuples
                return serialize_object(axl_resp)["return"]
        except requests.exceptions.ConnectionError as e:
            tk.messagebox.showerror(title="Error", message=str(e))
            return None

    def read_axl(self, dn_list, output_filename):
        """Check configuration via AXL SQL query"""
        try:
            self.list_box.delete(0, tk.END)
            self.results_count_text.set("Results Found: ")
            with open(self.axl_input_filename) as f:
                axl_json_data = json.load(f)
                for axl_json in axl_json_data:
                    try:
                        if not axl_json['fqdn']:
                            tk.messagebox.showerror(title="Error", message="FQDN must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="FQDN must be specified.")
                        return
                    try:
                        if not axl_json['username']:
                            tk.messagebox.showerror(title="Error", message="Username must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="Username must be specified.")
                        return
                    try:
                        if not axl_json['wsdl_file']:
                            tk.messagebox.showerror(title="Error", message="WSDL file must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="WSDL file must be specified.")
                        return
                    try:
                        if not axl_json['subquery']:
                            tk.messagebox.showerror(title="Error", message="Subquery must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="Subquery must be specified.")
                        return
        except FileNotFoundError:
            messagebox.showerror(title="Error", message="Unable to open JSON file.")
            return
        except json.decoder.JSONDecodeError:
            messagebox.showerror(title="Error", message="Unable to parse JSON file.")
            return

        axl_binding_name = "{http://www.cisco.com/AXLAPIService/}AXLAPIBinding"
        axl_address = "https://{fqdn}:8443/axl/".format(fqdn=axl_json['fqdn'])
        session = Session()
        session.verify = False
        session.auth = HTTPBasicAuth(axl_json['username'], self.axl_password)
        transport = Transport(cache=SqliteCache(), session=session, timeout=60)
        history = HistoryPlugin()
        try:
            client = Client(wsdl=axl_json['wsdl_file'], transport=transport, plugins=[history])
        except FileNotFoundError as e:
            tk.messagebox.showerror(title="Error", message=str(e))
            return
        axl = client.create_service(axl_binding_name, axl_address)

        # For each DN read from CSV file
        cntr = 0
        result_list = [["Device Name", "Device Description", "DN", "DN Description"]]
        for dn in dn_list:
            # Check for phones (tkclass=1)
            sql_statement = "SELECT d.name, d.description, n.dnorpattern, n.description AS ndescription FROM device d INNER JOIN devicenumplanmap dnmap ON dnmap.fkdevice=d.pkid INNER JOIN numplan n ON dnmap.fknumplan=n.pkid " \
                "INNER JOIN deviceprivacydynamic dpd ON dpd.fkdevice=d.pkid INNER JOIN recordingdynamic rd ON rd.fkdevicenumplanmap=dnmap.pkid WHERE (d.tkclass=1 AND n.dnorpattern='" \
                + dn + \
                "') AND (d.tkstatus_builtinbridge!=1 OR dpd.tkstatus_callinfoprivate!=0 OR " \
                + axl_json['subquery'] + \
                " OR dnmap.fkrecordingprofile IS NULL OR dnmap.tkpreferredmediasource!=2 OR rd.tkrecordingflag!=1) ORDER BY d.name"
            try:
                for row in self.sql_query(service=axl, sql_statement=sql_statement):
                    try:
                        # Handle None results
                        if row['name'] is None:
                            d_name = ""
                        else:
                            d_name = row['name']
                        if row['description'] is None:
                            d_description = ""
                        else:
                            d_description = row['description']
                        if row['dnorpattern'] is None:
                            n_dnorpattern = ""
                        else:
                            n_dnorpattern = row['dnorpattern']
                        if row['ndescription'] is None:
                            n_description = ""
                        else:
                            n_description = row['ndescription']

                        self.list_box.insert(tk.END, d_name + ' "' + d_description + '", ' + n_dnorpattern + ' "' + n_ndescription + '"')
                        result_list.append(list(row.values()))
                        cntr += 1
                    except TypeError:
                        continue
            except TypeError:
                pass
            except Fault as thin_axl_error:
                tk.messagebox.showerror(title="Error", message=thin_axl_error.message)
                return

            # Check for device profiles (tkclass=254)
            sql_statement = "SELECT d.name, d.description, n.dnorpattern, n.description AS ndescription FROM device d INNER JOIN devicenumplanmap dnmap ON dnmap.fkdevice=d.pkid INNER JOIN numplan n ON dnmap.fknumplan=n.pkid " \
                "INNER JOIN deviceprivacydynamic dpd ON dpd.fkdevice=d.pkid INNER JOIN recordingdynamic rd ON rd.fkdevicenumplanmap=dnmap.pkid WHERE (d.tkclass=254 AND n.dnorpattern='" \
                + dn + \
                "') AND (dpd.tkstatus_callinfoprivate!=0 OR " \
                + axl_json['subquery'] + \
                " OR dnmap.fkrecordingprofile IS NULL OR dnmap.tkpreferredmediasource!=2 OR rd.tkrecordingflag!=1) ORDER BY d.name"
            try:
                for row in self.sql_query(service=axl, sql_statement=sql_statement):
                    try:
                        # Handle None results
                        if row['name'] is None:
                            d_name = ""
                        else:
                            d_name = row['name']
                        if row['description'] is None:
                            d_description = ""
                        else:
                            d_description = row['description']
                        if row['dnorpattern'] is None:
                            n_dnorpattern = ""
                        else:
                            n_dnorpattern = row['dnorpattern']
                        if row['ndescription'] is None:
                            n_description = ""
                        else:
                            n_description = row['ndescription']

                        self.list_box.insert(tk.END, d_name + ' "' + d_description + '", ' + n_dnorpattern + ' "' + n_ndescription + '"')
                        result_list.append(list(row.values()))
                        cntr += 1
                    except TypeError:
                        continue
            except TypeError:
                pass
            except Fault as thin_axl_error:
                tk.messagebox.showerror(title="Error", message=thin_axl_error.message)
                return
        self.results_count_text.set("Results Found: " + str(cntr))

        # Output to CSV file if required
        try:
            if len(output_filename) != 0:
                with open(output_filename, 'w', newline='') as csv_file:
                    writer = csv.writer(csv_file)
                    writer.writerows(result_list)
        except OSError:
            tk.messagebox.showerror(title="Error", message="Unable to write CSV file.")

    def check_recording(self):
        """Validate parameters, read CSV file of DNs and then call AXL query"""
        if not self.axl_input_filename:
            tk.messagebox.showerror(title="Error", message="No AXL file selected.")
            return
        if not self.csv_input_filename:
            tk.messagebox.showerror(title="Error", message="No CSV file selected.")
            return
        # Parse input CSV file
        dn_list = []
        try:
            with open(self.csv_input_filename, encoding='utf-8-sig') as f:
                reader = csv.reader(f)
                for row in reader:
                    dn_list.append(row[0])
        except FileNotFoundError:
            tk.messagebox.showerror(title="Error", message="Unable to open CSV file.")
            return

        output_string = self.output_csv_text.get()
        if len(output_string) == 0:
            self.read_axl(dn_list, '')
        else:
            self.read_axl(dn_list, output_string)

    def open_json_file_dialog(self):
        """Dialogue to prompt for JSON file to open and AXL password"""
        self.axl_input_filename = tk.filedialog.askopenfilename(initialdir="/", filetypes=(("JSON files", "*.json"),("All files", "*.*")))
        self.axl_password = tk.simpledialog.askstring("Input", "AXL Password?", show='*')
        self.csv_input_filename = tk.filedialog.askopenfilename(initialdir="/", filetypes=(("CSV files", "*.csv"),("All files", "*.*")))

if __name__ == "__main__":
    disable_warnings(InsecureRequestWarning)
    # Initialise TKinter GUI objects
    root = tk.Tk()
    root.title("DN Recording Checker v1.1")
    DNRecordingCheckerFrame(root)
    root.mainloop()

CUCM Daylight Savings Problems

Daylight savings time can be a problem, as the start & stop dates aren't necessarily the same every year due to legislative changes. For example this year Brazil changed the start date for DST, thus causing CUCM to change the time displayed on phones a week earlier than is correct.
CUCM uses the Olson timezone database, for which Cisco fairly regularly release patches to match changes to timezones around the world, so it is worth updating if you work on a multi-national deployment.
Unfortunately there's not a way to view when CUCM thinks it should be changing to DST from the GUI, but with a little bit of SQL we can get at this information to validate it.

First run show timezone list to view the available timezone names:

admin:show timezone list

   0 - Africa/Abidjan
   1 - Africa/Accra
   2 - Africa/Addis_Ababa
   3 - Africa/Algiers
   4 - Africa/Asmara


Then select * from typetimezone:

admin:run sql select * from typetimezone where name like 'America/Sao_Paulo'
enum name              description          moniker                    bias stddate             stdbias dstdate              dstbias abbreviation legacyname                             
==== ================= ==================== ========================== ==== =================== ======= ==================== ======= ============ =======================================
17   America/Sao_Paulo (GMT-03:00) Brasilia TIMEZONE_AMERICA_SAO_PAULO 180  0/2/0/3,00:00:00:00 0       0/10/0/3,00:00:00:00 -60     BST          E. South America Standard/Daylight Time


  • bias - how many minutes from UTC the time difference is
  • stddate - start date for standard time
  • stdbias - standard time offset from bias (if applicable)
  • dstdate - start date for daylight savings time
  • dstbias - DST offset from bias
The key to decoding the stddate & dstdate fields is that the format is day/month/year (not used)/week time. So looking at dstdate for Sao Paulo time above, 0 = Sunday, 10 = October, 0 = ignored, 3 = 3rd week & 00:00:00:00 = 24h time. In other words the clock changes by -60 minutes from normal (i.e. 1 hour ahead) on the 3rd Sunday in October at midnight. Except this year it's not supposed to be until November 4th at midnight & the install I ran the commands against hasn't been patched to update the Olson timezone data.

There's a bunch more useful information in this TechNote.

4300 / 4400 Forwarding CPU Architecture and Utilisation

The 4300 & 4400 series routers run IOS XE & behave quite differently from previous branch router models (e.g. 2900 or 3900 series). They use dedicated CPU cores to handle forwarding traffic, the 4300 series uses specific cores on the Intel Atom C2000 series CPU & the 4400 series have a dedicated Cavium Octeon series CPU for this purpose. There's separation of the control plane via a dedicated CPU core, with packet forwarding spread across multiple cores. However the IOSd process, which handles the CLI, runs on a separate CPU from the control plane. As a result show process cpu only displays the CPU utilisation by the IOSd process, which will generally be very low. Instead show platform hardware qfp active datapath utilization must be used to see the CPU utilisation by the forwarding CPU(s).

Here is the show process cpu history output of a 4331 router that is handling so much traffic punted to the control plane that it isn't able to process OSPF hellos & keeps dropping OSPF adjacencies:
wanr2#sh proc cpu history
                                           11111
      444444444444666664444444444666666666600000777775555566666555
  100
   90
   80
   70
   60
   50
   40
   30
   20
   10             *****          *******************************
     0....5....1....1....2....2....3....3....4....4....5....5....6
               0    5    0    5    0    5    0    5    0    5    0
               CPU% per second (last 60 seconds)
      1 1  1    1     1
      080562778529765604564547466449777678786796886767577678698777
  100
   90
   80
   70
   60
   50
   40
   30
   20
   10 ###**#****##***** ** * * **  *****#*****#*##*#****##*##*##
     0....5....1....1....2....2....3....3....4....4....5....5....6
               0    5    0    5    0    5    0    5    0    5    0
               CPU% per minute (last 60 minutes)
              * = maximum CPU%   # = average CPU%

For comparison, the forwarding CPU(s) are at 100%:
wanr2#sh plat hard qfp act datapath utilization
  CPP 0: Subdev 0            5 secs        1 min        5 min       60 min
Input:  Priority (pps)            2            2            2            2
                 (bps)         2744         2056         2072         2064
    Non-Priority (pps)        98504        98368        98543        77967
                 (bps)     48603624     48562944     48578680     38524136
           Total (pps)        98506        98370        98545        77969
                 (bps)     48606368     48565000     48580752     38526200
Output: Priority (pps)            2            3            2            3
                 (bps)         2704         3240         3152         3096
    Non-Priority (pps)          128          125          103          111
                 (bps)       243736       218816       174288       224744
           Total (pps)          130          128          105          114
                 (bps)       246440       222056       177440       227840
Processing: Load (pct)          100          100          100           79

If you know from which CPUs are assigned to which roles, you can also use show processes cpu platform sorted (0 is always IOSd):
wanr2#sh processes cpu platform sorted
CPU utilization for five seconds: 4%, one minute: 5%, five minutes: 4%
Core 0: CPU utilization for five seconds: 8%, one minute: 4%, five minutes: 3%
Core 1: CPU utilization for five seconds: 24%, one minute: 7%, five minutes: 4%
Core 2: CPU utilization for five seconds: 4%, one minute: 4%, five minutes: 3%
Core 3: CPU utilization for five seconds: 3%, one minute: 4%, five minutes: 4%
Core 4: CPU utilization for five seconds: 8%, one minute: 7%, five minutes: 7%
Core 5: CPU utilization for five seconds: 1%, one minute: 0%, five minutes: 0%
Core 6: CPU utilization for five seconds: 14%, one minute: 12%, five minutes: 11%
Core 7: CPU utilization for five seconds: 0%, one minute: 0%, five minutes: 0%

Note that these router platforms also have an optional higher throughput license, which unlocks more CPU cores for forwarding. If this feature has been licensed, it is enabled via the platform hardware throughput level command, which requires a reboot:
wanr2(config)#plat hardware throughput level ?
  100000  throughput in kbps
  300000  throughput in kbps
wanr2(config)#platform hardware throughput level 300000
         Feature Name:throughput
 
PLEASE  READ THE  FOLLOWING TERMS  CAREFULLY. INSTALLING THE LICENSE OR
LICENSE  KEY  PROVIDED FOR  ANY CISCO  PRODUCT  FEATURE  OR  USING SUCH
PRODUCT  FEATURE  CONSTITUTES  YOUR  FULL ACCEPTANCE  OF  THE FOLLOWING
TERMS. YOU MUST NOT PROCEED FURTHER IF YOU ARE NOT WILLING TO  BE BOUND
BY ALL THE TERMS SET FORTH HEREIN.
 
Use of this product feature requires  an additional license from Cisco,
together with an additional  payment.  You may use this product feature
on an evaluation basis, without payment to Cisco, for 60 days. Your use
of the  product,  including  during the 60 day  evaluation  period,  is
subject to the Cisco end user license agreement
http://www.cisco.com/en/US/docs/general/warranty/English/EU1KEN_.html
If you use the product feature beyond the 60 day evaluation period, you
must submit the appropriate payment to Cisco for the license. After the
60 day  evaluation  period,  your  use of the  product  feature will be
governed  solely by the Cisco  end user license agreement (link above),
together  with any supplements  relating to such product  feature.  The
above  applies  even if the evaluation  license  is  not  automatically
terminated  and you do  not receive any notice of the expiration of the
evaluation  period.  It is your  responsibility  to  determine when the
evaluation  period is complete and you are required to make  payment to
Cisco for your use of the product feature beyond the evaluation period.
 
Your  acceptance  of  this agreement  for the software  features on one
product  shall be deemed  your  acceptance  with  respect  to all  such
software  on all Cisco  products  you purchase  which includes the same
software.  (The foregoing  notwithstanding, you must purchase a license
for each software  feature you use past the 60 days evaluation  period,
so  that  if you enable a software  feature on  1000  devices, you must
purchase 1000 licenses for use past  the 60 day evaluation period.)
 
Activation  of the  software command line interface will be evidence of
your acceptance of this agreement.
 
 
ACCEPT? (yes/[no]): yes
% The config will take effect on next reboot

The current throughput level can be confirmed via show platform hardware throughput level:
wanr2#show plat hard throughput level
The current throughput level is 100000 kb/s

Further useful information can be found here:

CUCM Dial Plan Analysis for Unused DNs

I've been tinkering with Python again, this in an updated version of a tool I wrote whilst working at AT&T, for the MACD team to aid with finding spare numbers within a direct dial range to use for DNs.

Overview
Tool to analyse CUCM dial plan to find unused phone numbers (i.e. no DN, translation pattern, route pattern, etc. that matches it), requires Python 3 to run.
For Windows the easiest install is the official Python Windows version, or Miniconda works fine too:
Miniconda distribution of Python 3: https://conda.io/miniconda.html
Official Python distribution: https://www.python.org/downloads/

The lxml, Requests, urllib3 and Zeep libraries are required to work.

Version History
Written by Chris Perkins in 2017 and 2018:
v1.0 – initial release with only CSV file support and CLI usage.
v1.1 – added GUI.
v1.2 – bug fixes.
v1.3 – added AXL support.
v1.4 - GUI adjustments & fixes some edge cases.

All testing was done using Windows. CSV files tested with CUCM v9.1 and v10.5, AXL tested with CUCM v11.5.

Using With CSV Files
This method imports dial plan information from CUCM via CSV files. These are created from within CUCM via Call Routing > Route Plan Report > View in file.

Therefore before using the tool, export the Route Plan Report from the CUCM cluster that you want to find unused numbers for.

Load the CSV file via File > Load CSV:


Then select a direct dial range from the drop down list under DN Range:


Click Find Unused DNs, it will then process the CSV file and find numbers in the selected range that aren’t currently in use. The list of unused DNs is in the format directory number / partition, so you can easily see which numbers and which partition the search is working on:

Unused DNs lists how many unused directory numbers were found during the dial plan analysis.
Dial Plan Entries Parsed lists how many possible numbers it had to analyse to find the unused DNs.

Using with AXL
This method imports dial plan information from CUCM using the AXL API. The AXL schema for the version of CUCM in use is required, this is downloaded from CUCM via Application > Plugins > Cisco AXL Toolkit. The requires files contained within the .zip file are AXLAPI.wsdl, AXLEnums.xsd and AXLSoap.xsd.
Different CUCM servers are defined in JSON formatted files, allowing for multiple CUCM clusters running different versions (and thus different AXL schemas). Load the CSV file via File > Load AXL:

It will then prompt for the password:

After this the process is identical to working with CSV files.

Customising the Tool
The direct dial ranges to search for can be customised, so that the tool can be used for any CUCM cluster. These settings are stored in dialplan.json in JSON format, for example:
[
{
    "description": "ANZ - Sydney - 2XXXX",
    "range_start": "20000",
    "range_end": "29999",
    "partition": "INTERNAL"
},
{
    "description": "ANZ - Adelaide - 30[23]XX",
    "range_start": "30200",
    "range_end": "30399",
    "partition": "INTERNAL"
}
]


  • The JSON file starts with [ and ends with ].
  • Each direct dial range is enclosed within { } and contains parameters for the description, range start, range end and partition. The field headings and values must be enclosed within “”.
  • The range end must be greater than the range start.
  • The direct dial ranges must have a comma after each, except for the last one.

So to add another range to the above example:
[
{
    "description": "ANZ - Sydney - 2XXXX",
    "range_start": "20000",
    "range_end": "29999",
    "partition": "INTERNAL"
},
{
    "description": "ANZ - Adelaide - 30[23]XX",
    "range_start": "30200",
    "range_end": "30399",
    "partition": "INTERNAL"
},
{
    "description": "ANZ - Adelaide - 309XX",
    "range_start": "30900",
    "range_end": "30999",
    "partition": "INTERNAL"
},
{
    "description": "ANZ - Canberra - 33[1-3]XX",
    "range_start": "33100",
    "range_end": "33399",
    "partition": "INTERNAL"
}
]


The parameters for using AXL are also stored in JSON format:
[
{
    "fqdn": "cucm-emea-pub.somewhere.com",
    "username": "AppAdmin",
    "wsdl_file": "file://C://temp//AXLAPI.wsdl"
}
]


  • “fqdn” should be the FQDN or IP address of the target CUCM publisher.
  • “username” is an application or end user with the Standard AXL API Access role.
  • “wsdl_file” points to the location of the AXL schema, note the slightly different path syntax for Windows.

Source Code

#!/usr/bin/env python
# v1.4 - written by Chris Perkins in 2017 & 2018, excuse the spaghetti code it was my first Python program...
# Takes CUCM Route Plan Report exported as CSV or uses AXL, parses the regexs for the dial plan to find unused numbers in a given direct dial range
# Number range to match against is defined in JSON format in dialplan.json
# Won't parse dial plan entries with * or # as they're invalid for a direct dial range

# v1.4 - GUI adjustments & fixes some edge cases
# v1.3 – added AXL support
# v1.2 – bug fixes
# v1.1 – added GUI
# v1.0 – initial release with only CSV file support and CLI usage

# Original AXL SQL query code courtesy of Jonathan Els - https://afterthenumber.com/2018/04/27/serializing-thin-axl-sql-query-responses-with-python-zeep/

# To Do:
# Improve the GUI

import itertools, csv, sys, json
import tkinter as tk
import requests
from tkinter import ttk
from tkinter import filedialog, simpledialog, messagebox
from collections import OrderedDict
from zeep import Client
from zeep.cache import SqliteCache
from zeep.transports import Transport
from zeep.plugins import HistoryPlugin
from zeep.exceptions import Fault
from zeep.helpers import serialize_object
from requests import Session
from requests.auth import HTTPBasicAuth
from urllib3 import disable_warnings
from urllib3.exceptions import InsecureRequestWarning
from lxml import etree

# Stores information about numbers in a range
class DirectoryNumbers:
    def __init__(self, start_num, end_num):
        """Constructor initialises attributes"""
        self.number = []
        self.is_used = []
        self.classification = []

        for num in range(int(start_num), int(end_num) + 1):
            num_str = str(num)
            # For numbers with preceeding 0, conversion to int will strip, so prepend with 0 to match length of source string
            if len(num_str) < len(end_num):
                pad_str = ""
                for x in range(0, len(end_num) - len(num_str)):
                    pad_str += "0"
                num_str = pad_str + num_str
            self.number.append(num_str)
            self.is_used.append(False)
            self.classification.append(0)

# GUI and main code
class DialPlanAnalyserFrame(tk.Frame):
    def __init__(self, parent):
        """Constructor checks parameters and initialise variables"""
        self.range_descriptions = []
        self.numbers = []
        self.input_filename = None
        self.use_axl = False
        self.axl_password = ""

        try:
            with open("dialplan.json") as f:
                self.json_data = json.load(f)
                for range_data in self.json_data:
                    try:
                        if len(range_data['range_start']) != len(range_data['range_end']):
                            tk.messagebox.showerror(title="Error", message="The first and last numbers in range must be of equal length.")
                            sys.exit()
                        elif int(range_data['range_start']) >= int(range_data['range_end']):
                            tk.messagebox.showerror(title="Error", message="The last number in range must be greater than the first.")
                            sys.exit()
                    except (TypeError, ValueError, KeyError):
                        tk.messagebox.showerror(title="Error", message="Number range parameters incorrectly formatted.")
                        sys.exit()
                    try:
                        if not range_data['description']:
                            tk.messagebox.showerror(title="Error", message="Description must be specified.")
                            sys.exit()
                        # Uncomment to disallow DNs not in a partition
                        #elif not range_data['partition']:
                        #    tk.messagebox.showerror(title="Error", message="Partition must be specified.")
                        #    sys.exit()
                        self.range_descriptions.append(range_data['description'])
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="Description must be specified.")
                        sys.exit()
        except FileNotFoundError:
            messagebox.showerror(title="Error", message="Unable to open JSON file.")
            sys.exit()
        except json.decoder.JSONDecodeError:
            messagebox.showerror(title="Error", message="Unable to parse JSON file.")
            sys.exit()

        self.range_descriptions = sorted(self.range_descriptions)
        for item in self.json_data:
            if item['description'].upper() == self.range_descriptions[0].upper():
                self.range_description = item['description']
                self.range_start = int(item['range_start'])
                self.range_end = int(item['range_end'])
                self.range_partition = item['partition']
                self.directory_numbers = DirectoryNumbers(item['range_start'], item['range_end'])
                break

        tk.Frame.__init__(self, parent)
        parent.geometry("320x480")
        self.pack(fill=tk.BOTH, expand=True)
        menu_bar = tk.Menu(self)
        file_menu = tk.Menu(menu_bar, tearoff=0)
        file_menu.add_command(label="Load AXL", command=self.open_json_file_dialog)
        file_menu.add_command(label="Load CSV", command=self.open_csv_file_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="Exit", command=self.quit)
        menu_bar.add_cascade(label="File", menu=file_menu)
        parent.config(menu=menu_bar)
        tk.Label(self, text="DN Range:").place(relx=0.4, rely=0.0, height=22, width=62)
        self.range_combobox = ttk.Combobox(self, values=self.range_descriptions, state="readonly")
        self.range_combobox.current(0)
        self.range_combobox.bind("<<ComboboxSelected>>", self.combobox_update)
        self.range_combobox.place(relx=0.02, rely=0.042, relheight=0.06, relwidth=0.96)
        tk.Button(self, text="Find Unused DNs", command=self.find_unused_dns).place(relx=0.35, rely=0.12, height=22, width=100)
        self.unused_label_text = tk.StringVar()
        self.unused_label_text.set("Unused DNs: ")
        tk.Label(self, textvariable=self.unused_label_text).place(relx=0.35, rely=0.18, height=22, width=110)
        list_box_frame = tk.Frame(self, bd=2, relief=tk.SUNKEN)
        list_box_scrollbar_y = tk.Scrollbar(list_box_frame)
        list_box_scrollbar_x = tk.Scrollbar(list_box_frame, orient=tk.HORIZONTAL)
        self.list_box = tk.Listbox(list_box_frame, xscrollcommand=list_box_scrollbar_x.set, yscrollcommand=list_box_scrollbar_y.set)
        list_box_frame.place(relx=0.02, rely=0.22, relheight=0.73, relwidth=0.96)
        list_box_scrollbar_y.place(relx=0.94, rely=0.0, relheight=1.0, relwidth=0.06)
        list_box_scrollbar_x.place(relx=0.0, rely=0.94, relheight=0.06, relwidth=0.94)
        self.list_box.place(relx=0.0, rely=0.0, relheight=0.94, relwidth=0.94)
        list_box_scrollbar_y.config(command=self.list_box.yview)
        list_box_scrollbar_x.config(command=self.list_box.xview)
        self.entries_label_text = tk.StringVar()
        self.entries_label_text.set("Dial Plan Entries Parsed: ")
        tk.Label(self, textvariable=self.entries_label_text).place(relx=0.21, rely=0.95, height=22, width=220)

    def combinations(self, terms, accum):
        """Recursively parse a jagged list of digits to generate list of combination strings"""
        # combinations(digits, '') would populate numbers_in_use with combination strings
        last = (len(terms) == 1)
        n = len(terms[0])
        for i in range(n):
            item = accum + terms[0][i]
            if last:
                self.numbers_in_use.append(item)
            else:
                self.combinations(terms[1:], item)

    def parse_regex(self, pattern, range_start, range_end):
        """Parse CUCM regex pattern and return list of the digit strings the regex matches within the number range specified"""
        is_slice = False
        is_range = False
        is_negate = False
        num_digits = 0
        digits = []
        numbers_in_use = []

        # Parse regex and store digits in jagged list
        for column in range(16):
            digits.append([])
        for char in pattern:
            if char == '[':
                is_slice = True
            elif char == '^' and is_slice == True:
                is_negate = True
            elif char == ']':
                is_slice = False
                if is_negate == True:
                    negate_slice = []
                    for range_char in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']:
                        if range_char not in digits[num_digits]:
                            negate_slice.append(range_char)
                    digits[num_digits] = negate_slice[:]
                    is_negate = False
                num_digits += 1
            elif char in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']:
                if is_range == False:
                    digits[num_digits].append(char)
                    if is_slice == False:
                        num_digits += 1
                else:
                    for range_char in range(int(digits[num_digits][-1]) + 1, int(char) + 1):
                        digits[num_digits].append(str(range_char))
                    is_range = False
            elif char == '-' and is_slice == True:
                is_range = True
            elif char == 'X':
                digits[num_digits] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
                num_digits += 1
            elif char == '*' or char == '#':
                # Strings containing * or # can't be parsed as an integer so return empty list as also not a valid PSTN number
                return []

        # Strip empty lists
        digits2 = [x for x in digits if x != []]

        # Use itertools.product() to convert jagged list of digits to list of combination strings >= range_start & <= range_end
        for list in itertools.product(*digits2):
            char_string = ''
            for char in list:
                char_string += str(char)
            if char_string != '':
                number = int(char_string)
                if number >= range_start and number <= range_end:
                    numbers_in_use.append(char_string)

        return numbers_in_use

    def element_list_to_ordered_dict(self, elements):
        """Convert list to OrderedDict"""
        return [OrderedDict((element.tag, element.text) for element in row) for row in elements]


    def sql_query(self, service, sql_statement):
        """Execute SQL query via AXL and return results"""
        try:
            axl_resp = service.executeSQLQuery(sql=sql_statement)
            try:
                return self.element_list_to_ordered_dict(serialize_object(axl_resp)["return"]["rows"])
            except KeyError:
                # Single tuple response
                return self.element_list_to_ordered_dict(serialize_object(axl_resp)["return"]["row"])
            except TypeError:
                # No SQL tuples
                return serialize_object(axl_resp)["return"]
        except requests.exceptions.ConnectionError as e:
            tk.messagebox.showerror(title="Error", message=str(e))
            return None

    def read_axl(self):
        """Read and parse Route Plan via AXL"""
        try:
            self.list_box.delete(0, tk.END)
            with open(self.input_filename) as f:
                axl_json_data = json.load(f)
                for axl_json in axl_json_data:
                    try:
                        if not axl_json['fqdn']:
                            tk.messagebox.showerror(title="Error", message="FQDN must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="FQDN must be specified.")
                        return
                    try:
                        if not axl_json['username']:
                            tk.messagebox.showerror(title="Error", message="Username must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="Username must be specified.")
                        return
                    try:
                        if not axl_json['wsdl_file']:
                            tk.messagebox.showerror(title="Error", message="WSDL file must be specified.")
                            return
                    except KeyError:
                        tk.messagebox.showerror(title="Error", message="WSDL file must be specified.")
                        return
        except FileNotFoundError:
            messagebox.showerror(title="Error", message="Unable to open JSON file.")
            return
        except json.decoder.JSONDecodeError:
            messagebox.showerror(title="Error", message="Unable to parse JSON file.")
            return

        sql_statement = "SELECT n.dnorpattern, p.name FROM numplan n LEFT JOIN routepartition p ON n.fkroutepartition=p.pkid"
        axl_binding_name = "{http://www.cisco.com/AXLAPIService/}AXLAPIBinding"
        axl_address = "https://{fqdn}:8443/axl/".format(fqdn=axl_json['fqdn'])
        session = Session()
        session.verify = False
        session.auth = HTTPBasicAuth(axl_json['username'], self.axl_password)
        transport = Transport(cache=SqliteCache(), session=session, timeout=60)
        history = HistoryPlugin()
        try:
            client = Client(wsdl=axl_json['wsdl_file'], transport=transport, plugins=[history])
        except FileNotFoundError as e:
            tk.messagebox.showerror(title="Error", message=str(e))
            return
        axl = client.create_service(axl_binding_name, axl_address)

        try:
            raw_route_plan = []
            for row in self.sql_query(service=axl, sql_statement=sql_statement):
                # Ignore entries not in the correct partition and update directory_numbers with numbers found to be in use
                if row['name'] is None:
                    pname = ""
                else:
                    pname = row['name']
                if pname.upper() == self.range_partition.upper():
                    for char_string in self.parse_regex(row['dnorpattern'], self.range_start, self.range_end):
                        raw_route_plan.append(char_string)
                        try:
                            dn_index = self.directory_numbers.number.index(char_string)
                            self.directory_numbers.is_used[dn_index] = True
                        except (IndexError, ValueError):
                            continue
        except TypeError:
            return
        except Fault as thin_axl_error:
            tk.messagebox.showerror(title="Error", message=thin_axl_error.message)
            return

        # Update TKinter display objects with results
        self.entries_label_text.set("Dial Plan Entries Parsed: " + str(len(raw_route_plan)))
        cntr = 0
        for num in range(0, len(self.directory_numbers.number)):
            if self.directory_numbers.is_used[num] == False:
                cntr += 1
                self.list_box.insert(tk.END, self.directory_numbers.number[num] + " / " + self.range_partition)
        self.unused_label_text.set("Unused DNs: " + str(cntr))

    def read_csv_file(self):
        """Read and parse Route Plan Report CSV file"""
        column_index = []

        try:
            self.list_box.delete(0, tk.END)
            # encoding='utf-8-sig' is necessary for correct parsing fo UTF-8 encoding of CUCM Route Plan Report CSV file
            with open(self.input_filename, encoding='utf-8-sig') as f:
                reader = csv.reader(f)
                header_row = next(reader)
                for index, column_header in enumerate(header_row):
                    if column_header == "Pattern or URI":
                        column_index.append(index)
                    elif column_header == "Pattern/Directory Number":
                        column_index.append(index)
                    elif column_header == "Partition":
                        column_index.append(index)
                if len(column_index) != 2:
                    tk.messagebox.showerror(title="Error", message="Unable to parse CSV file.")
                    return
                raw_route_plan = []
                for row in reader:
                    # Ignore entries not in the correct partition and update directory_numbers with numbers found to be in use
                    if row[column_index[1]].upper() == self.range_partition.upper():
                        for char_string in self.parse_regex(row[column_index[0]], self.range_start, self.range_end):
                            raw_route_plan.append(char_string)
                            try:
                                dn_index = self.directory_numbers.number.index(char_string)
                                self.directory_numbers.is_used[dn_index] = True
                            except (IndexError, ValueError):
                                pass
        except FileNotFoundError:
            tk.messagebox.showerror(title="Error", message="Unable to open CSV file.")
            return

        # Update TKinter display objects
        self.entries_label_text.set("Dial Plan Entries Parsed: " + str(len(raw_route_plan)))
        cntr = 0
        for num in range(0, len(self.directory_numbers.number)):
            if self.directory_numbers.is_used[num] == False:
                cntr += 1
                self.list_box.insert(tk.END, self.directory_numbers.number[num] + " / " + self.range_partition)
        self.unused_label_text.set("Unused DNs: " + str(cntr))

    def find_unused_dns(self):
        """Check AXL or CSV selected and hand over to correct method to handle"""
        if self.use_axl:
            if not self.input_filename:
                tk.messagebox.showerror(title="Error", message="No AXL file selected.")
                return
            else:
                self.read_axl()
        else:
            if not self.input_filename:
                tk.messagebox.showerror(title="Error", message="No CSV file selected.")
                return
            else:
                self.read_csv_file()

    def open_csv_file_dialog(self):
        """Dialogue to prompt for CSV file to open"""
        self.input_filename = tk.filedialog.askopenfilename(initialdir="/", filetypes=(("CSV files", "*.csv"),("All files", "*.*")))
        self.use_axl = False
        self.axl_password = ""

    def open_json_file_dialog(self):
        """Dialogue to prompt for JSON file to open and AXL password"""
        self.input_filename = tk.filedialog.askopenfilename(initialdir="/", filetypes=(("JSON files", "*.json"),("All files", "*.*")))
        self.use_axl = True
        self.axl_password = tk.simpledialog.askstring("Input", "AXL Password?", show='*')

    def combobox_update(self, event):
        """Populate range variables when Combobox item selected"""
        self.list_box.delete(0, tk.END)
        self.unused_label_text.set("Unused DNs: ")
        self.entries_label_text.set("Dial Plan Entries Parsed: ")
        value = self.range_combobox.get()
        for item in self.json_data:
            if item['description'].upper() == value.upper():
                self.range_description = item['description']
                self.range_start = int(item['range_start'])
                self.range_end = int(item['range_end'])
                self.range_partition = item['partition']
                self.directory_numbers = DirectoryNumbers(item['range_start'], item['range_end'])
                break

if __name__ == "__main__":
    disable_warnings(InsecureRequestWarning)
    # Initialise TKinter GUI objects
    root = tk.Tk()
    root.title("Dial Plan Analyser v1.4")
    DialPlanAnalyserFrame(root)
    root.mainloop()

Voice Over WLAN Best Practices

VoWLAN deployments can be challenging - VoIP already imposses strict criteria on the network conditions to facilite good call quality & WiFi itself poses additional challenges. Given that a wireless channel is a shared medium, potentially subject to interference from other devices & that the transmit time on a WiFi device is highly variable due to the nature of CSMA/CA, this is a recipe for jitter & packet loss.
Cisco publish a number of guidelines for VoWLAN success, albeit spread across multiple documents, so below is a summary of some of the main points:

  • Maximum of 15 or 20 associated devices per AP.
  • 5GHz is strongly preferred.
  • Noise levels should not exceed -92 dBm with a signal-to-noise ratio (SNR) of 25 dB.
  • Signal strength should be -67 dBm or better per AP.
  • Minimum 20 to 30 percent overlap of adjacent access points with non-overlapping channels must be considered during design site survey.
  • Packet error rate (PER) should not exceed 1%, jitter should be <100 ms & retries should be < 20%.
  • To avoid one-way audio issues resulting from different power settings between Wi-Fi IP phones & access points, World mode (IEEE 802.11d) should be configured.
  • Traffic Specification (TSPEC) must be enabled for CAC on APs & Platinum QoS for the VoWLAN SSID.
  • Channel utilization levels should be kept below 50 percent.
  • Cisco Compatible Extensions (CCX) should be enabled on wireless infrastructure, where possible.
  • Set the Beacon interval to 100 ms.
  • A DTIM of 2 is recommended where possible to save battery life on the IP phones.
  • WPA2/AES Enterprise with CCKM or 802.11r is recommended for 792x phones to avoid the need for a complete 802.1x re-authentication when roaming.
Some further useful Cisco documentation can be found in the Enterprise Mobility 8.1 Design Guide, Voice Over Wireless LAN (VoWLAN) Troubleshooting Checklist & the excellent Cisco Live presentation Voice over WiFi - Deployment Recommendations and Best Practices (BRKEWN-2000).

CUBE Template

This is the last of a planned series of templates. It provides a baseline template for a CUBE handling a SIP trunk from CUCM to the PSTN. Given that different vendor's SIP implementations vary, adjustments are likely to be needed, such as altering the headers via sip-profiles. Inline commentary explains various settings.

!
! Disable unnecessary services
no ip source-route
!
! Don't use ip options drop if you're using RSVP
! Don't use no service dhcp if you're using DHCP Relay
ip options drop
no ip http server
no ip http secure-server
no service tcp-small-servers
no service udp-small-servers
no service dhcp
no ip bootp server
no ip finger
no ip identd
no service config
no mop enabled
no service pad
!
! Enable password encryption, TCP keepalives & faster config viewing
service password-encryption
service tcp-keepalives-in
service tcp-keepalives-out
parser config cache interface
!
! Enable buffer overflow detection & correction
exception memory ignore overflow io
exception memory ignore overflow processor
!
! Optimise TFTP transfers
ip tftp blocksize 8192
!
! Enable log time stamps with the timezone & logging to a syslog server
service timestamps debug datetime msec
service timestamps log datetime localtime msec show-timezone
logging buffered 16384
logging host x.x.x.x
!
! Enable voice Internal Error Codes to syslog
voice iec syslog
!
! Enable SSH v2, reduce SSH session establish timeout & create SSH key
hostname [name]
ip domain-name [domain name]
crypto key generate rsa modulus 2048
ip ssh time-out 120
ip ssh version 2
!
! Block logins for 5 minutes after 4 failed attempts within 2 minutes, also log login attempts
login block-for 300 attempts 4 within 120
login delay 2
login on-failure log
login on-success log
!
! Define a login banner
banner login ^
************************************************************************
You have logged on to a [COMPANY] proprietary device.

This device may be used only for the authorized business purposes
of [COMPANY]. Anyone found using this device or its information for
any unauthorized purpose may be subject to disciplinary action
and/or prosecution.
************************************************************************
^
!
! Define an admin user, configure local authentication & authorisation (ideally use RADIUS/TACACS+)
username [user] privilege 15 secret [password]
aaa new-model
aaa authentication login default local
aaa authentication enable default enable
!
! Set correct time zone & configure multiple NTP servers via DNS
ip name-server 208.67.220.220 208.67.222.222
clock timezone GMT 0
clock summer-time BST recurring last Sun Mar 1:00 last Sun Oct 2:00
ntp server 0.uk.pool.ntp.org
ntp server 1.uk.pool.ntp.org
ntp update-calendar
!
! Enable DSP farm
voice-card 0
 dsp services dspfarm
!
voice rtp send-recv
!
voice service voip
 !
 ! Restrict call setup messages to trusted IP addresses
 ip address trusted list
  ipv4 1.2.3.4 255.255.255.255
  ipv4 1.2.3.5 255.255.255.255
 !
 ! Best practice settings
 mode border-element license capacity 100
 address-hiding
 dtmf-interworking standard
 allow-connections sip to sip
 supplementary-service h450.12
 no supplementary-service sip moved-temporarily
 no supplementary-service sip refer
 !
 ! T38 fax relay
 fax protocol t38 version 0 ls-redundancy 0 hs-redundancy 0 fallback pass-through g711alaw
 fax-relay sg3-to-g3
 h323
  h225 display-ie ccm-compatible
  call preserve
 sip
  asserted-id pai
  no update-callerid
  header-passing error-passthru
  early-offer forced
  privacy-policy passthru
  mid-call-signaling passthru
  sip-profiles 100
!
voice class codec 1
 codec preference 1 g711alaw
 codec preference 2 g711ulaw
!
!
! Normalise SIP messages to remove display names & remove video attributes
voice class sip-profiles 100
 request ANY sip-header From modify "\"(.*)\" <sip:(.*)@(.*)>" "\"\2\" <sip:\2@\3>"
 request ANY sip-header Remote-Party-ID modify "\"(.*)\" <sip:(.*)@(.*)>" "\"\2\" <sip:\2@\3>"
 request ANY sip-header P-Asserted-Identity modify "\"(.*)\" <sip:(.*)@(.*)>" "\"\2\" <sip:\2@\3>"
 request ANY sdp-header Connection-Info remove
 response ANY sdp-header Connection-Info remove
 request ANY sdp-header Video-Attribute remove
 request ANY sdp-header Video-Session-Info remove
 request ANY sdp-header Video-Bandwidth-Info remove
 request ANY sdp-header Video-Connection-Info remove
 request ANY sdp-header Video-Media modify "m=video(.*)" ""
!
! Strip outside dialling prefix
voice translation-rule 1
 rule 1 /^9\(.+\)/ /\1/
!
!
voice translation-profile SIP-OUT
 translate called 1
!
! Simple QoS configuration
class-map match-any VoIP-Signal
 match ip dscp cs3  af31
class-map match-any VoIP-Media
 match ip dscp ef
!
policy-map VoIP
 class VoIP-Media
  priority percent 33
 class VoIP-Signal
  bandwidth percent 5
 class class-default
  fair-queue
!
interface GigabitEthernet0/0
 description ## WAN Interface ##
 ip address x.x.x.x 255.255.255.192
 duplex auto
 speed auto
 service-policy output VoIP
!
interface GigabitEthernet0/1
 description ## LAN Interface ##
 ip address y.y.y.y 255.255.255.0
 duplex auto
 speed auto
 service-policy output VoIP
!
! Required to receive multicast MoH
ccm-manager music-on-hold
!
mgcp profile default
!
! Template dial-peers
dial-peer voice 1 voip
 description ## SIP Trunk ##
 translation-profile outgoing SIP-OUT
 destination-pattern 9.+
 session protocol sipv2
 session target ipv4:1.2.3.4
 incoming called-number 0.+
 voice-class codec 1 
 voice-class sip dtmf-relay force rtp-nte
 voice-class sip bind control source-interface GigabitEthernet0/0
 voice-class sip bind media source-interface GigabitEthernet0/0
 !
 ! Use keepalives if the SIP trunk supports it
 voice-class sip options-keepalive
 dtmf-relay rtp-nte
 ip qos dscp cs3 signaling
 no vad
!
dial-peer voice 2 voip
 description ## DIDs to Subscriber ##
 destination-pattern 0.+
 session protocol sipv2
 session target ipv4:1.2.3.4
 incoming called-number 9.+
 voice-class codec 1 
 voice-class sip bind control source-interface GigabitEthernet0/1
 voice-class sip bind media source-interface GigabitEthernet0/1
 !
 ! Solves problems with SCCP phones that don't support RFC2833
 dtmf-relay rtp-nte sip-kpml
 ip qos dscp cs3 signaling
 no vad
!
dial-peer voice 3 voip
 description ## DIDs to Publisher ##
 destination-pattern 0.+
 preference 1
 session protocol sipv2
 session target ipv4:1.2.3.5
 incoming called-number 9.+
 voice-class codec 1 
 voice-class sip bind control source-interface GigabitEthernet0/1
 voice-class sip bind media source-interface GigabitEthernet0/1
 !
 ! Solves problems with SCCP phones that don't support RFC2833
 dtmf-relay rtp-nte sip-kpml
 ip qos dscp cs3 signaling
 no vad
!
! Set SIP timers & retries
sip-ua
 no remote-party-id
 retry invite 3
 retry register 3
 retry bye 3
 retry cancel 3
 !
 ! connection-reuse seems to break SIP CME/SRST, disable if necessary
 connection-reuse
 host-registrar
!
! Restrict vty access to SSH & set 15 minute timeout on console & vty
ip access-list standard VTY-IN
 permit x.x.x.x x.x.x.x
line con 0
 logging synchronous
 transport preferred none
 exec-timeout 15
line vty 0 15
 logging synchronous
 transport preferred none
 transport input ssh
 access-class VTY-IN in
 exec-timeout 15

MGCP / SRST Template

This is the fourth in a planned series of templates. It provides a baseline template for an MGCP gateway with basic SRST (i.e. not CME in SRST mode). The MGCP configuration in CUCM should match, so be sure to update both the CLI & GUI with the correct switch type, framing, cptone/network locale, etc. for your deployment. Inline commentary explains various settings.
 
!
! Disable unnecessary services
no ip source-route
!
! Don't use ip options drop if you're using RSVP
! Don't use no service dhcp if you're using DHCP Relay
ip options drop
no ip http server
no ip http secure-server
no service tcp-small-servers
no service udp-small-servers
no service dhcp
no ip bootp server
no ip finger
no ip identd
no service config
no mop enabled
no service pad
!
! Enable password encryption, TCP keepalives & faster config viewing
service password-encryption
service tcp-keepalives-in
service tcp-keepalives-out
parser config cache interface
!
! Enable CDP & LLDP
cdp run
lldp run global
!
! Optimise TFTP transfers
ip tftp blocksize 8192
!
! Enable buffer overflow detection & correction
exception memory ignore overflow io
exception memory ignore overflow processor
!
! Enable log time stamps with the timezone & logging to a syslog server
service timestamps debug datetime msec
service timestamps log datetime localtime msec show-timezone
logging buffered 16384
logging host x.x.x.x
!
! Enable voice Internal Error Codes to syslog
voice iec syslog
!
! Enable SSH v2, reduce SSH session establish timeout & create 2048 bit SSH key
hostname [name]
ip domain-name [domain name]
crypto key generate rsa modulus 2048
ip ssh time-out 120
ip ssh version 2
!
! Block logins for 5 minutes after 4 failed attempts within 2 minutes, also log login attempts
login block-for 300 attempts 4 within 120
login delay 2
login on-failure log
login on-success log
!
! Define a login banner
banner login ^
************************************************************************
You have logged on to a [COMPANY] proprietary device.

This device may be used only for the authorized business purposes
of [COMPANY]. Anyone found using this device or its information for
any unauthorized purpose may be subject to disciplinary action
and/or prosecution.
************************************************************************
^
!
! Define an admin user, configure local authentication & authorisation (ideally use RADIUS/TACACS+)
username [user] privilege 15 secret [password]
aaa new-model
aaa authentication login default local
aaa authentication enable default enable
!
! Set correct time zone & configure multiple NTP servers via DNS
ip name-server 208.67.220.220 208.67.222.222
clock timezone GMT 0
clock summer-time BST recurring last Sun Mar 1:00 last Sun Oct 2:00
ntp server 0.uk.pool.ntp.org
ntp server 1.uk.pool.ntp.org
ntp update-calendar
!
! ISDN settings
card type e1 0 0
!
! ISR G1 & G2 clocking commands
network-clock-participate wic 0
network-clock-select 1 e1 0/0/0
!
! 4000 series clocking commands
network-clock synchronization automatic
network-clock input-source 1 controller E1 0/1/0

!
! 4000 series CSCvb01800 bug workaround for clock slips
no network-clock synchronization participate 0/1
!
isdn switch-type primary-net5
!
controller E1 0/0/0
 pri-group timeslots 1-31 service mgcp
 !
 ! 4000 series clocking command
 clock source line primary
!
! Enable B channel negotiation
interface Serial 0/0/0:15
 isdn negotiate-bchan
!
! Example 6-digit translations
voice translation-rule 1
 rule 1 /^25\(2...\)/ /\1/
 rule 2 /^75\(3...\)/ /\1/
!
voice translation-rule 2
 rule 1 /^\(2...\)$/ /0130525\1/
 rule 2 /^\(3...\)$/ /0130575\1/
 rule 3 /^....$/ /01305252600/
!
voice translation-rule 3
 rule 1 /\(.*\)/ /90\1/
!
voice translation-rule 4
 rule 1 /^9/ //
!
voice translation-profile PSTN_In
 translate calling 3
 translate called 1
!
voice translation-profile PSTN_Out
 translate calling 2
!
voice-port 0/0/0:15
 translation-profile outgoing PSTN_Out
 translation-profile incoming PSTN_In
 echo-cancel coverage 64
 bearer-cap Speech
 cptone GB
!
! Enable MGCP fallback & related settings
application
 global
  service alternate Default
 !
!
ccm-manager fallback-mgcp
ccm-manager redundant-host 10.10.10.240
ccm-manager mgcp
ccm-manager music-on-hold
ccm-manager switchback graceful
!
! Tweaked MGCP parameters, such a QoS & DTMF relay
mgcp
mgcp dtmf-relay voip codec all mode out-of-band
mgcp call-agent 10.10.10.243 2427 service-type mgcp version 0.1
mgcp rtp unreachable timeout 1000 action notify
mgcp modem passthrough voip mode nse
mgcp package-capability rtp-package
mgcp package-capability sst-package
mgcp package-capability pre-package
no mgcp package-capability res-package
no mgcp timer receive-rtcp
mgcp sdp simple
mgcp ip qos dscp cs3 signaling
!
! Improves T38 reliability
no ccm-manager fax protocol cisco
no mgcp fax t38 inhibit
mgcp package-capability fxr-package
mgcp default-package fxr-package
no mgcp fax t38 ecm
mgcp fax t38 nsf 000000
!
mgcp profile default
!
! Enable SIP to SIP calls and SIP registrar
voice service voip
 allow-connections sip to sip
 sip
  bind control source-interface x
  bind media source-interface x
  registrar server
!
sip-ua
 host-registrar
!
! Minimal dial plan
dial-peer voice 1 pots
 description Calls to or from the PSTN
 destination-pattern 9T
 incoming called-number .T
 direct-inward-dial
 port 0/0/0:15
!
dial-peer voice 2 pots
 description Emergency services
 destination-pattern 9999
 port 0/0/0:15
 forward-digits 3
!
dial-peer voice 3 pots
 description Emergency services
 destination-pattern 9112
 port 0/0/0:15
 forward-digits 3
!
! Minimal SCCP SRST config
call-manager-fallback
 secondary-dialtone 9
 max-conferences 4 gain -6
 transfer-system full-consult
 timeouts interdigit 5
 ip source-address x.x.x.x port 2000
 max-ephones 52
 max-dn 104 dual-line
 keepalive 20
 time-zone 21
 time-format 24
 date-format dd-mm-yy
 transfer-pattern .T
 call-forward pattern .T
!
! Minimal SIP SRST config
voice register global
 timeouts interdigit 5
 max-dn 104
 max-pool 52
 timezone 21
 time-format 24
 date-format D/M/Y
 network-locale GB
!
! Allow SIP phones from specified network to register
voice register pool 1
 id network x.x.x.x mask 255.255.255.0
 dtmf-relay sip-kpml
 codec g711ulaw
 no vad
!
! Restrict vty access to SSH & set 15 minute timeout on console & vty
ip access-list standard VTY-IN
 permit x.x.x.x x.x.x.x
line con 0
 logging synchronous
 transport preferred none
 exec-timeout 15
line vty 0 15
 logging synchronous
 transport preferred none
 transport input ssh
 access-class VTY-IN in
 exec-timeout 15

Troubleshooting Causes of "host not found" Error When Using Extension Mobility or Phone Services

There's several common causes for a phone to display "host not found" when pressing the Services or Directories buttons, or accessing Extension Mobility. Contrary to what the error message implies, often it's not actually a DNS issue that's the cause. Phone services rely on HTTP or HTTPS, with services hosted by CUCM handled by the Tomcat web server & using TCP ports 8080 or 8443.

First of all it's important to understand which server the phone is trying to access, as the default services built into CUCM use a load balancing mechanism by default, a detailed explanation of which can be found in the SRND. In summary by default built in services (i.e. service URLs starting Application:) use HTTPS & use a load balancing mechanism so that the phone will rewrite the service URL to point to the CUCM server with which it is currently registered.

DNS
DNS is only an issue if the service URL contains an FQDN or hostname, or in the case of built in services, if the Servers in CUCM are defined as an FQDN or hostname. Confirm that the phone actually has DNS servers configured, that these DNS servers are reachable & can resolve the FQDN or hostname.

SSL
You can confirm whether a service will be accessed via HTTPS by looking at the configuration to see if a Secure Service URL has been set, or for built in services, if the phone's configuration file contains <phoneServices useHTTPS="true">. This will allow you to confirm which port the phone will try to use to access the service.

Web Server
Check if the web server is inaccessible, try telnetting to the relevant port or viewing the service URL. For CUCM's built in services, also confirm that the Tomcat service is running on the relevant server.

Certificate Trust
If the web server's certificate isn't present in the phone's ITL/CTL file, the inability to verify the certificate can cause the host not found error.
Confirm that the phone has learnt TFTP server addresses via DHCP option 150 or manual configuration, otherwise it won't be able to update its configuration & may be caching out of date ITL/CTL files.
For certificates that aren't in the ITL/CTL, the phone should attempt to contact the Trust Verification Service on CUCM via TCP port 2445. If the TVS service isn't enabled or isn't running, or cannot be reached then certificate verification will fail. Note that the TVS service also uses a certificate that must in the ITL for the phone to trust it.

Phone Logs
The phone's logs provide insight into what's happening, for example failure to access the TVS service will show up in the logs. Accessing these logs requires that the phone's web server is enabled, which isn't the case by default in CUCM. Also network settings, such as DNS or TFTP server(s) can be verified via the phone's web server.

ASA Template

This is the third in a planned series of templates. It provides a baseline template for ASA configuration prior to customisation, such as ACLs, routing protocols, NAT, VPNs, etc. Not all commands will apply, such as tweaking the TCP MSS if you're using VPNs, or disabling denied connection logging. So don't just copy & paste the whole lot without confirming. Inline commentary explains various settings.

!
! Enable jumbo frames support (requires reboot), then tweak  MTU on interface where jumbo frame are to be used
jumbo-frame reservation
mtu inside 1500
!
! Enable SSH v2 & restrict admin access
hostname [name]
domain-name [domain name]
crypto key generate rsa modulus 2048
ssh version 2
ssh x.x.x.x y.y.y.y [interface name]
http x.x.x.x y.y.y.y [interface name]
!
! Enable management access across a VPN
management-access INSIDE
!
! Disable deprecated SSL encryption
no ssl encryption des-sha1  rc4-sha1
!
! Define an admin user, configure local authentication (ideally use RADIUS/TACACS+) & set 15 minute session timeout
username [user] password [password] privilege 15
aaa authentication ssh console LOCAL 
aaa authentication serial console LOCAL
telnet timeout 15
ssh timeout 15
console timeout 15
!
! Set correct time zone & configure multiple NTP servers via DNS
dns domain-lookup [outside interface]
dns server-group DefaultDNS
 name-server 208.67.220.220
 name-server 208.67.222.222
clock timezone GMT 0
clock summer-time BST recurring last Sun Mar 1:00 last Sun Oct 2:00
ntp server 0.uk.pool.ntp.org prefer
ntp server 1.uk.pool.ntp.org
!
! Enable logging to syslog server & adjust ASDM logging to reduce CPU load
logging enable
logging timestamp
logging buffer-size 16384
logging host [interface name] x.x.x.x
logging trap critical
logging history errors
logging queue 2048 
logging asdm warning 
logging asdm-buffer-size 512 
asdm history enable
!
! Define a login banner
banner login ************************************************************************
banner login You have logged on to a [COMPANY] proprietary device.
banner login This device may be used only for the authorized business purposes 
banner login of [COMPANY]. Anyone found using this device or its information for 
banner login any unauthorized purpose may be subject to disciplinary action 
banner login and/or prosecution.
banner login ************************************************************************
!
! Disable high volume logging to reduce CPU load:
! Build TCP Connection
no logging message 302013
! Teardown TCP Connection
no logging message 302014
! Deny udp reverse path check
no logging message 106021
! Bad TCP hdr length
no logging message 500003
! Denied ICMP type=0, no matching session
no logging message 313004
! No matching connection for ICMP error message
no logging message 313005
! Inbound TCP connection denied outside Firewall Access
no logging message 106001
! Inbound UDP connection denied outside Firewall Access
no logging message 106006
no logging message 106007
!
! Enable basic threat detection but disable statistics
threat-detection basic-threat
no threat-detection statistics
!
! Enable ICMP echo & unreachable, but rate limit unreachables
icmp unreachable rate-limit 10 burst-size 5
icmp permit any echo [outside interface]
icmp permit any echo-reply [outside interface]
icmp permit any unreachable [outside interface]
icmp permit any echo [inside interface]
icmp permit any echo-reply [inside interface]
icmp permit any unreachable [inside interface]
!
! Allow tracert & MTU path discovery to work through the ASA + RFC2827 anti-spoofing for outside interface (note 224.0.0.0/4 used by IGP routing protocols)
access-list OUTSIDE-IN extended deny ip 10.0.0.0 0.0.0.255 any
access-list OUTSIDE-IN extended deny ip 172.16.0.0 0.0.15.255 any
access-list OUTSIDE-IN extended deny ip 192.168.0.0 0.0.255.255 any
access-list OUTSIDE-IN extended deny ip 0.0.0.0 0.0.0.255 any
access-list OUTSIDE-IN extended deny ip 127.0.0.0 0.0.0.255 any
access-list OUTSIDE-IN extended deny ip 169.254.0.0 0.0.255.255 any
access-list OUTSIDE-IN extended deny ip 224.0.0.0 0.0.0.15 any
access-list OUTSIDE-IN extended deny ip 239.0.0.0 0.0.0.255 any
access-list OUTSIDE-IN extended deny ip 240.0.0.0 0.0.1.255 any
access-list OUTSIDE-IN extended permit icmp any any time-exceeded
access-list OUTSIDE-IN extended permit icmp any any unreachable
access-list OUTSIDE-IN extended permit icmp any any parameter-problem
access-list OUTSIDE-IN extended permit icmp any any source-quench
access-list OUTSIDE-IN extended permit ip any any
access-group OUTSIDE-IN in interface [outside interface]
!
! Adjust TCP maximum segment size (default is 1380, depends on VPN encapsulations in use) & disable TCP resets
sysopt connection tcpmss 1420
sysopt connection tcpmss minimum 0
no service resetinbound
no service resetoutside
!
! Permit ARP for subnets there aren't interfaces for (to present them via NAT)
arp permit-nonconnected
!
! Set ISAKMP identity to ASA's IP address, don't use if using certificate authenticated site to site VPNs
crypto isakmp identity address
!
! Allow hairpin NAT
same-security-traffic permit intra-interface
!
! Discard routes for RFC1918 summary addresses so as not to forward out via default route
route Null0 10.0.0.0 255.0.0.0
route Null0 172.16.0.0 255.240.0.0
route Null0 192.168.0.0 255.255.0.0
!
! Enable reverse path filtering, may cause some routing headaches
ip verify reverse-path interface [outside interface]
ip verify reverse-path interface [inside interface]
!
! ASA 5500-X kludge so the IPS can use an IP address from the inside interface subnet via the Management0/0 interface (which must be connected to the inside switch)
interface Management0/0
 no nameif
 security-level 0
 no ip address
 management-only
!
! Tune DNS inspection parameters
policy-map type inspect dns custom_dns_map
 parameters
  message-length maximum 1280
  dns-guard
  protocol-enforcement
  no nat-rewrite
  no id-randomization
  no tsig enforced
  no id-mismatch
!
! Consider disabling unnecessary inspects
policy-map global_policy
 class inspection_default
! These inspects are the bare minimum
  inspect dns custom_dns_map
  inspect ftp
  inspect icmp
  inspect icmp error
  inspect pptp
  inspect ipsec-pass-thru
  inspect ip-options
! These may not be needed, SIP inspect is very commonly required though
  inspect h323 h225
  inspect h323 ras
  inspect netbios
  inspect rsh
  inspect rtsp
  inspect skinny
  inspect esmtp
  inspect sqlnet
  inspect sunrpc
  inspect tftp
  inspect sip
  inspect xdmcp

Internet Facing Router Template

This is the second in a planned series of templates. It provides a baseline template for router configuration prior to customisation, such as ACLs, routing protocols, QoS etc. Not all commands will work on all models of routers or all versions of IOS, so don't just copy & paste the whole lot without confirming. Inline commentary explains various settings.

!
! Disable unnecessary services, including CDP/LLDP (alternatively only enable them on the inside interface)
no ip source-route
!
! Don't use ip options drop if you're using RSVP
! Don't use no service dhcp if you're using DHCP Relay
ip options drop
no ip http server
no ip http secure-server
no service tcp-small-servers
no service udp-small-servers
no service dhcp
no ip bootp server
no ip finger
no ip identd
no service config
no lldp run global
no cdp run
no mop enabled
no service pad
!
! Enable password encryption, TCP keepalives & faster config viewing
service password-encryption
service tcp-keepalives-in
service tcp-keepalives-out
parser config cache interface
!
! Optimise TFTP transfers
ip tftp blocksize 8192
!
! RFC2827 anti-spoofing for outside interface (note 224.0.0.0/4 used by IGP routing protocols)
ip acccess-list extended OUTSIDE-IN
 deny ip 10.0.0.0 0.0.0.255 any
 deny ip 172.16.0.0 0.0.15.255 any
 deny ip 192.168.0.0 0.0.255.255 any
 deny ip 0.0.0.0 0.0.0.255 any
 deny ip 127.0.0.0 0.0.0.255 any
 deny ip 169.254.0.0 0.0.255.255 any
 deny ip 224.0.0.0 0.0.0.15 any
 deny ip 239.0.0.0 0.0.0.255 any
 deny ip 240.0.0.0 0.0.1.255 any
 permit ip any any
!
! Rate limit ICMP unreachables, disable ICMP redirects & directed broadcasts on the outside interface
ip icmp rate-limit unreachable 100
interface GigabitEthernet0/0
 description ## Outside interface ##
 no ip redirects
 no ip directed-broadcast
 ip access-group OUTSIDE-IN in
!
! Discard routes for RFC1918 summary addresses, so as not to forward out the default route
ip route 10.0.0.0 255.0.0.0 null0
ip route 172.16.0.0 255.240.0.0 null0
ip route 192.168.0.0 255.255.0.0 null0
!
! Enable buffer overflow detection & correction
exception memory ignore overflow io
exception memory ignore overflow processor
!
! Enable log time stamps with the timezone & logging to a syslog server
service timestamps debug datetime msec
service timestamps log datetime localtime msec show-timezone
logging buffered 16384
logging host x.x.x.x
!
! Enable SSH v2, reduce SSH session establish timeout & create SSH key
hostname [name]
ip domain-name [domain name]
crypto key generate rsa modulus 2048
ip ssh time-out 120
ip ssh version 2
!
! Block logins for 5 minutes after 4 failed attempts within 2 minutes, also log login attempts
login block-for 300 attempts 4 within 120
login delay 2
login on-failure log
login on-success log
!
! Define a login banner
banner login ^
************************************************************************
You have logged on to a [COMPANY] proprietary device.

This device may be used only for the authorized business purposes
of [COMPANY]. Anyone found using this device or its information for
any unauthorized purpose may be subject to disciplinary action
and/or prosecution.
************************************************************************
^
!
! Define an admin user, configure local authentication & authorisation (ideally use RADIUS/TACACS+)
username [user] privilege 15 secret [password]
aaa new-model
aaa authentication login default local
aaa authentication enable default enable
!
! Set correct time zone & configure multiple NTP servers via DNS
ip name-server 208.67.220.220 208.67.222.222
clock timezone GMT 0
clock summer-time BST recurring last Sun Mar 1:00 last Sun Oct 2:00
ntp server 0.uk.pool.ntp.org
ntp server 1.uk.pool.ntp.org
ntp update-calendar
!
! Restrict vty access to SSH & set 15 minute timeout on console & vty
ip access-list standard VTY-IN
 permit x.x.x.x x.x.x.x
line con 0
 logging synchronous
 transport preferred none
 exec-timeout 15
line vty 0 15
 logging synchronous
 transport preferred none
 transport input ssh
 access-class VTY-IN in
 exec-timeout 15

Switch Configuration Template

This is the first in a planned series of templates. It provides a baseline template for switch configuration prior to customisation, such as port-security, routing protocols, QoS etc. Not all commands will work on all models of switches or all versions of IOS, so don't just copy & paste the whole lot without confirming. Inline commentary explains various settings.

!
! For switches that support it, set SDM template to match intended role. Templates vary between models & a reboot is required
sdm prefer {access | default | routing | vlan} 
!
! Disable unnecessary services
no ip source-route
no ip http server
no ip http secure-server
no service tcp-small-servers
no service udp-small-servers
no ip bootp server
no ip finger
no ip identd
no service config
no mop enabled
no service pad
!
! Enable CDP & LLDP
cdp run
lldp run
!
! Enable routing if required
ip routing 

no ip source-route
!
! Enable password encryption & faster config viewing
service password-encryption
parser config cache interface
!
! Optimise TFTP transfers & EtherChannel load balancing
ip tftp blocksize 8192
port-channel load-balance src-dst-ip
!
! If using DHCP Snooping disable DHCP option 82 insertion
no ip dhcp snooping information option
!
! Enable log time stamps with the timezone & logging to a syslog server
service timestamps debug datetime msec
service timestamps log datetime localtime msec show-timezone
logging buffered 16384
logging host x.x.x.x
!
! Enable SSH v2, reduce SSH session establish timeout & create SSH key
hostname [name]
ip domain-name [domain name]
crypto key generate rsa modulus 2048
ip ssh time-out 120
ip ssh version 2
!
! Block logins for 5 minutes after 4 failed attempts within 2 minutes, also log login attempts
login block-for 300 attempts 4 within 120
login delay 2
login on-failure log
login on-success log 

!
! Due to CVE-2018-0171 & CVE-2018-0156 disable vstack
no vstack
!
! Define a login banner
banner login ^
************************************************************************
You have logged on to a [COMPANY] proprietary device.

This device may be used only for the authorized business purposes 
of [COMPANY]. Anyone found using this device or its information for 
any unauthorized purpose may be subject to disciplinary action 
and/or prosecution.
************************************************************************
^
!
! Define an admin user, configure local authentication & authorisation (ideally use RADIUS/TACACS+)
username [user] privilege 15 secret [password]
aaa new-model
aaa authentication login default local
aaa authentication enable default enable
!
! Set VTP to transparent unless the LAN uses VTP
vtp domain UNUSED
vtp mode transparent
!
! Match the LAN's STP settings
spanning-tree mode rapid-pvst
spanning-tree extend system-id
spanning-tree pathcost method long
!
! BPDU Guard on by default & create parking VLAN
spanning-tree portfast bpduguard default
vlan 999
 name PARKING
!
! Enable notification of MAC address flapping
mac address-table notification mac-move
!
! Assign unused ports as access ports to VLAN 999
interface range Ethernet0/0 - Ethernet0/2
 description ## Unused Port ##
 switchport access vlan 999
 switchport mode access
 speed auto
 duplex auto
!
! Assign trunks' native VLAN to 999 & disable DTP
interface Ethernet0/3
 description ## Trunk to Something ##
 switchport mode trunk
 switchport trunk native vlan 999
 switchport nonegotiate
 speed auto
 duplex auto
!
! Set correct time zone & configure multiple NTP servers via DNS
ip name-server 208.67.220.220 208.67.222.222
clock timezone GMT 0
clock summer-time BST recurring last Sun Mar 1:00 last Sun Oct 2:00
ntp server 0.uk.pool.ntp.org
ntp server 1.uk.pool.ntp.org
ntp update-calendar
!
! Restrict vty access to SSH & set 15 minute timeout on console & vty
ip access-list standard VTY-IN
 permit x.x.x.x x.x.x.x
line con 0
 logging synchronous
 transport preferred none
 exec-timeout 15
line vty 0 15
 logging synchronous
 transport preferred none
 transport input ssh
 access-class VTY-IN in
 exec-timeout 15

Phone Reason for Out of Service Codes

Examine jabber.log or Debug Display on a phone's web server to look for entries after a disconnect occurred.
Then refer to this list, which is scraped from CUCM Serviceability > Alarm > Definition > CallManager Alarm Catalog > Phone > Find > LastOutOfServiceInformation.
Note that CUCM 11.5 added some more reason codes but the alarm catalogue hasn't been updated, there's a bug listing for this: CSCvb63000

10   --  TCPtimedOut - The TCP connection to the Cisco Unified Communication Manager experienced a timeout error
12   --  TCPucmResetConnection - The Cisco Unified Communication Manager reset the TCP connection
13   --  TCPucmAbortedConnection - The Cisco Unified Communication Manager aborted the TCP connection
14   --  TCPucmClosedConnection - The Cisco Unified Communication Manager closed the TCP connection
15   --  SCCPKeepAliveFailure - The device closed the connection due to a SCCP KeepAlive failure
16   --  TCPdeviceLostIPAddress - The connection closed due to the IP address being lost.  This may be due to the DHCP Lease expiring or the detection of IP address duplication. Check that the DHCP Server is online and that no duplication has been reported by the DHCP Server
17   --  TCPDeviceRegsistrationTimedOut - The device closed the TCP connection due to a registration timeout
18   --  TCPclosedConnectHighPriorityUcm - The device closed the TCP connection in order to reconnect to a higher priority Cisco Unified CM
20   --  TCPclosedUserInitiatedReset - The device closed the TCP connection due to a user initiated reset
22   --  TCPclosedUcmInitiatedReset - The device closed the TCP connection due to a reset command from the Cisco Unified CM
23   --  TCPclosedUcmInitiatedRestart - The device closed the TCP connection due to a restart command from the Cisco Unified CM
24   --  TCPClosedRegistrationReject - The device closed the TCP connection due to receiving a registration rejection from the Cisco Unified CM
25   --  RegistrationSuccessful - The device has initialized and is unaware of any previous connection to the Cisco Unified CM
26   --  TCPclosedVlanChange - The device closed the TCP connection due to reconfiguration of IP on a new Voice VLAN
27   --  TCPclosedPowerSavePlus - The device closed the TCP connection in order to enter Power Save Plus mode
100  --  ConfigVersionMismatch - The device detected a version stamp mismatch during registration Cisco Unified CM
104  --  TCPclosedApplyConfig - The device closed the TCP connection to restart triggered internally by the device to apply the configuration changes
105  --  TCPclosedDeviceRestart - The device closed the TCP connection due to a restart triggered internally by the device because device failed to download the configuration or dial plan file
106  --  TCPsecureConnectionFailed - The device failed to setup a secure TCP connection with Cisco Unified CM
107  --  TCPclosedDeviceReset - The device closed the TCP connection to set the inactive partition as active partition, then reset, and come up from the new active partition
108  --  VpnConnectionLost - The device could not register to Unified CM because VPN connectivity was lost
200  --  ClientApplicationClosed - The device was unregistered because the client application was closed
201  --  OsInStandbyMode - The device was unregistered because the OS was put in standby mode
202  --  OsInHibernateMode - The device was unregistered because the OS was put in hibernate mode
203  --  OsInShutdownMode - The device was unregistered because the OS was shut down
204  --  ClientApplicationAbort - The device was unregistered because the client application crashed
205  --  DeviceUnregNoCleanupTime - The device was unregistered in the previous session because the system did not allow sufficient time for cleanup
206  --  DeviceUnregOnSwitchingToDeskphone - The device was unregistered because the client requested to switch from softphone to deskphone control
207  --  DeviceUnregOnSwitchingToSoftphone - The device is being registered because the client requested to switch from deskphone control to softphone
208  --  DeviceUnregOnNetworkChanged - The device is being unregistered because the client detected a change of network
209  --  DeviceUnregExceededRegCount - The device is being unregistered because the device has exceeded the maximum number of concurrent registrations
210  --  DeviceUnregExceededLoginCount - The device is being unregistered because the client has exceeded the maximum number of concurrent logons
❌