Compare commits

...

14 Commits

Author SHA1 Message Date
ffafe3c4f6 Reduced connect() timeout to speed up scan, and percolate NAPI connection errors up to CheckMK's GUI. 2025-05-20 19:30:12 +02:00
ebe9b389ce Remove a couple stale files remaining from renames. 2025-05-20 17:55:42 +02:00
9e0e13a636 Improvements as requested by George:
* Switch wedge agent from reporting CN UUIDs to CN hostnames, and use CheckMK's piggyback mechanism to send wedge status to the correct Host in CheckMK.
* Improve how local ports are selected during connect() attempts, so there's (much) less likely to be conflicts on subsequent runs, due to length of ephemeral port expiry.
* Increase connect() time from 100ms to 1000ms, to deal better with potentially slow/overloaded VMs.
2025-05-20 17:01:18 +02:00
0358e8b2e8 cisco-ip-sla for CheckMK 2.3.0 looks like it was never committed. Here we go! 2025-05-19 10:03:34 +02:00
cb3b6b4052 Update cisco-ip-sla/local/share/check_mk/checks/cisco_ip_sla
matching also nxos
2025-05-15 14:39:32 +03:00
99f45848c2 SCO-241: Update domain_checks plugin to use 2.3.0 CheckMK plugin API (since 2.4.0 requires this API). Also merge discovery and check GUIs into single pages. 2025-05-12 21:16:14 +02:00
34a51e5c79 SCO-242 - update triton_wedge plugin to CheckMK's v2 API (optional in CheckMK 2.3.0, but mandatory in 2.4.0). 2025-05-11 18:49:38 +02:00
133224ed27 Renamed a directory I missed in the prior commit. 2025-05-09 09:53:14 +02:00
9455ee8602 Rename directories to something more consistent, and less annoying when autocompleting on the CLI. 2025-05-07 18:11:18 +02:00
8d31d8c54a Add updated mkp file for domain_checks. 2025-04-13 19:15:26 +02:00
1cba7d19e7 Add two extra includes. For some reason this plugin causes GUI exceptions in prod, even though none of the other plugin GUIs do. 2025-04-13 15:15:13 +02:00
a37a8c33b1 Add plugin that performs various checks on domain names. 2025-04-09 18:50:46 +02:00
de64c02488 Add CUCM plugin, since it was lying around in another repo. 2025-04-01 20:10:05 +02:00
0833ae7a16 Add Triton Wedge detector. 2025-04-01 20:07:06 +02:00
233 changed files with 2265 additions and 4 deletions

2
.gitmodules vendored
View File

@ -2,5 +2,5 @@
path = juniper path = juniper
url = https://github.com/spearheadsys/check_mk.git url = https://github.com/spearheadsys/check_mk.git
[submodule "check_mk-check-selinux"] [submodule "check_mk-check-selinux"]
path = check_mk-check-selinux path = selinux
url = https://code.spearhead.cloud/Spearhead/check_mk-check-selinux.git url = https://code.spearhead.cloud/Spearhead/check_mk-check-selinux.git

View File

@ -161,7 +161,7 @@ def check_cisco_ip_sla(_item, params, data):
if state: if state:
infotext += " (warn/crit at %s/%s)" % (warn, crit) infotext += " (warn/crit at %s/%s)" % (warn, crit)
if unit == "ms/us": if unit == "ms" or unit == "us" or unit == "ms/us":
factor = 1e3 if unit == "ms" else 1e6 factor = 1e3 if unit == "ms" else 1e6
perfdata = [ perfdata = [
("rtt", value / factor, warn / factor, crit / factor) ("rtt", value / factor, warn / factor, crit / factor)
@ -183,8 +183,8 @@ check_info["cisco_ip_sla"] = {
"default_levels_variable": "cisco_ip_sla_default_levels", "default_levels_variable": "cisco_ip_sla_default_levels",
"has_perfdata": True, "has_perfdata": True,
"snmp_scan_function": lambda oid: "cisco" in oid(".1.3.6.1.2.1.1.1.0").lower() "snmp_scan_function": lambda oid: "cisco" in oid(".1.3.6.1.2.1.1.1.0").lower()
and "ios" in oid(".1.3.6.1.2.1.1.1.0").lower() and "os" in oid(".1.3.6.1.2.1.1.1.0").lower()
and oid(".1.3.6.1.4.1.9.9.42.1.2.2.1.37.*"), # and oid(".1.3.6.1.4.1.9.9.42.1.2.2.1.37.*"),
"snmp_info": [ "snmp_info": [
( (
".1.3.6.1.4.1.9.9.42.1.2.2.1", ".1.3.6.1.4.1.9.9.42.1.2.2.1",

Binary file not shown.

View File

@ -0,0 +1,208 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (C) 2019 Checkmk GmbH - License: GNU General Public License v2
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
# conditions defined in the file COPYING, which is part of this source code package.
from cmk.gui.i18n import _
from cmk.gui.plugins.wato.utils import (
CheckParameterRulespecWithItem,
rulespec_registry,
RulespecGroupCheckParametersNetworking,
)
from cmk.gui.valuespec import Dictionary, DropdownChoice, Integer, TextInput, Tuple
def _item_spec_cisco_ip_sla():
return TextInput(
title=_("RTT row index of the service"),
allow_empty=True,
)
def _parameter_valuespec_cisco_ip_sla():
return Dictionary(
elements=[
(
"rtt_type",
DropdownChoice(
title=_("RTT type"),
choices=[
("echo", _("echo")),
("path echo", _("path echo")),
("file IO", _("file IO")),
("UDP echo", _("UDP echo")),
("TCP connect", _("TCP connect")),
("HTTP", _("HTTP")),
("DNS", _("DNS")),
("jitter", _("jitter")),
("DLSw", _("DLSw")),
("DHCP", _("DHCP")),
("FTP", _("FTP")),
("VoIP", _("VoIP")),
("RTP", _("RTP")),
("LSP group", _("LSP group")),
("ICMP jitter", _("ICMP jitter")),
("LSP ping", _("LSP ping")),
("LSP trace", _("LSP trace")),
("ethernet ping", _("ethernet ping")),
("ethernet jitter", _("ethernet jitter")),
("LSP ping pseudowire", _("LSP ping pseudowire")),
],
default_value="echo",
),
),
(
"threshold",
Integer(
title=_("Treshold"),
help=_(
"Depending on the precision the unit can be "
"either milliseconds or micoseconds."
),
unit=_("ms/us"),
minvalue=1,
default_value=5000,
),
),
(
"state",
DropdownChoice(
title=_("State"),
choices=[
("active", _("active")),
("inactive", _("inactive")),
("reset", _("reset")),
("orderly stop", _("orderly stop")),
("immediate stop", _("immediate stop")),
("pending", _("pending")),
("restart", _("restart")),
],
default_value="active",
),
),
(
"connection_lost_occured",
DropdownChoice(
title=_("Connection lost occured"),
choices=[
("yes", _("yes")),
("no", _("no")),
],
default_value="no",
),
),
(
"timeout_occured",
DropdownChoice(
title=_("Timeout occured"),
choices=[
("yes", _("yes")),
("no", _("no")),
],
default_value="no",
),
),
(
"completion_time_over_treshold_occured",
DropdownChoice(
title=_("Completion time over treshold occured"),
choices=[
("yes", _("yes")),
("no", _("no")),
],
default_value="no",
),
),
(
"latest_rtt_completion_time",
Tuple(
title=_("Latest RTT completion time"),
help=_(
"Depending on the precision the unit can be "
"either milliseconds or micoseconds."
),
elements=[
Integer(
title=_("Warning at"),
unit=_("ms/us"),
minvalue=1,
default_value=100,
),
Integer(
title=_("Critical at"),
unit=_("ms/us"),
minvalue=1,
default_value=200,
),
],
),
),
(
"latest_rtt_state",
DropdownChoice(
title=_("Latest RTT state"),
choices=[
("ok", _("OK")),
("disconnected", _("disconnected")),
("over treshold", _("over treshold")),
("timeout", _("timeout")),
("other", _("other")),
],
default_value="ok",
),
),
(
"packets_lost_src->dest",
Tuple(
title=_("Packets lost src->dest"),
elements=[
Integer(
title=_("Warning at"),
unit=_("packets"),
minvalue=1,
default_value=100,
),
Integer(
title=_("Critical at"),
unit=_("packets"),
minvalue=1,
default_value=1000,
),
],
),
),
(
"packets_lost_dest->src",
Tuple(
title=_("Packets lost dest->src"),
elements=[
Integer(
title=_("Warning at"),
unit=_("packets"),
minvalue=1,
default_value=100,
),
Integer(
title=_("Critical at"),
unit=_("packets"),
minvalue=1,
default_value=1000,
),
],
),
),
],
)
rulespec_registry.register(
CheckParameterRulespecWithItem(
check_group_name="cisco_ip_sla",
group=RulespecGroupCheckParametersNetworking,
item_spec=_item_spec_cisco_ip_sla,
match_type="dict",
parameter_valuespec=_parameter_valuespec_cisco_ip_sla,
title=lambda: _("Cisco IP SLA"),
)
)

View File

@ -0,0 +1,225 @@
#!/usr/bin/env python3
# Copyright (C) 2019 Checkmk GmbH - License: GNU General Public License v2
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
# conditions defined in the file COPYING, which is part of this source code package.
from collections.abc import Callable, Sequence
from cmk.base.check_api import LegacyCheckDefinition
from cmk.base.config import check_info
from cmk.agent_based.v2 import all_of, contains, exists, OIDBytes, OIDEnd, SNMPTree
def parse_cisco_ip_sla(string_table):
precisions = {line[0]: "ms" if line[-1] == "1" else "us" for line in string_table[0]}
rtt_types = {
"1": "echo",
"2": "path echo",
"3": "file IO",
"4": "script",
"5": "UDP echo",
"6": "TCP connect",
"7": "HTTP",
"8": "DNS",
"9": "jitter",
"10": "DLSw",
"11": "DHCP",
"12": "FTP",
"13": "VoIP",
"14": "RTP",
"15": "LSP group",
"16": "ICMP jitter",
"17": "LSP ping",
"18": "LSP trace",
"19": "ethernet ping",
"20": "ethernet jitter",
"21": "LSP ping pseudowire",
}
states = {
"1": "reset",
"2": "orderly stop",
"3": "immediate stop",
"4": "pending",
"5": "inactive",
"6": "active",
"7": "restart",
}
rtt_states = {
"0": "other",
"1": "ok",
"2": "disconnected",
"3": "over threshold",
"4": "timeout",
"5": "busy",
"6": "not connected",
"7": "dropped",
"8": "sequence error",
"9": "verify error",
"10": "application specific error",
}
def to_ip_address(int_list):
if len(int_list) == 4:
return "%d.%d.%d.%d" % tuple(int_list)
elif len(int_list) == 6:
return "%d:%d:%d:%d:%d:%d" % tuple(int_list)
return ""
# contains description, parse function, unit and type
contents: Sequence[tuple[tuple[str, Callable | None, str, str | None], ...]] = [
( # rttMonEchoAdminEntry
("Target address", to_ip_address, "", None),
("Source address", to_ip_address, "", None),
# rttMonEchoAdminPrecision is deliberatly dropped by zip below
),
( # rttMonCtrlAdminEntry
("Owner", None, "", None),
("Tag", None, "", None),
("RTT type", lambda x: rtt_types.get(x, "unknown"), "", "option"),
("Threshold", int, "ms", "option"),
),
( # rttMonCtrlOperEntry
("State", lambda x: states.get(x, "unknown"), "", "option"),
("Text", None, "", None),
(
"Connection lost occured",
lambda x: "yes" if x == "1" else "no",
"",
"option",
),
("Timeout occured", lambda x: "yes" if x == "1" else "no", "", "option"),
(
"Completion time over treshold occured",
lambda x: "yes" if x == "1" else "no",
"",
"option",
),
),
( # rttMonLatestRttOperEntry
("Latest RTT completion time", int, "ms/us", "level"),
("Latest RTT state", lambda x: rtt_states.get(x, "unknown"), "", "option"),
),
( # rttMonJitterStatsEntry
("Packets lost src->dest", int, "packets", "level"),
("Packets lost dest->src", int, "packets", "level"),
),
]
parsed: dict[str, list] = {}
for content, entries in zip(contents, string_table):
if not entries:
continue
for entry in entries:
index, values = entry[0], entry[1:]
data = parsed.setdefault(index, [])
for (description, parser, unit, type_), value in zip(content, values):
if parser:
value = parser(value)
if unit == "ms/us":
unit = precisions[index]
data.append((description, value, unit, type_))
return parsed
def inventory_cisco_ip_sla(parsed):
for index in parsed:
yield index, {}
def check_cisco_ip_sla(item, params, parsed):
if not (data := parsed.get(item)):
return
for description, value, unit, type_ in data:
if not value and "packets" not in unit:
continue
state = 0
if unit:
infotext = f"{description}: {value} {unit}"
else:
infotext = f"{description}: {value}"
perfdata = []
param = params.get(description.lower().replace(" ", "_"))
if type_ == "option":
if param and param != value:
state = 1
infotext += " (expected %s)" % param
elif type_ == "level":
warn, crit = param # a default level hat to exist
if value >= crit:
state = 2
elif value >= warn:
state = 1
if state:
infotext += f" (warn/crit at {warn}/{crit})"
# last check ("ms/us") is probably a harmless bug, but keeping
if unit == "ms" or unit == "us" or unit == "ms/us":
factor = 1e3 if unit == "ms" else 1e6
perfdata = [
("rtt", value / factor, warn / factor, crit / factor)
] # fixed: true-division
elif unit == "packets":
perfdata = [
("lost", value, warn, crit)
]
yield state, infotext, perfdata
check_info["cisco_ip_sla"] = LegacyCheckDefinition(
detect=all_of(
contains(".1.3.6.1.2.1.1.1.0", "cisco"),
contains(".1.3.6.1.2.1.1.1.0", "ios"),
exists(".1.3.6.1.4.1.9.9.42.1.2.2.1.37.*"),
),
fetch=[
SNMPTree(
base=".1.3.6.1.4.1.9.9.42.1.2.2.1",
oids=[OIDEnd(), OIDBytes("2"), OIDBytes("6"), "37"],
),
SNMPTree(
base=".1.3.6.1.4.1.9.9.42.1.2.1.1",
oids=[OIDEnd(), "2", "3", "4", "5"],
),
SNMPTree(
base=".1.3.6.1.4.1.9.9.42.1.2.9.1",
oids=[OIDEnd(), "10", "2", "5", "6", "7"],
),
SNMPTree(
base=".1.3.6.1.4.1.9.9.42.1.2.10.1",
oids=[OIDEnd(), "1", "2"],
),
SNMPTree(
base=".1.3.6.1.4.1.9.9.42.1.5.2.1",
# rttMonLatestJitterOperPacketLossSD
# rttMonLatestJitterOperPacketLossDS
oids=[OIDEnd(), "26", "27"],
),
],
parse_function=parse_cisco_ip_sla,
service_name="Cisco IP SLA %s",
discovery_function=inventory_cisco_ip_sla,
check_function=check_cisco_ip_sla,
check_ruleset_name="cisco_ip_sla",
check_default_parameters={
"state": "active",
"connection_lost_occured": "no",
"timeout_occured": "no",
"completion_time_over_treshold_occured": "no",
"latest_rtt_completion_time": (250, 500),
"latest_rtt_state": "ok",
"packets_lost_src->dest": (100, 1000),
"packets_lost_dest->src": (100, 1000),
},
)

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,
)
)

BIN
domains/domain_checks-0.2.0.mkp Executable file

Binary file not shown.

View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
# Copyright (C) 2025 Spearhead Systems SRL
import datetime
import json
from cmk.agent_based.v2 import Result, Service, State, CheckPlugin, AgentSection
# Incoming agent output is JSON of the form:
#
# {"domain": "google.com", "state": "CRIT", "expires": "2028-09-14"}
#
# "domain" and "state" are always present, whereas "expires" is optional
# (e.g. most ccTLDs do not return expiry information). The "state" is one of
# (OK, WARN, CRIT, UNKNOWN).
#
# Return a dictionary which uses domain as key, and the rest of the dictionary
# as a value.
def parse_domains_expiry(string_table):
results = {}
for [line] in string_table:
result = json.loads(line)
results[result["domain"]] = result
return results
def discover_domains_expiry(section):
for domain in section.keys():
yield Service(item=domain)
def check_domains_expiry(item, params, section):
data = section.get(item)
if not data:
yield Result(state=State.WARN, summary="Expiry not found in whois")
return
state = State[data["state"]]
expiry = data.get("expires")
if expiry:
summary = f"Expires on {expiry}"
else:
summary = "No expiry found"
yield Result(state=state, summary=summary)
agent_section_domains_expiry = AgentSection(
name="domains_expiry",
parse_function=parse_domains_expiry
)
check_plugin_domains_expiry = CheckPlugin(
name="domains_expiry",
service_name="Expiry for Domain '%s'",
discovery_function=discover_domains_expiry,
check_function=check_domains_expiry,
check_default_parameters={},
)

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python3
# Copyright (C) 2025 Spearhead Systems SRL
import json
from cmk.agent_based.v2 import Result, Service, State, CheckPlugin, AgentSection
# Incoming agent output is JSON of the form:
#
# {"domain": "yahoo.com", "state": "WARN", "unexpected": ["ns5.yahoo.com"], "missing": ["ns6.yahoo.com"]}
#
# Each JSON row is for a single domain. Each row always has "domain" and "state"
# fields, with optional "unexpected" and "missing" fields. The "state" field is
# one of "OK", "WARN", "CRIT", or "UNKNOWN". The "unexpected" field is a list of
# unwanted nameservers that are present in the dig query result, whereas the
# "missing" field are wanted nameservers that are not present.
#
# Return a dictionary which uses domain as key, and a dictionary of
# state/unexpected/missing.
def parse_domains_nameservers(string_table):
results = {}
for [line] in string_table:
result = json.loads(line)
results[result["domain"]] = result
return results
def discover_domains_nameservers(section):
for domain in section.keys():
yield Service(item=domain)
def check_domains_nameservers(item, params, section):
data = section.get(item)
if not data:
yield Result(state=State.WARN, summary="Nameservers missing")
return
state = State[data["state"]]
unexpected = data.get("unexpected")
missing = data.get("missing")
summary = ""
if unexpected:
summary += f"Unexpected nameserver(s) found: {''.join(unexpected)}. "
if missing:
summary += f"Expected nameserver(s) missing: {''.join(missing)}. "
if not summary:
summary = "All expected nameservers found, none unexpected found"
yield Result(state=state, summary=summary)
agent_section_triton_wedge = AgentSection(
name="domains_nameservers",
parse_function=parse_domains_nameservers
)
check_plugin_domains_nameservers = CheckPlugin(
name="domains_nameservers",
service_name="Nameservers for Domain '%s'",
discovery_function=discover_domains_nameservers,
check_function=check_domains_nameservers,
check_default_parameters={},
)

View File

@ -0,0 +1,42 @@
#!/bin/bash
# Copyright (C) 2025 Spearhead Systems SRL
set -eu
if [[ $# < 3 ]]; then
echo "Usage: ${@: 0:1} <domains> <crit date> <warn date>" 1>&2
echo "Example: ${@: 0:1} google.com yahoo.com 2024-05-07 2024-05-21" 1>&2
exit 1
fi
# Extract from args
domains="${@: 1:$#-2}"
warn="${@: -1:1}"
crit="${@: -2:1}"
echo "<<<domains_expiry:sep(0)>>>"
for domain in $domains; do
echo -n "{\"domain\": \"$domain\", \"state\": \""
# Unfortunately, there's no actual format for whois entries, so this is a
# best-effort based on things seen in the wild. Note that ccTLDs usually
# do not publish expiry dates at all.
expires=$(whois "$domain" | grep 'Expir.*' | head -1 | grep -Eo '[0-9]{4}-[0-9]{2}-[0-9]{2}' || true)
if [[ "$expires" == "" ]]; then
echo -n "UNKNOWN"
elif [[ "$expires" < "$crit" ]]; then
echo -n "CRIT"
elif [[ "$expires" < "$warn" ]]; then
echo -n "WARN"
else
echo -n "OK"
fi
if [[ "$expires" == "" ]]; then
echo "\"}"
else
echo "\", \"expires\": \"$expires\"}"
fi
done

View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
# Copyright (C) 2025 Spearhead Systems SRL
import json
import optparse
import subprocess
print("<<<domains_nameservers:sep(0)>>>")
parser = optparse.OptionParser()
parser.add_option('-d', '--domains', action="append")
parser.add_option('-n', '--nameservers', action="append")
parser.add_option('-a', '--alert', default="WARN")
opts, _ = parser.parse_args()
alert = opts.alert
assert ["OK", "WARN", "CRIT", "UNKNOWN"].count(alert)
for domain_str, nameserver_str in zip(opts.domains, opts.nameservers):
domains = domain_str.split(",")
nameservers = set(nameserver_str.split(","))
for domain in domains:
dig_result = subprocess.run(["dig", "+short", "NS", domain], capture_output=True)
dig_ns = set(dig_result.stdout.decode("utf-8").split('.\n'))
dig_ns.remove('')
result = {
"domain": domain,
"state": "OK",
}
if dig_ns != nameservers:
result["state"] = alert
unexpected = list(dig_ns - nameservers)
missing = list(nameservers - dig_ns)
if unexpected:
result["unexpected"] = unexpected
if missing:
result["missing"] = missing
print(json.dumps(result))

View File

@ -0,0 +1,52 @@
#!/usr/bin/env python3
# Copyright (C) 2025 Spearhead Systems SRL
from cmk.rulesets.v1.form_specs.validators import LengthInRange, NumberInRange
from cmk.rulesets.v1.form_specs import Dictionary, DictElement, List, String, Integer, DefaultValue
from cmk.rulesets.v1.rule_specs import SpecialAgent, Topic, Title, Help
def _formspec():
return Dictionary(
title=Title("Domains Expiry"),
elements={
"domains": DictElement(
required=True,
parameter_form=List(
title=Title("Domain names"),
help_text=Help("List of domain names to check"),
editable_order=False,
custom_validate=(LengthInRange(min_value=1),),
element_template=String(
custom_validate=(LengthInRange(min_value=3),),
),
),
),
"days_warn": DictElement(
required=True,
parameter_form=Integer(
title=Title("Warn if expires within days"),
help_text=Help("If there are fewer days until one of the above domains expires, issue an alert"),
custom_validate=(NumberInRange(min_value=0),),
prefill=DefaultValue(30),
),
),
"days_crit": DictElement(
required=True,
parameter_form=Integer(
title=Title("Crit if expires within days"),
help_text=Help("If there are fewer days until one of the above domains expires, issue an alert"),
custom_validate=(NumberInRange(min_value=0),),
prefill=DefaultValue(7),
),
),
}
)
rule_spec_agent_config_domains_expiry = SpecialAgent(
topic=Topic.NETWORKING,
name="domains_expiry",
title=Title("Domains Expiry"),
parameter_form=_formspec,
)

View File

@ -0,0 +1,70 @@
#!/usr/bin/env python3
# Copyright (C) 2025 Spearhead Systems SRL
from cmk.rulesets.v1.form_specs.validators import LengthInRange
from cmk.rulesets.v1.form_specs import Dictionary, DictElement, SingleChoice, SingleChoiceElement, List, String, DefaultValue
from cmk.rulesets.v1.rule_specs import SpecialAgent, Topic, Title, Help
from cmk.agent_based.v2 import State
def _formspec():
return Dictionary(
title=Title("Domains Nameservers"),
elements={
"domain_nameservers": DictElement(
required=True,
parameter_form=List(
title=Title("Domains to check"),
help_text=Help("List of domain names to check"),
editable_order=False,
custom_validate=(LengthInRange(min_value=1),),
element_template=Dictionary(
elements={
"domains": DictElement(
required=True,
parameter_form=List(
title=Title("Domain names"),
help_text=Help("List of domain names the below nameservers apply to"),
custom_validate=(LengthInRange(min_value=1),),
element_template=String(
custom_validate=(LengthInRange(min_value=3),),
),
),
),
"nameservers": DictElement(
required=True,
parameter_form=List(
title=Title("Nameservers"),
help_text=Help("List of nameservers that the above domain names should have"),
custom_validate=(LengthInRange(min_value=1),),
element_template=String(
custom_validate=(LengthInRange(min_value=3),),
),
),
),
}
)
),
),
"alert_level": DictElement(
required=True,
parameter_form=SingleChoice(
title=Title("Alert level used on mismatch"),
help_text=Help("Alert level used when there is a mismatch in domain name servers for a domain"),
prefill=DefaultValue(State.WARN.name),
elements=[
SingleChoiceElement(name=State.CRIT.name, title=Title(State.CRIT.name)),
SingleChoiceElement(name=State.WARN.name, title=Title(State.WARN.name)),
SingleChoiceElement(name=State.OK.name, title=Title(State.OK.name)),
],
),
),
},
)
rule_spec_agent_config_domains_nameservers = SpecialAgent(
topic=Topic.NETWORKING,
name="domains_nameservers",
title=Title("Domains Nameservers"),
parameter_form=_formspec,
)

View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
# Copyright (C) 2025 Spearhead Systems SRL
from datetime import datetime, timedelta
from cmk.server_side_calls.v1 import noop_parser, SpecialAgentConfig, SpecialAgentCommand
def _agent_arguments(params, host_config):
today = datetime.today()
args = params["domains"]
for key in ["days_crit", "days_warn"]:
date = (today + timedelta(days=params[key])).date()
args.append(str(date))
yield SpecialAgentCommand(command_arguments=args)
special_agent_domains_expiry = SpecialAgentConfig(
name="domains_expiry",
parameter_parser=noop_parser,
commands_function=_agent_arguments,
)

View File

@ -0,0 +1,34 @@
#!/usr/bin/env python3
# Copyright (C) 2025 Spearhead Systems SRL
from cmk.server_side_calls.v1 import noop_parser, SpecialAgentConfig, SpecialAgentCommand
# Although the API makes it seem like we can yield several
# SpecialAgentCommands, the use of a table for _elems in
# lib/python3/cmk/base/sources/_builder.py (at least in 2.3.0p31) means that
# we can only emit one SpecialAgentCommands. This CheckMK bug means that
# SpecialAgentCommands() will be forgotten.
#
# Alas, that means we need to pack everything into a single command invocation.
def _agent_arguments(params, host_config):
args = []
alert_level = params["alert_level"]
for ele in params["domain_nameservers"]:
domains = ele["domains"]
nameservers = ele["nameservers"]
args.append("--domains=" + ",".join(domains))
args.append("--nameservers=" + ",".join(nameservers))
args.append("--alert=" + alert_level)
yield SpecialAgentCommand(command_arguments=args)
special_agent_domains_nameservers = SpecialAgentConfig(
name="domains_nameservers",
parameter_parser=noop_parser,
commands_function=_agent_arguments,
)

Some files were not shown because too many files have changed in this diff Show More