├── properties.json ├── simulator_snapshot.json ├── plugin.json ├── README.md └── sslcertcheck_plugin.py /properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "days_event_info": 7, 3 | "days_event_resource": 1, 4 | "ports_include": "443;1024-65535", 5 | "ports_exclude": "", 6 | "publish_metadata": true, 7 | "check_interval": 4, 8 | "additional_sni": "" 9 | } -------------------------------------------------------------------------------- /simulator_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "entries": [ 3 | { 4 | "process_type": 1, 5 | "group_name": "Linux system", 6 | "processes": [ 7 | { 8 | "process_name": "python3.5", 9 | "properties": { 10 | "CmdLine": "-m plugin_sdk.demo_app", 11 | "WorkDir": "/home/demo", 12 | "ListeningPorts": "8769" 13 | } 14 | } 15 | ] 16 | }, 17 | { 18 | "process_type": 10, 19 | "group_name": "JBoss X", 20 | "processes": [ 21 | { 22 | "process_name": "java", 23 | "properties": { 24 | "CmdLine": "-cp jboss.jar", 25 | "WorkDir": "/home", 26 | "ListeningPorts": [ "9090", "9091" ] 27 | } 28 | } 29 | ] 30 | } 31 | 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom.python.sslcertcheck_plugin", 3 | "version": "1.02", 4 | "type": "python", 5 | "entity": "HOST", 6 | "metricGroup": "ssl", 7 | "processTypeNames": ["LINUX_SYSTEM", "WINDOWS_SYSTEM"], 8 | "source": { 9 | "package": "sslcertcheck_plugin", 10 | "className": "SSLCertCheck_Plugin", 11 | "install_requires": [ "asn1crypto", "pytz" ], 12 | "activation": "Singleton" 13 | }, 14 | "metrics": [ { 15 | "timeseries": { 16 | "key": "certificates_found", 17 | "unit": "Count", 18 | "displayname": "Certificates found" 19 | } 20 | } 21 | ], 22 | "configUI": { 23 | "displayName": "SSL certificate check", 24 | "properties" : [ 25 | { "key" : "days_event_info", "displayName": "Info event (days before expiration)", "displayOrder": 1 }, 26 | { "key" : "days_event_error", "displayName": "Error event (days before expiration)", "displayOrder": 2 }, 27 | { "key" : "ports_include", "displayName": "Port range to include", "displayOrder": 3 }, 28 | { "key" : "ports_exclude", "displayName": "Port range to exclude", "displayOrder": 4 }, 29 | { "key" : "publish_metadata", "displayName": "Show certificate info in metadata", "displayOrder":5 }, 30 | { "key" : "check_interval", "displayName": "Interval between checks (hours)", "displayOrder":6 }, 31 | { "key" : "additional_sni", "displayName": "Additional hostnames to check (SNI)", "displayOrder":7 }, 32 | { "key" : "debug", "displayName": "Debug logging", "displayOrder":8 } 33 | ] 34 | }, 35 | "properties": [ 36 | { 37 | "key" : "days_event_info", 38 | "type" : "Integer", 39 | "defaultValue" : 7 40 | }, 41 | { 42 | "key" : "days_event_error", 43 | "type" : "Integer", 44 | "defaultValue" : 1 45 | }, 46 | { 47 | "key" : "ports_include", 48 | "type" : "String", 49 | "defaultValue" : "443;1024-65535" 50 | }, 51 | { 52 | "key" : "ports_exclude", 53 | "type" : "String", 54 | "defaultValue" : "" 55 | }, 56 | { 57 | "key" : "publish_metadata", 58 | "type" : "Boolean", 59 | "defaultValue" : true 60 | }, 61 | { 62 | "key" : "check_interval", 63 | "type" : "Integer", 64 | "defaultValue" : 4 65 | }, 66 | { 67 | "key" : "additional_sni", 68 | "type" : "String", 69 | "defaultValue" : "" 70 | }, 71 | { 72 | "key" : "debug", 73 | "type" : "Boolean", 74 | "defaultValue" : false 75 | } 76 | ] 77 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynatrace OneAgent SSL certificate check plugin 2 | 3 | This is a Dynatrace OneAgent plugin for checking and verifying SSL/TLS certificate validity for services running on hosts monitored by OneAgent. OneAgent can be deployed in both full-stack and cloud intrastructore mode. This plugin sends informational and error events if server certificate used by a service is about to expire. 4 | 5 | # Features 6 | 7 | - Port scope filters (inclusive / exclusive range) - only services with port numbers in the inclusive range and outside the exclusive range are checked. 8 | - Configurable expiry time information and error event - you can configure when to send events prior to certificate expiration. 9 | - Certificate metadata - adds certificate information to process group instance metadata. ![metadata sample](https://user-images.githubusercontent.com/7961782/74569470-c486e280-4f7a-11ea-86f3-6dfa1fcaf80c.png) 10 | - Support for SNI - you can supply additional FQDNs to be checked 11 | 12 | # Installation 13 | 14 | 1. Download the release zip file from the [releases](releases) page named custom.python.sslcertcheck_plugin.zip. 15 | 2. Upload the zip file to your Dynatrace tenant in Settings > Monitoring > Monitored technologies > Custom plugins and choose Upload plugin. More information is available in [Dynatrace help](https://www.dynatrace.com/support/help/shortlink/plugins-python#upload-your-custom-plugin) 16 | 3. Unzip the zip file on OneAgents into /opt/dynatrace/oneagent/plugin_deployment directory on hosts with OneAgents or to appropriate plug_deployment directory if you have installed the agent into non-default directory. 17 | 4. OneAgents with the plugin deployed will discover certificates within few minutes. Discovery events can be seen in the events area at the host level and process group level. 18 | 19 | # Configuration 20 | 21 | Following options can be set in the tenant: 22 | 23 | | Setting | Description | Default value | 24 | | ------- | ----------- | --------------| 25 | | Info event (days before expiration) | Number of days before an informational event is sent for the process group about certificate expiration. | 7 | 26 | | Error event (days before expiration) | Number of days before an error event is sent for the process group about certificate expiration. | 1 | 27 | | Port range to include | Port range to include in the check, separated by semicolon | 443;1024-65535 | 28 | | Port range to exclude" | Port range to exclude in the check, separated by semicolon | | 29 | | Show certificate info in metadata | Publish certificate info in the process group metadata, works only with recent OneAgents | true | 30 | | Interval between checks (hours) | Interval between checks on each host in hours | 4 | 31 | | Additional hostnames to check (SNI) | Additional hostnames to use when Server Name Indication is used. This allows checking of services using multiple certificates for a single TLS port. | | 32 | | Debug logging | Enable DEBUG logging for the plugin | False | 33 | 34 | # Troubleshooting 35 | 36 | For troubleshooting check OneAgent plugin engine log. 37 | 38 | # Limitations 39 | 40 | - Opened TCP port bindings are retrieved from OneAgent and only local TCP ports are checked. Listening IP address is provided by OneAgent. Currently OneAgent supplies 127.0.0.1 as the listening IP address regardless of the actual TCP port binding. 41 | - Certificate metadata information may not show up correctly in the process group metadata for OneAgent 1.177 - 1.183 -------------------------------------------------------------------------------- /sslcertcheck_plugin.py: -------------------------------------------------------------------------------- 1 | from ruxit.api.base_plugin import BasePlugin 2 | from ruxit.api.snapshot import pgi_name, parse_port_bindings 3 | from ruxit.api.data import PluginProperty, MEAttribute 4 | from ruxit.api.selectors import * 5 | from datetime import datetime, timezone, timedelta 6 | import logging 7 | import threading 8 | import time 9 | import idna 10 | import ssl 11 | import socket 12 | import asn1crypto 13 | import asn1crypto.x509 14 | import re 15 | import pytz 16 | 17 | class SSLCheckResult: 18 | def __init__(self, sni, certificate): 19 | self.sni = sni 20 | self.certificate = certificate 21 | self.discoverEvent = time.time() 22 | 23 | # Check thread 24 | class SSLPortChecker(threading.Thread): 25 | def __init__(self, binding, plugin): 26 | threading.Thread.__init__(self) 27 | self.binding = binding 28 | self.plugin = plugin 29 | 30 | def run(self): 31 | certs = [] 32 | self.plugin.logger.debug("SSLCheck - SSLPortCheckerThread - checking {b}".format(b=self.binding)) 33 | try: 34 | connection = ssl.create_connection(self.binding) 35 | connection.settimeout(3) 36 | context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 37 | sock = context.wrap_socket(connection) 38 | remote_cert=asn1crypto.x509.Certificate.load(sock.getpeercert(True)) 39 | certs.append(SSLCheckResult(sni="", certificate=remote_cert['tbs_certificate'])) 40 | serial=remote_cert['tbs_certificate']['serial_number'].native 41 | # try additional SNI 42 | for sni in self.plugin.additional_sni: 43 | self.plugin.logger.debug("SSLCheck - SSLPortCheckerThread - checking {b} SNI {s}".format(b=self.binding, s=sni)) 44 | connection = ssl.create_connection(self.binding) 45 | connection.settimeout(3) 46 | context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 47 | sock = context.wrap_socket(connection, server_hostname=sni) 48 | remote_cert = asn1crypto.x509.Certificate.load(sock.getpeercert(True)) 49 | # Only add certificate if differs from main certificate 50 | if (remote_cert['tbs_certificate']['serial_number'].native!=serial): 51 | certs.append(SSLCheckResult(sni=sni, certificate=remote_cert['tbs_certificate'])) 52 | except: 53 | self.plugin.logger.debug("SSLCheck - SSLPortCheckerThread - checking {b} - error/timed out".format(b=self.binding)) 54 | self.plugin.logger.debug("SSLCheck - SSLPortCheckerThread - finished checking {b}".format(b=self.binding)) 55 | self.plugin.sslinfo[self.binding] = certs 56 | 57 | # Main Plugin Class 58 | class SSLCertCheck_Plugin(BasePlugin): 59 | checkBindings=[] 60 | sslinfo={} 61 | lastCheck=0 62 | 63 | firstRun = True 64 | 65 | LOCAL_TIMEZONE = datetime.now(timezone.utc).astimezone().tzinfo 66 | 67 | # Parse range string 68 | def parseRanges(self, rangeString: str): 69 | ranges = [] 70 | for range in rangeString.split(";"): 71 | range_re = re.search("(\d+)\s*-\s*(\d+)", range) 72 | if (range_re): 73 | ranges.append([ int(range_re.group(1)), int(range_re.group(2))]) 74 | else: 75 | range_re = re.search("(\d+)", range) 76 | if (range_re): 77 | ranges.append([ int(range_re.group(1)), int(range_re.group(1))]) 78 | return ranges 79 | 80 | def portInCheckRanges(self, port: int): 81 | isInRange = False 82 | for in_range in self.inclusivePortRange: 83 | if (in_range[0] <= port <= in_range[1]): 84 | isInRange = True 85 | for in_range in self.exclusivePortRange: 86 | if (in_range[0] <= port <= in_range[1]): 87 | isInRange = False 88 | return isInRange 89 | 90 | # Discovers TCP listen ports to check 91 | def discoverPorts(self): 92 | discoveredBindings=[] 93 | # Iteration across all procss groups 94 | pgi_list = self.find_all_process_groups( lambda entry: entry.group_name.startswith("") ) 95 | for pgi in pgi_list: 96 | pgi_id = pgi.group_instance_id 97 | # Iteration across all processes in the process group 98 | for proc in pgi.processes: 99 | port_bindings = parse_port_bindings(pgi) 100 | # Check all bindings 101 | for binding in port_bindings: 102 | if (self.portInCheckRanges(binding[1])): 103 | # Skipping OneAgent ports itself for sanity 104 | if (pgi.group_name!="OneAgent system monitoring"): 105 | self.logger.debug("SSLCheck - Port binding {binding} for process group {pg}".format(binding=binding, pg=pgi.group_name)) 106 | discoveredBindings.append(binding) 107 | if (set(self.checkBindings) != set(discoveredBindings)): 108 | self.logger.debug("SSLCheck - Discovered ports do not match previously discovered ports, forcing recheck") 109 | self.lastCheck=0 110 | else: 111 | self.logger.debug("SSLCheckDiscovered ports match previously discovered ports.") 112 | self.checkBindings=discoveredBindings 113 | 114 | # Trigger check threads 115 | def checkPorts(self): 116 | self.lastCheck = time.time() 117 | self.logger.info("SSLCheck - starting check for ports") 118 | for b in self.checkBindings: 119 | t = SSLPortChecker(b,self) 120 | t.start() 121 | 122 | def dtEventCertProperties(self, certificate:asn1crypto.x509.TbsCertificate, hostPort:str=None): 123 | properties={} 124 | for cert_prop in ["Subject","Issuer", "Validity"]: 125 | for k,v in certificate[cert_prop.lower()].native.items(): 126 | if isinstance(v, datetime): 127 | properties["{prop} {attr}".format(prop=cert_prop, attr=k)]=v.astimezone(self.LOCAL_TIMEZONE).isoformat() 128 | else: 129 | properties["{prop} {attr}".format(prop=cert_prop, attr=k)]=v 130 | if hostPort: 131 | properties["Certificate found at"]=hostPort 132 | return properties 133 | 134 | def initialize(self, **kwargs): 135 | self.logger.debug("SSLCheck - Initializing") 136 | self.inclusivePortRange=self.parseRanges(self.config["ports_include"]) 137 | self.exclusivePortRange=self.parseRanges(self.config["ports_exclude"]) 138 | if self.config["additional_sni"]: 139 | self.additional_sni = re.split('[ ;,]+',self.config["additional_sni"]) 140 | else: 141 | self.additional_sni = [] 142 | self.discoverPorts() 143 | self.checkPorts() 144 | 145 | def query(self, **kwargs): 146 | # Initializes DEBUG logging in first run or when debug setting is true 147 | if self.config['debug'] or self.firstRun: 148 | self.logger.setLevel(logging.DEBUG) 149 | self.firstRun = False 150 | else: 151 | self.logger.info("Setting log level to WARNING (Debug is %s)", self.config['debug']) 152 | self.logger.setLevel(logging.WARNING) 153 | 154 | self.logger.debug("SSLCheck - time {t} lastcheck {l}".format(t=time.time(), l=self.lastCheck)) 155 | self.discoverPorts() 156 | if (time.time() > (self.lastCheck + self.config["check_interval"]*3600) ): 157 | self.logger.debug("SSLCheck - check interval due") 158 | self.checkPorts() 159 | 160 | # publish results 161 | certcount = 0 162 | for binding in self.sslinfo: 163 | for check_result in self.sslinfo[binding]: 164 | cert = check_result.certificate 165 | entity=ListenPortSelector(port_number=binding[1]) 166 | host = binding[0] 167 | port = binding[1] 168 | sni = sni=check_result.sni 169 | if (sni==""): 170 | hps="{h}:{p}".format(h=host, p=port) 171 | else: 172 | hps="{h}:{p}/{sni}".format(h=host, p=port, sni=sni) 173 | self.logger.info("SSLCheck result {hps} subject CN {sub} notvalidbefore {nvb} novalidafter {nva}".format(hps=hps, 174 | sub=cert['subject'].native['common_name'], 175 | nvb=cert['validity']['not_before'].native, 176 | nva=cert['validity']['not_after'].native)) 177 | certcount=certcount+1 178 | 179 | if (check_result.discoverEvent > self.lastCheck): 180 | check_result.discoverEvent = 0 181 | self.results_builder.report_custom_info_event( 182 | description="Certificate with CN:{sub} published on {hps} discovered".format(sub=cert['subject'].native['common_name'], hps=hps), 183 | title="Certificate discovered", 184 | entity_selector=entity, 185 | properties=self.dtEventCertProperties(cert, hps)) 186 | if (cert['validity']['not_after'].native < datetime.now(timezone.utc) + timedelta(days=self.config['days_event_error'])): 187 | # sending error event 188 | self.results_builder.report_error_event( 189 | description="Certificate expiring in less than {expiring} days".format(expiring=self.config['days_event_info']), 190 | title="Certificate due to expire", 191 | entity_selector=entity, 192 | properties=self.dtEventCertProperties(cert, hps)) 193 | elif (cert['validity']['not_after'].native < datetime.now(timezone.utc) + timedelta(days=self.config['days_event_info'])): 194 | # sending info event 195 | self.results_builder.report_custom_info_event( 196 | description="Certificate expiring in less than {expiring} days".format(expiring=self.config['days_event_info']), 197 | title="Certificate expiration warning", 198 | entity_selector=entity, 199 | properties=self.dtEventCertProperties(cert, hps)) 200 | 201 | if (self.config["publish_metadata"]==True): 202 | # Send certificate metadata to process 203 | self.logger.info("SSLCheck metadata sent for {hps} on subject CN {sub}".format(hps=hps, 204 | sub=cert['subject'].native['common_name'])) 205 | self.results_builder.add_property(PluginProperty(me_attribute=MEAttribute.CUSTOM_PG_METADATA, 206 | entity_selector=entity, 207 | key="Certificate [{hps}, {cn}]".format( 208 | hps=hps, cn=cert["subject"].native["common_name"]), 209 | value="Valid from:{nvb} to {nva} issued by {issuer}".format( 210 | nvb=cert['validity']['not_before'].native.isoformat( 211 | ), 212 | nva=cert['validity']['not_after'].native.isoformat( 213 | ), 214 | issuer=cert["issuer"].native["common_name"]))) --------------------------------------------------------------------------------