Add CUCM plugin, since it was lying around in another repo.

This commit is contained in:
Marsell Kukuljevic 2025-04-01 20:10:05 +02:00
parent 0833ae7a16
commit de64c02488
7 changed files with 1049 additions and 0 deletions

View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
#
# Parses and checks non-phone devices from CUCM.
import json
from cmk.base.plugins.agent_based.agent_based_api.v1 import register, Result, Service, State
# Convert JSON entries into dictionaries indexed by name.
def parse_cucm(string_table):
lookup = {}
for row in string_table:
device = json.loads(row[0])
name = device["name"]
lookup[name] = device
return lookup
register.agent_section(
name="cucm_chk",
parse_function=parse_cucm
)
# Produce a list of services based on the parsed devices.
def discover_cucm(section):
for name, details in sorted(section.items()):
model_name = details["model_name"]
ip = details.get("ip")
gui_name = "%s %s (%s)" % (model_name, name, ip)
yield Service(item=gui_name, parameters={"name": name})
# Given a specific device, look it up in the parsed devices, and produce
# results on that service based upon the devices' status.
def check_cucm(item, params, section):
name = params["name"]
device = section.get(name)
if device is None:
yield Result(state=State.WARN, summary="Not appearing in CUCM API")
return
status = device.get("status")
if status is None:
yield Result(state=State.WARN, summary="No status for this in CUCM API")
elif status == "Registered":
yield Result(state=State.OK, summary="Registered")
elif ["Unregistered", "Rejected", "PartiallyRegistered", "Unknown"].count(status) == 1:
summary = status
msg = device.get("status_reason")
if msg:
summary += " " + msg
yield Result(state=State.WARN, summary=summary)
else:
yield Result(state=State.WARN, summary="Unknown status: %s" % status)
register.check_plugin(
name="cucm_chk",
service_name="CUCM %s",
discovery_function=discover_cucm,
check_function=check_cucm,
check_default_parameters={},
check_ruleset_name="cucm_chk",
)

View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
# Parses and inventories phones.
# XXX for the inventory plugin, if mac/serial/model is None, do not update the inventory
# XXX for checkmk, add a last_seen. If last_seen is older than six months, remove it.
import json
from cmk.base.plugins.agent_based.agent_based_api.v1 import register, TableRow
# Convert JSON entries into dictionaries indexed by name.
def parse_cucm(string_table):
lookup = {}
for row in string_table:
phone = json.loads(row[0])
name = phone["name"]
lookup[name] = phone
return lookup
# Produce a table of all phones parsed earlier.
def inventory_cucm(section):
path = ["phones"]
for name, details in sorted(section.items()):
details.pop("name")
yield TableRow(
path=path,
key_columns={"name": name},
inventory_columns=details
)
register.agent_section(
name="cucm_inv",
parse_function=parse_cucm
)
register.inventory_plugin(
name="cucm_inv",
inventory_function=inventory_cucm,
)

View File

@ -0,0 +1,340 @@
#!/usr/bin/env python3
#
# Contact CUCM to fetch the status of non-phone devices, and return the results
# of each device as a JSON line.
#
# This file imports code from agent_cucm_inv, so much of the important logic
# is found there.
from importlib.util import spec_from_loader, module_from_spec
from importlib.machinery import SourceFileLoader
import os, sys, json, urllib.request
# Since Python doesn't import files without .py extensions, we need to do a
# little tapdance to import agent_cucm_inv.
file = 'agent_cucm_inv'
path = os.path.dirname(__file__) + '/' + file
spec = spec_from_loader(file, SourceFileLoader(file, path))
inv = module_from_spec(spec)
spec.loader.exec_module(inv)
# Call the CUCM RisPort70 API synchronously, using a SOAP query to fetch
# information about devices matching the requested device type. It returns
# XML, which we parse.
#
# Be aware that the API will return information about a maximum of 2000 devices,
# and provides no means of pagination. Having more than 2000 non-phone devices
# would be quite exceptional, so we don't handle that here, but if you ever
# need to support more than that look into how agent_cucm_inv uses AXL to
# do pagination.
#
# See query_cucm_risport() in agent_cucm_inv for more info.
def query_cucm(addr, port, user, password, insecure, device):
url = 'https://%s:%s/realtimeservice2/services/RISService70/' % (addr, port)
headers = [('Content-Type', 'text/plain')]
try:
return inv.get_url(url, user, password, insecure, headers, f"""
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:soap="http://schemas.cisco.com/ast/soap">
<soapenv:Header/>
<soapenv:Body>
<soap:selectCmDevice>
<soap:StateInfo></soap:StateInfo>
<soap:CmSelectionCriteria>
<soap:MaxReturnedDevices>2000</soap:MaxReturnedDevices>
<soap:DeviceClass>{device}</soap:DeviceClass>
<soap:Model>255</soap:Model>
<soap:Status></soap:Status>
<soap:NodeName></soap:NodeName>
<soap:SelectBy>Name</soap:SelectBy>
<soap:SelectItems></soap:SelectItems>
<soap:Protocol>Any</soap:Protocol>
<soap:DownloadStatus>Any</soap:DownloadStatus>
</soap:CmSelectionCriteria>
</soap:selectCmDevice>
</soapenv:Body>
</soapenv:Envelope>
""")
except urllib.error.HTTPError as e:
sys.stderr.write("CUCM error: %s\n" % e)
# Statuses listed here: https://developer.cisco.com/docs/sxml/#!risport70-api-reference/ReasonCode
status_reason_lookup = {
"0": None,
"1": "Unknown",
"6": "ConnectivityError",
"8": "DeviceInitiatedReset",
"9": "CallManagerReset",
"10": "DeviceUnregistered",
"11": "MalformedRegisterMsg",
"12": "SCCPDeviceThrottling",
"13": "KeepAliveTimeout",
"14": "ConfigurationMismatch",
"15": "CallManagerRestart",
"16": "DuplicateRegistration",
"17": "CallManagerApplyConfig",
"18": "DeviceNoResponse",
"19": "EMLoginLogout",
"20": "EMCCLoginLogout",
"25": "RegistrationSequenceError",
"26": "InvalidCapabilities",
"28": "FallbackInitiated",
"29": "DeviceSwitch",
"30": "DeviceWipe",
"31": "DeviceForcedReset",
"33": "LowBattery",
"34": "ManualPowerOff",
}
# Model names listed here: https://developer.cisco.com/docs/sxml/#!risport70-api-reference/risport70-api-reference
model_name_lookup = {
"1": "Cisco 30 SP+",
"2": "Cisco 12 SP+",
"3": "Cisco 12 SP",
"4": "Cisco 12 S",
"5": "Cisco 30 VIP",
"6": "Cisco 7910",
"7": "Cisco 7960",
"8": "Cisco 7940",
"9": "Cisco 7935",
"10": "Cisco VGC Phone",
"11": "Cisco VGC Virtual Phone",
"12": "Cisco ATA 186",
"15": "EMCC Base Phone",
"20": "SCCP Phone",
"30": "Analog Access",
"40": "Digital Access",
"42": "Digital Access+",
"43": "Digital Access WS-X6608",
"47": "Analog Access WS-X6624",
"48": "VGC Gateway",
"50": "Conference Bridge",
"51": "Conference Bridge WS-X6608",
"52": "Cisco IOS Conference Bridge (HDV2)",
"53": "Cisco Conference Bridge (WS-SVC-CMM)",
"61": "H.323 Phone",
"62": "H.323 Gateway",
"70": "Music On Hold",
"71": "Device Pilot",
"72": "CTI Port",
"73": "CTI Route Point",
"80": "Voice Mail Port",
"83": "Cisco IOS Software Media Termination Point (HDV2)",
"84": "Cisco Media Server (WS-SVC-CMM-MS)",
"85": "Cisco Video Conference Bridge (IPVC-35xx)",
"86": "Cisco IOS Heterogeneous Video Conference Bridge",
"87": "Cisco IOS Guaranteed Audio Video Conference Bridge",
"88": "Cisco IOS Homogeneous Video Conference Bridge",
"90": "Route List",
"100": "Load Simulator",
"110": "Media Termination Point",
"111": "Media Termination Point Hardware",
"112": "Cisco IOS Media Termination Point (HDV2)",
"113": "Cisco Media Termination Point (WS-SVC-CMM)",
"115": "Cisco 7941",
"119": "Cisco 7971",
"120": "MGCP Station",
"121": "MGCP Trunk",
"122": "GateKeeper",
"124": "7914 14-Button Line Expansion Module",
"125": "Trunk",
"126": "Tone Announcement Player",
"131": "SIP Trunk",
"132": "SIP Gateway",
"133": "WSM Trunk",
"134": "Remote Destination Profile",
"227": "7915 12-Button Line Expansion Module",
"228": "7915 24-Button Line Expansion Module",
"229": "7916 12-Button Line Expansion Module",
"230": "7916 24-Button Line Expansion Module",
"232": "CKEM 36-Button Line Expansion Module",
"253": "SPA8800",
"254": "Unknown MGCP Gateway",
"255": "Unknown",
"302": "Cisco 7985",
"307": "Cisco 7911",
"308": "Cisco 7961G-GE",
"309": "Cisco 7941G-GE",
"335": "Motorola CN622",
"336": "Third-party SIP Device (Basic)",
"348": "Cisco 7931",
"358": "Cisco Unified Personal Communicator",
"365": "Cisco 7921",
"369": "Cisco 7906",
"374": "Third-party SIP Device (Advanced)",
"375": "Cisco TelePresence",
"376": "Nokia S60",
"404": "Cisco 7962",
"412": "Cisco 3951",
"431": "Cisco 7937",
"434": "Cisco 7942",
"435": "Cisco 7945",
"436": "Cisco 7965",
"437": "Cisco 7975",
"446": "Cisco 3911",
"468": "Cisco Unified Mobile Communicator",
"478": "Cisco TelePresence 1000",
"479": "Cisco TelePresence 3000",
"480": "Cisco TelePresence 3200",
"481": "Cisco TelePresence 500-37",
"484": "Cisco 7925",
"486": "Syn-Apps Virtual Phone",
"493": "Cisco 9971",
"495": "Cisco 6921",
"496": "Cisco 6941",
"497": "Cisco 6961",
"503": "Cisco Unified Client Services Framework",
"505": "Cisco TelePresence 1300-65",
"520": "Cisco TelePresence 1100",
"521": "Transnova S3",
"522": "BlackBerry MVS VoWifi",
"527": "IPTrade TAD",
"537": "Cisco 9951",
"540": "Cisco 8961",
"547": "Cisco 6901",
"548": "Cisco 6911",
"550": "Cisco ATA 187",
"557": "Cisco TelePresence 200",
"558": "Cisco TelePresence 400",
"562": "Cisco Dual Mode for iPhone",
"564": "Cisco 6945",
"575": "Cisco Dual Mode for Android",
"577": "Cisco 7926",
"580": "Cisco E20",
"582": "Generic Single Screen Room System",
"583": "Generic Multiple Screen Room System",
"584": "Cisco TelePresence EX90",
"585": "Cisco 8945",
"586": "Cisco 8941",
"588": "Generic Desktop Video Endpoint",
"590": "Cisco TelePresence 500-32",
"591": "Cisco TelePresence 1300-47",
"592": "Cisco 3905",
"593": "Cisco Cius",
"594": "VKEM 36-Button Line Expansion Module",
"596": "Cisco TelePresence TX1310-65",
"597": "Cisco TelePresence MCU",
"598": "Ascom IP-DECT Device",
"599": "Cisco TelePresence Exchange System",
"604": "Cisco TelePresence EX60",
"606": "Cisco TelePresence Codec C90",
"607": "Cisco TelePresence Codec C60",
"608": "Cisco TelePresence Codec C40",
"609": "Cisco TelePresence Quick Set C20",
"610": "Cisco TelePresence Profile 42 (C20)",
"611": "Cisco TelePresence Profile 42 (C60)",
"612": "Cisco TelePresence Profile 52 (C40)",
"613": "Cisco TelePresence Profile 52 (C60)",
"614": "Cisco TelePresence Profile 52 Dual (C60)",
"615": "Cisco TelePresence Profile 65 (C60)",
"616": "Cisco TelePresence Profile 65 Dual (C90)",
"617": "Cisco TelePresence MX200",
"619": "Cisco TelePresence TX9000",
"621": "Cisco 7821",
"620": "Cisco TelePresence TX9200",
"622": "Cisco 7841",
"623": "Cisco 7861",
"626": "Cisco TelePresence SX20",
"627": "Cisco TelePresence MX300",
"628": "IMS-integrated Mobile (Basic)",
"631": "Third-party AS-SIP Endpoint",
"632": "Cisco Cius SP",
"633": "Cisco TelePresence Profile 42 (C40)",
"634": "Cisco VXC 6215",
"635": "CTI Remote Device",
"640": "Usage Profile",
"642": "Carrier-integrated Mobile",
"645": "Universal Device Template",
"647": "Cisco DX650",
"648": "Cisco Unified Communications for RTX",
"652": "Cisco Jabber for Tablet",
"659": "Cisco 8831",
"682": "Cisco TelePresence SX10",
"683": "Cisco 8841",
"684": "Cisco 8851",
"685": "Cisco 8861",
"688": "Cisco TelePresence SX80",
"689": "Cisco TelePresence MX200 G2",
"690": "Cisco TelePresence MX300 G2",
"20000": "Cisco 7905",
"30002": "Cisco 7920",
"30006": "Cisco 7970",
"30007": "Cisco 7912",
"30008": "Cisco 7902",
"30016": "Cisco IP Communicator",
"30018": "Cisco 7961",
"30019": "Cisco 7936",
"30027": "Analog Phone",
"30028": "ISDN BRI Phone",
"30032": "SCCP gateway virtual phone",
"30035": "IP-STE",
"36041": "Cisco TelePresence Conductor",
"36042": "Cisco DX80",
"36043": "Cisco DX70",
"36049": "BEKEM 36-Button Line Expansion Module",
"36207": "Cisco TelePresence MX700",
"36208": "Cisco TelePresence MX800",
}
# Given CUCM XML, use XPath to extract relevant details for each device
# searching based on device type. Return a list of devices' information.
def get_device_details(xml, device):
namespace = {"ns1": "http://schemas.cisco.com/ast/soap"}
items = xml.findall(f".//ns1:DeviceClass[.='{device}']/..", namespace)
names_seen = {}
trunk_details = []
for item in items:
ip = item.find(".//ns1:IP", namespace).text
name = item.find("ns1:Name", namespace).text
model = item.find("ns1:Model", namespace).text
status = item.find("ns1:Status", namespace).text
reason = item.find("ns1:StatusReason", namespace).text
if not names_seen.get(name):
trunk_details.append({
"ip": ip,
"name": name,
"status": status,
"status_reason": status_reason_lookup.get(reason),
"type": device,
"model_name": model_name_lookup.get(model) or "Unknown"
})
names_seen[name] = True
return trunk_details
# Contact CUCM and query it for device information for the following device
# types: SIP trunks, hunt lists, H323 and media resources (e.g. IVR). Return
# a list for devices' information.
def get_devices(addr, port, user, password, insecure):
devices = []
for device in ["SIPTrunk", "MediaResources", "H323", "HuntList"]:
cucm_xml = query_cucm(addr, port, user, password, insecure, device)
details = get_device_details(cucm_xml, device)
devices.extend(details)
return devices
# Parse args, contact CUCM, check status of non-hone devices, and then print
# results
def main(argv=None):
if argv is None:
argv = sys.argv[1:]
args = inv.parse_arguments(argv)
devices = get_devices(args.hostname, args.port, args.user, args.password,
args.insecure)
inv.print_out(devices, "cucm_chk")
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,434 @@
#!/usr/bin/env python3
#
# Given a CUCM host, query the CUCM to get a complete list of phones, then
# query all those phones concurrently for additional details, then print
# the results out in a format CheckMK understands.
#
# Run the command on the console for a complete list of options.
#
# This script is designed to work with up to 90K phones, and ideally below 10K.
# If more than 10K phones are queried, this script should be modified to
# perform connection reuse to improve performance. Beyond 90K support for
# paginating the AXL API must be added.
phone_query_timeout = 10 # max time to query a single phone
phone_queries_timeout = 45 # max time to query all phones
cucm_page_size = 1000 # CUCM will not return pages larger than 2000.
# Larger page sizes cause notably longer queries,
# so a default of 1000 devices per query is a
# safer number.
import urllib.request, base64, sys, argparse, asyncio, re, json, ssl, html
from xml.etree import ElementTree
from textwrap import wrap
# Create a TLS context for client connections.
def create_ssl_ctx():
ctx = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
# Typical GET or POST HTTP with Basic auth (using user and password
# credientials). Returns data structure parsed from XML.
def get_url(url, user, password, insecure, headers, data):
request = urllib.request.Request(url, data=bytes(data, 'ascii'))
for header, value in headers:
request.add_header(header, value)
if user and password:
auth_str = base64.b64encode(bytes('%s:%s' % (user, password), 'ascii'))
request.add_header('Authorization', 'Basic %s' % auth_str.decode('utf-8'))
ctx = None
if insecure:
ctx = create_ssl_ctx()
with urllib.request.urlopen(request, context=ctx) as conn:
xml_data = conn.read()
return ElementTree.fromstring(xml_data)
# Call the CUCM AXL API synchronously, using a SOAP query to fetch the names of
# all phones. It returns XML, which we parse. We call the AXL API because the
# RisPort70 API (see query_cucm_risport() below) does not support pagination,
# so we need to get a full list of phone names from AXL first, then do multiple
# queries on RisPort70 using subsets of the phone name list found from AXL.
#
# The AXL API has a return limit of 8MB, which is around 90K phones, so we
# don't bother paginating the AXL API itself; if more than that is needed, add
# pagination here.
#
# References:
# https://developer.cisco.com/docs/axl/
# https://github.com/reillychase/How-to-return-Cisco-RIS-with-more-than-1000-results/blob/master/main.py
def query_cucm_axl(addr, port, user, password, insecure):
url = 'https://%s:%s/axl/' % (addr, port)
headers = [
('Content-Type', 'text/xml'),
('Accept', 'text/xml'),
('SOAPAction', 'CUCM:DB ver=12.5'),
]
try:
return get_url(url, user, password, insecure, headers, """
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:ns="http://www.cisco.com/AXL/API/12.5">
<soapenv:Header/>
<soapenv:Body>
<ns:listPhone>
<searchCriteria>
<name>%</name>
</searchCriteria>
<returnedTags>
<name/>
</returnedTags>
</ns:listPhone>
</soapenv:Body>
</soapenv:Envelope>
""")
except urllib.error.HTTPError as e:
sys.stderr.write("AXL error: %s\n" % e)
# Call the CUCM RisPort70 API synchronously, using a SOAP query to fetch
# information about the phones with ids listed in the phone_ids arg. It returns
# XML, which we parse.
#
# Be aware that the API will return information about a maximum of 2000 devices,
# and provides no means of pagination. In order to do pagination, we first need
# to query the AXL API for a list of phone names, then all this function
# repeatedly with a different subset of 2000 phones from that complete list.
#
# Although this function will allow pages for 2000 devices, it's recommended to
# use less for each call to avoid timeouts. The default maximum size of
# phone_ids is 1000, although this can be varied by changing the cucm_page_size
# at the top of this file.
#
# References:
# https://developer.cisco.com/docs/sxml/#!risport70-api-reference
# https://paultursan.com/2018/12/getting-cucm-real-time-data-via-risport70-with-python-and-zeep-cisco-serviceability-api/
def query_cucm_risport(addr, port, user, password, insecure, phone_ids):
assert len(phone_ids) <= 2000
url = 'https://%s:%s/realtimeservice2/services/RISService70/' % (addr, port)
headers = [('Content-Type', 'text/plain')]
id_query = ''.join([f'<soap:item><soap:Item>{id}</soap:Item></soap:item>' for id in phone_ids])
try:
return get_url(url, user, password, insecure, headers, f"""
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:soap="http://schemas.cisco.com/ast/soap">
<soapenv:Header/>
<soapenv:Body>
<soap:selectCmDevice>
<soap:StateInfo></soap:StateInfo>
<soap:CmSelectionCriteria>
<soap:MaxReturnedDevices>2000</soap:MaxReturnedDevices>
<soap:DeviceClass>Any</soap:DeviceClass>
<soap:Model>255</soap:Model>
<soap:Status>Registered</soap:Status>
<soap:NodeName></soap:NodeName>
<soap:SelectBy>Name</soap:SelectBy>
<soap:SelectItems>
{id_query}
</soap:SelectItems>
<soap:Protocol>Any</soap:Protocol>
<soap:DownloadStatus>Any</soap:DownloadStatus>
</soap:CmSelectionCriteria>
</soap:selectCmDevice>
</soapenv:Body>
</soapenv:Envelope>
""")
except urllib.error.HTTPError as e:
sys.stderr.write("CUCM error: %s\n" % e)
# Given AXL XML, use XPath to extract names for all phones.
def get_phone_ids(xml):
# should this be ns2?
namespace = {'ns': 'http://www.cisco.com/AXL/API/12.5'}
items = xml.findall(".//phone/name", namespace)
names = []
for item in items:
names.append(item.text)
return names
# Given CUCM XML, use XPath to extract a bunch of details for each phone.
def get_phone_details(xml):
namespace = {'ns1': 'http://schemas.cisco.com/ast/soap'}
items = xml.findall(".//ns1:DeviceClass[.='Phone']/..", namespace)
names_seen = {}
phone_details = []
for item in items:
ip = item.find('.//ns1:IP', namespace).text
name = item.find('ns1:Name', namespace).text
dir_num = item.find('ns1:DirNumber', namespace).text
description = item.find('ns1:Description', namespace).text
user = item.find('ns1:LoginUserId', namespace).text
# These come with a -Registered on the end of the numbers.
# Since all numbers we get from CUCM are registered, there's no
# need for the -Registered, and we cut it off here.
if dir_num:
dir_num = dir_num.split('-')[0]
if not names_seen.get(name):
phone_details.append((name, ip, dir_num, user, description))
names_seen[name] = True
return phone_details
# If a phone (possibly) returns XML, attempt to extract the MAC, serial and
# model. We're using regex here, instead of full-blown XML parsing, to minimize
# the time and GC garbage generated.
def get_phone_details_from_xml(data):
# if the HTTP server didn't return a 200...
if data.find('200 OK') == -1:
return None, None, None
# attempt to extract info from XML
mac = re.search('<MACAddress>(.+)</MACAddress>', data)
serial = re.search('<serialNumber>(.+)</serialNumber>', data)
model = re.search('<modelNumber>(.+)</modelNumber>', data)
mac_str = mac and normalize_mac(html.unescape(mac[1]))
ser_str = serial and html.unescape(serial[1])
mod_str = model and html.unescape(model[1])
return mac_str, ser_str, mod_str
# If a phone (possibly) returns HTML, attempt to extract the MAC, serial and
# model. We use regex here for the same reason we use it in
# get_phone_details_from_xml().
def get_phone_details_from_html(data):
if data.find('200 OK') == -1:
return None, None, None
mac = None
serial = None
model = None
# attempt to extract info from HTML
matches = re.findall('<b>\s*(.*?)\s*</b>', data, re.M | re.I)
for i, txt in enumerate(matches):
txt = txt.lower()
if not mac and txt == 'mac address':
mac = normalize_mac(html.unescape(matches[i + 1]))
elif not serial and txt == 'serial number':
serial = html.unescape(matches[i + 1])
elif not model and txt == 'model number':
model = html.unescape(matches[i + 1])
elif mac and serial and model:
break
return mac, serial, model
# Different phones return MACs in different formats. We convert them to a single
# canonical format here.
def normalize_mac(mac):
if mac.find(":") != -1:
return mac.lower()
else:
return ":".join(wrap(mac, 2)).lower()
# Create a new HTTP/HTTPS connection, send a request, and extract any results.
async def get_async_url(ip, url, insecure=False):
ctx = None
port = 80
if not insecure:
ctx = create_ssl_ctx()
port = 443
# XXX switch from 127.0.0.1:8081 to ip:port
# future = asyncio.open_connection('127.0.0.1', 8081, ssl=ctx)
future = asyncio.open_connection(ip, port, ssl=ctx)
reader, writer = await asyncio.wait_for(future, timeout=phone_query_timeout)
query = f'GET {url} HTTP/1.1\r\nHost: {ip}\r\nConnection: close\r\n\r\n'
writer.write(query.encode())
await writer.drain()
data = ''
while not reader.at_eof():
raw = await reader.read(-1)
data += raw.decode()
writer.close()
await writer.wait_closed()
return data
# Asynchronously contact the HTTP server in a phone. There are several
# different URLs that might return information, depending on the model of phone.
# To fetch the MAC and serial details we want requires us to potentially call
# all endpoints until we get some results. Be aware that some phone HTTP servers
# return 200 (and empty results) if we call the wrong URL for that model.
#
# We attempt to contact the phone using HTTPS first, falling back to HTTP if
# attempts with HTTPS failed.
#
# Originally we tried to take advantage of HTTP connection reuse, but this was
# causing some problems, and it wasn't worth the effort to handle the edge
# cases. Now we always make a new connection per request, even when it's
# multiple URLs on the same IP. If additional performance is ever needed, HTTP
# connection reuse is worth adding; depending on the latency it can easily
# 2x+ request rate.
async def query_phone_info_now(details):
name, ip, dir_num, user, description = details
mac = None
serial = None
model = None
try:
data = await get_async_url(ip, '/DeviceInformationX')
mac, serial, model = get_phone_details_from_xml(data)
if not mac:
data = await get_async_url(ip, '/Device_Information.html')
mac, serial, model = get_phone_details_from_html(data)
if not mac:
data = await get_async_url(ip, '/CGI/Java/Serviceability?adapter=device.statistics.device')
mac, serial, model = get_phone_details_from_html(data)
if not mac:
data = await get_async_url(ip, '/')
mac, serial, model = get_phone_details_from_html(data)
except (ConnectionRefusedError, asyncio.TimeoutError, ssl.SSLError):
try:
if not mac:
data = await get_async_url(ip, '/DeviceInformationX', True)
mac, serial, model = get_phone_details_from_xml(data)
if not mac:
data = await get_async_url(ip, '/Device_Information.html', True)
mac, serial, model = get_phone_details_from_html(data)
if not mac:
data = await get_async_url(ip, '/CGI/Java/Serviceability?adapter=device.statistics.device', True)
mac, serial, model = get_phone_details_from_html(data)
if not mac:
data = await get_async_url(ip, '/', True)
mac, serial, model = get_phone_details_from_html(data)
except (ConnectionRefusedError, asyncio.TimeoutError):
pass
return {
"name": name,
"ip": ip,
"mac": mac,
"serial": serial,
"dir_num": dir_num,
"model": model,
"user": user,
"description": description
}
# This functions job is solely to keep a limit on the concurrent number of
# connections made to the phones. Without this limit we'd quickly run out of
# spare sockets when dealing with large numbers of phones.
async def query_phone_info(details, semaphore):
async with semaphore:
return await query_phone_info_now(details)
# Given information about a list of phones (specifically, their IP addresses),
# we call the HTTP server on each phone to extract the MAC and serial. We
# return with a list of dicts containing information about all phones.
# Contacting thousands of phones serially would take too long, so we keep 200
# concurrent calls in-flight to the phones to shorten all querying to a few
# seconds.
async def query_phones(details):
sem = asyncio.Semaphore(200)
tasks = map(lambda d: asyncio.create_task(query_phone_info(d, sem)), details)
done, pending = await asyncio.wait(tasks, timeout=phone_queries_timeout)
# we silently ignore pending for now
return map(lambda f: f.result(), done)
# Given an array of phone names, do paginated queries to the CUCM for phone
# information, then asynchronously query all the phones. While the CUCM has
# most of the information we want about a phone, it critically lacks the serial
# and MAC of the phone, which is why we need to fetch the details from the
# phone itself over an HTTP server each phone has.
def get_phones(addr, port, user, password, insecure):
axl_xml = query_cucm_axl(addr, port, user, password, insecure)
phone_ids = get_phone_ids(axl_xml)
phone_details = []
page_size = cucm_page_size
for i in range(0, len(phone_ids), page_size):
ids = phone_ids[i:i + page_size]
cucm_xml = query_cucm_risport(addr, port, user, password, insecure, ids)
details = get_phone_details(cucm_xml)
phone_details.extend(details)
return asyncio.run(query_phones(phone_details))
# Print out all our results in a format that CheckMK understands. Most of our
# output are in JSON rows.
def print_out(device_info, agent_name):
sys.stdout.write(f"<<<{agent_name}:sep(0)>>>\n")
device_info = list(device_info)
device_info.sort(key=lambda d: d["ip"])
for entry in device_info:
sys.stdout.write("%s\n" % json.dumps(entry))
# Parse the command-line arguments. We have several options, but hostname is
# always required. Print out help to console if we get no args.
def parse_arguments(argv):
parser = argparse.ArgumentParser()
parser.add_argument(
"-u", "--user", default=None, help="Username for CUCM login"
)
parser.add_argument(
"-s", "--password", default=None, help="Password for CUCM login"
)
parser.add_argument(
"-p", "--port", default=443, type=int, help="Use alternative port (default: 443)"
)
parser.add_argument(
"hostname", metavar="HOSTNAME", help="Hostname of the CUCM to query."
)
parser.add_argument(
"-k", "--insecure", default=False, help="Skip certificate verification",
action="store_true"
)
return parser.parse_args(argv)
# Parse args, contact CUCM, query phones, and then print results
def main(argv=None):
if argv is None:
argv = sys.argv[1:]
args = parse_arguments(argv)
phones = get_phones(args.hostname, args.port, args.user, args.password,
args.insecure)
print_out(phones, 'cucm_inv')
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,24 @@
#!/usr/bin/env python3
def agent_cucm_arguments(params, hostname, ipaddress):
args = []
if "user" in params:
args += ["-u", params["user"]]
if "password" in params:
args += ["-s", params["password"]]
if "port" in params:
args += ["-p", params["port"]]
if params.get("insecure"):
args.append("-k")
args.append(params["instance"])
return args
special_agent_info["cucm_chk"] = agent_cucm_arguments
special_agent_info["cucm_inv"] = agent_cucm_arguments

View File

@ -0,0 +1,19 @@
from cmk.gui.plugins.views import inventory_displayhints
from cmk.gui.i18n import _l
# inventory list for phones found in CUCM
inventory_displayhints.update({
".phones:": {
"title": _l("Phones"),
"keyorder": ["name", "user", "dir_num", "ip", "model", "serial", "mac", "description"],
},
".phones:*.name": {"title": _l("Name")},
".phones:*.user": {"title": _l("User")},
".phones:*.dir_num": {"title": _l("Dir Num")},
".phones:*.ip": {"title": _l("IP Addr")},
".phones:*.model": {"title": _l("Model")},
".phones:*.serial": {"title": _l("Serial")},
".phones:*.mac": {"title": _l("MAC Addr")},
".phones:*.description": {"title": _l("Description")},
})

View File

@ -0,0 +1,120 @@
#!/usr/bin/env python3
#
# GUI configuration pages to set up inventorying and checks done by the CUCM
# agent to CUCM. These two pages are for giving the agent the necessary details
# to connect to CUCM (e.g. IP address, user, login, etc).
#
# Ideally, we'd have a single page to configure both the inventorying and
# checks, since both contact the same CUCM instance. Unfortunately, I didn't
# find a clean way to do it, so we're left with two identical GUI pages that
# take identical information. At least we manage to share most of the code
# here by taking a deep copy and modifying the title.
import copy
from cmk.gui.i18n import _
from cmk.gui.plugins.wato.utils import (
rulespec_registry,
HostRulespec,
RulespecGroupCheckParametersHardware
)
from cmk.gui.plugins.wato.inventory import RulespecGroupInventory
from cmk.gui.watolib.rulespecs import Rulespec
from cmk.gui.valuespec import (
Dictionary,
TextInput,
Hostname,
NetworkPort,
Password,
TextAscii,
FixedValue
)
# GUI config page for inventory.
def _valuespec_special_agents_cucm_inv():
return Dictionary(
title=_("CUCM inventory"),
help=_(""),
optional_keys=["port", "user", "password", "insecure"],
elements=[
(
"instance",
Hostname(
title=_("Hostname"),
help=_(
"Host of CUCM host for query"
),
allow_empty=False,
),
),
(
"port",
NetworkPort(
title=_("Port"),
help=_(
"Port of CUCM host for query"
),
minvalue=1,
default_value=443,
),
),
(
"user",
TextInput(
title=_("Username"),
help=_(
"Username used when querying CUCM"
),
),
),
(
"password",
Password(
title=_("Password"),
help=_(
"Password used when querying CUCM"
),
),
),
(
"insecure",
FixedValue(
True,
title=_("Insecure"),
totext=_("Disable SSL certificate verification"),
help=_(
"Ignore unverified HTTPS request warnings when contacting CUCM"
),
),
),
],
)
# GUI config page for checks. We do a deep copy of the above function and just
# change the title. A bit hackish since we're changing a private attribute.
def _valuespec_special_agents_cucm_chk():
inv_spec = _valuespec_special_agents_cucm_inv()
chk_spec = copy.deepcopy(inv_spec)
chk_spec._title=_("CUCM checks")
return chk_spec
rulespec_registry.register(
HostRulespec(
factory_default=Rulespec.FACTORY_DEFAULT_UNUSED,
name="special_agents:cucm_inv",
group=RulespecGroupInventory,
valuespec=_valuespec_special_agents_cucm_inv,
)
)
rulespec_registry.register(
HostRulespec(
factory_default=Rulespec.FACTORY_DEFAULT_UNUSED,
name="special_agents:cucm_chk",
group=RulespecGroupCheckParametersHardware,
valuespec=_valuespec_special_agents_cucm_chk,
)
)