33Monitor certificates of services that require STARTTLS and return a JSON formatted sensor result.
44
55This custom Python script sensor is used to monitor certificates of services that require STARTTLS
6- to initiate a secure transport. It takes the same parameter as the PRTG built-in sensor `SSL Certificate`
7- but additionally requires the protocol the sensor must use to communicate with the remote endpoint.
6+ to initiate a secure transport. It takes the same parameter as the PRTG built-in sensor
7+ `SSL Certificate` but additionally requires the protocol the sensor must use to communicate with
8+ the remote endpoint.
89The list of protocols is currently limited to `SMTP`, `LMTP`, and `LDAP`.
910
1011The sensor result in JSON contains the same channels as the `SSL Certificate` sensor with channel
1112`Days to Expiration` set as primary channel.
1213
1314Keyword for additional parameters:
1415port -- Port for the connection to the target endpoint (default: 25)
15- protocol -- Protocol used for the connection to the target endpoint (default: smtp)
16+ protocol -- Protocol used with the connection (default: smtp)
1617 Implemented protocols: smtp, lmtp, ldap
1718cert_domainname -- Common Name as contained in the certificate
1819cert_domainname_validation -- Type of validation of the cert domain name (default: None)
3334from cryptography .hazmat .backends import default_backend
3435from cryptography .hazmat .primitives import hashes
3536
36-
37- from prtg .sensor .result import CustomSensorResult
37+ from prtg .sensor .result import CustomSensorResult
3838from prtg .sensor .units import ValueUnit
3939
4040class Protocol (Enum ):
41+ """Application Layer protocols
42+ """
4143 SMTP = 1
4244 LMTP = 2
4345 IMAP = 3
4446 LDAP = 4
4547
4648class Validation (Enum ):
49+ """Certificate Name validations
50+ """
4751 NONE = 1
4852 CN = 2
4953 CN_SAN = 3
5054
5155def prtg_params_dict (params : str ) -> dict :
5256 """
5357 prtg_params_dict - Converts a PRTG params string into a dictionary.
54-
58+
5559 It takes the params string and converts it via json into a dictionary. The solution is based
5660 on Stack Overflow (https://stackoverflow.com/questions/47663809/python-convert-string-to-dict)
5761 """
58-
62+
5963 _params = '{' + params .strip () + '}'
6064 _params_json_string = ''
6165
6266 # Remove surrounding spaces arround separator chars
63- _params_stripped = re .sub (r'\s*([:,])\s*' , '\g<1>' , _params )
67+ _params_stripped = re .sub (r'\s*([:,])\s*' , r '\g<1>' , _params )
6468 _params_json_string = re .sub (r'([:,])' , r'"\g<1>"' , _params_stripped )
6569 _params_json_string = re .sub (r'{' , r'{"' , _params_json_string )
6670 _params_json_string = re .sub (r'}' , r'"}' , _params_json_string )
67-
71+
6872 return json .loads (_params_json_string )
6973
70- def starttls_getpeercert (host : str , port : int , starttls_proto : Protocol , cert_hostname = None , timeout = 3.0 , msglen = 4096 ) -> dict :
74+ def starttls_getpeercert (address ,
75+ starttls_proto : Protocol ,
76+ cert_hostname = None ,
77+ timeout = 3.0 , msglen = 4096 ) -> dict :
7178 """
7279 starttls_getpeercert - Retrieves the certificate of the other side of the connection.
7380
7481 @Returns: Certificate dict with selfSigned? and rootAuthorityTrusted? mixed in.
7582
76- @NOTE: Dictionary keys
83+ @NOTE: Dictionary keys:
7784 subject (string; distinguished name form)
7885 issuer (string; distinguished name form)
7986 version (int)
@@ -91,23 +98,28 @@ def starttls_getpeercert(host: str, port: int, starttls_proto: Protocol, cert_ho
9198 rootAuthorityTrusted? (boolean)
9299
93100 Ref: https://stackoverflow.com/questions/5108681/use-python-to-get-an-smtp-server-certificate
94- REf: https://stackoverflow.com/questions/71114085/how-can-i-retrieve-openldap-servers-starttls-certificate-with-pythons-ssl-libr
101+ REf: https://stackoverflow.com/questions/71114085/how-can-i-retrieve-openldap-servers-starttls-
102+ certificate-with-pythons-ssl-libr
95103 """
96104
97- if cert_hostname == None :
98- sni_hostname = host
105+ if cert_hostname is None :
106+ sni_hostname = address [ 0 ]
99107 else :
100108 sni_hostname = cert_hostname
101-
102- # Request #1: Get certificate data (incl. self-issued certs) with context in verify_mode == CERT_NONE
103- # The SSLContext is created with the class constructor since retrieving self-signed certs require
104- # loosened SSL settings. To fetch self-signed certs verify_mode MUST be set to CERT_NONE which
105- # requires disabling hostname checking.
109+
110+ # Request #1: Get certificate data (incl. self-issued certs) with verify_mode set to CERT_NONE
111+ # in the created context
112+ # The SSLContext is created with the class constructor since retrieving self-signed certs
113+ # require loosened SSL settings. To fetch self-signed certs verify_mode MUST be set to
114+ # CERT_NONE which requires disabling hostname checking.
106115 ctx = ssl .SSLContext (ssl .PROTOCOL_TLS_CLIENT )
107116 ctx .check_hostname = False
108- ctx .verify_mode = ssl .VerifyMode . CERT_NONE
117+ ctx .verify_mode = ssl .CERT_NONE
109118
110- raw_sock = _starttls_do_service_handshake ((host , port ), starttls_proto , timeout = timeout , msglen = msglen )
119+ raw_sock = _starttls_do_service_handshake (address ,
120+ starttls_proto ,
121+ timeout = timeout ,
122+ msglen = msglen )
111123 with ctx .wrap_socket (raw_sock , server_hostname = sni_hostname ) as ssl_sock :
112124 cert_der = ssl_sock .getpeercert (binary_form = True )
113125 cert = x509 .load_der_x509_certificate (cert_der , default_backend ())
@@ -119,27 +131,33 @@ def starttls_getpeercert(host: str, port: int, starttls_proto: Protocol, cert_ho
119131 if cert_dict ['selfSigned?' ]:
120132 cert_dict ['rootAuthorityTrusted?' ] = False
121133 else :
122- ctx_trust_check = ssl .create_default_context ()
123- raw_sock_trust_check = _starttls_do_service_handshake ((host , port ), starttls_proto , timeout = timeout , msglen = msglen )
134+ ctx = ssl .create_default_context ()
135+ raw_sock = _starttls_do_service_handshake (address ,
136+ starttls_proto ,
137+ timeout = timeout ,
138+ msglen = msglen )
124139 try :
125- ssl_sock_trust_check = ctx_trust_check .wrap_socket (raw_sock_trust_check , server_hostname = sni_hostname )
140+ ssl_sock = ctx .wrap_socket (raw_sock , server_hostname = sni_hostname )
126141 cert_dict ['rootAuthorityTrusted?' ] = True
127- ssl_sock_trust_check .close ()
142+ ssl_sock .close ()
128143 except ssl .SSLCertVerificationError :
129144 cert_dict ['rootAuthorityTrusted?' ] = False
130145
131146 return cert_dict
132147
133- def _starttls_do_service_handshake (address , starttls_proto : Protocol , timeout = 3.0 , msglen = 4096 ) -> socket .socket :
148+ def _starttls_do_service_handshake (address ,
149+ starttls_proto : Protocol ,
150+ timeout = 3.0 ,
151+ msglen = 4096 ) -> socket .socket :
134152 """
135153 starttls_do_service_handshake - Perform the service handshake to initiate a TLS connection
136154
137155 @Returns: socket.socket
138156 """
139157 # Protocol.LDAP sends a LDAP_START_TLS_OID - sniffed with Wireshark
140158 protocol_greeters = {
141- Protocol .SMTP : bytes (" EHLO {0} \n STARTTLS \n " . format ( socket .gethostname ()) , 'ascii' ),
142- Protocol .LMTP : bytes (" LHLO {0} \n STARTTLS \n " . format ( socket .gethostname ()) , 'ascii' ),
159+ Protocol .SMTP : bytes (f' EHLO { socket .gethostname ()} \n STARTTLS \n ' , 'ascii' ),
160+ Protocol .LMTP : bytes (f' LHLO { socket .gethostname ()} \n STARTTLS \n ' , 'ascii' ),
143161 Protocol .LDAP : b'\x30 \x1d \x02 \x01 \x01 \x77 \x18 \x80 \x16 \x31 \x2e \x33 \x2e \x36 \x2e \x31 ' \
144162 b'\x2e \x34 \x2e \x31 \x2e \x31 \x34 \x36 \x36 \x2e \x32 \x30 \x30 \x33 \x37 '
145163 }
@@ -164,14 +182,17 @@ def _starttls_do_service_handshake(address, starttls_proto: Protocol, timeout=3.
164182 raw_sock .send (protocol_greeting )
165183 # Look for \x0a\x01 {result code} \x04\x00\x04\x00 - if the second \x04 is not followed
166184 # by \x00 then it seems that the server support STARTTLS but has no cert installed
167- _ldap_tls_ext_response = raw_sock .recv (msglen )
168- _ldap_tls_ext_response_result_pos = _ldap_tls_ext_response .find (b'\x0a \x01 ' )
169- _ldap_tls_ext_response_result_trail = _ldap_tls_ext_response .find (b'\x04 \x00 \x04 \x00 ' , _ldap_tls_ext_response_result_pos )
185+ _ldap_response = raw_sock .recv (msglen )
186+ _ldap_response_result_pos = _ldap_response .find (b'\x0a \x01 ' )
187+ _ldap_response_result_trail = _ldap_response .find (b'\x04 \x00 \x04 \x00 ' ,
188+ _ldap_response_result_pos )
170189
171- if not (_ldap_tls_ext_response_result_trail - _ldap_tls_ext_response_result_pos == 3 and
172- _ldap_tls_ext_response [ _ldap_tls_ext_response_result_pos + 2 ] == 0 ):
190+ if not (_ldap_response_result_trail - _ldap_response_result_pos == 3 and
191+ _ldap_response [ _ldap_response_result_pos + 2 ] == 0 ):
173192 raw_sock .close ()
174- raise OSError ("LDAP Server does not support LDAP_START_TLS_OID or has no certificate installed." )
193+ _err_msg = "LDAP Server does not support LDAP_START_TLS_OID "
194+ _err_msg += "or has no certificate installed."
195+ raise OSError (_err_msg )
175196
176197 return raw_sock
177198
@@ -207,12 +228,13 @@ def _starttls_cert_dict(cert) -> dict:
207228 try :
208229 _extension = cert .extensions .get_extension_for_oid (_extension_oid )
209230 if _dict_key == 'subjectAltName' :
210- cert_dict [_dict_key ] = [ ('DNS' , _dnsname ) for _dnsname in _extension .value .get_values_for_type (x509 .DNSName ) ]
231+ _extension_subject_alt_names = _extension .value .get_values_for_type (x509 .DNSName )
232+ cert_dict [_dict_key ] = [ ('DNS' , _name ) for _name in _extension_subject_alt_names ]
211233 if _dict_key == 'crlDistributionPoints' :
212234 cert_dict [_dict_key ] = [ _crldp .full_name [0 ].value for _crldp in _extension .value ]
213235 except x509 .ExtensionNotFound :
214236 pass
215-
237+
216238 # Cert extension data - Authority Access Info Methods (OCSP, caIssuers)
217239 _extension_oid = x509 .ObjectIdentifier ('1.3.6.1.5.5.7.1.1' )
218240 _authority_access_method_oids = [
@@ -224,23 +246,24 @@ def _starttls_cert_dict(cert) -> dict:
224246 while len (_authority_access_method_oids ) > 0 :
225247 _access_method_oid , _dict_key = _authority_access_method_oids .pop (0 )
226248 for _access_description in _extension .value :
227- try :
228- if _access_description .access_method == _access_method_oid :
229- cert_dict [_dict_key ] = _access_description .access_location .value
230- break
231- except :
232- pass
249+ if _access_description .access_method == _access_method_oid :
250+ cert_dict [_dict_key ] = _access_description .access_location .value
251+ break
233252 except x509 .ExtensionNotFound :
234253 pass
235254
236255 return cert_dict
237256
238- def _prtg_cert_cncheck_result (certificate : dict , validation_mode : Validation , cert_hostname : str ) -> int :
257+ def _prtg_cert_cncheck_result (certificate : dict ,
258+ validation_mode : Validation ,
259+ cert_hostname : str ) -> int :
239260 """
240- prtg_cert_cncheck_result - Returns the proper result value based on the sensor parameter cert_domainname_validation
261+ prtg_cert_cncheck_result - Returns the proper result value based on cert_domainname_validation
241262
242- The return value matches the expected result specified in the default overlay prtg.standardlookups.sslcertificatesensor.cncheck.
243- This script DOES NOT return all values since SNI check and common_name check are considered interchangeable.
263+ The return value matches the expected result specified in the default overlay
264+ prtg.standardlookups.sslcertificatesensor.cncheck.
265+ This script DOES NOT return all values since SNI check and common_name check
266+ are considered interchangeable.
244267
245268 Expected result values defined in overlay:
246269 Value 0: State Ok, Matches device address (Validation mode: cn)
@@ -262,10 +285,10 @@ def _prtg_cert_cncheck_result(certificate: dict, validation_mode: Validation, ce
262285 if cert_hostname .strip ().lower () == certificate ["commonName" ].strip ().lower ():
263286 result_value = 5
264287 if "subjectAltName" in certificate .keys ():
265- _dns_names = [altname_tuple [1 ] for altname_tuple in certificate ["subjectAltName" ] if altname_tuple [0 ] == "DNS" ]
266- if cert_hostname .strip ().lower () in _dns_names :
288+ _names = [_tuple [1 ] for _tuple in certificate ["subjectAltName" ] if _tuple [0 ] == "DNS" ]
289+ if cert_hostname .strip ().lower () in _names :
267290 result_value = 5
268- # If after all checks result_value is still 2 (disabled) - correct it to the propper check error value
291+ # If after all checks result_value is still 2 (disabled) - correct it to the error value
269292 if result_value == 2 :
270293 result_value = 6
271294
@@ -280,17 +303,23 @@ def main():
280303 in order to start a secure connection.
281304 """
282305 try :
306+ # Basic argument check
307+ if len (sys .argv ) < 2 :
308+ raise TypeError ("JSON object argument missing." )
283309 data = json .loads (sys .argv [1 ])
310+
311+ if "params" not in data .keys ():
312+ raise TypeError ("Key 'params' missing in JSON object." )
284313 params = prtg_params_dict (data ["params" ])
285- _now = datetime .datetime .now ()
286314
287- cert = starttls_getpeercert (
288- data ["host" ],
289- int (params .get ("port" , "25" )),
290- Protocol [params .get ("protocol" , "smtp" ).upper ()],
291- cert_hostname = params .get ("cert_domainname" , data ["host" ]))
315+ _now = datetime .datetime .now ()
316+ cert = starttls_getpeercert ((data ["host" ], int (params .get ("port" , "25" ))),
317+ Protocol [params .get ("protocol" , "smtp" ).upper ()],
318+ cert_hostname = params .get ("cert_domainname" , data ["host" ]))
292319
293- csr_text_ok = "OK. Certificate Common Name: {} - Certificate Thumbprint: {} - STARTTLS Protocol: {}"
320+ csr_text_ok = "OK. Certificate Common Name: {} - "
321+ csr_text_ok += "Certificate Thumbprint: {} - "
322+ csr_text_ok += "STARTTLS Protocol: {}"
294323 csr = CustomSensorResult (text = csr_text_ok .format (cert ['commonName' ],
295324 cert ['fingerprint' ],
296325 params .get ('protocol' , "smtp" ).upper ()))
@@ -307,9 +336,9 @@ def main():
307336 limit_warning_msg = "Certificate will expire soon." )
308337
309338 # Channel _Common Name Check_
310- _prtg_cncheck_value = _prtg_cert_cncheck_result ( cert ,
311- Validation [ params .get ("cert_domainname_validation " , "NONE" ). upper ()],
312- params . get ( "cert_domainname" , data [ "host" ]) )
339+ _validation = Validation [ params . get ( "cert_domainname_validation" , "NONE" ). upper ()]
340+ _sni_domainname = params .get ("cert_domainname " , data [ "host" ])
341+ _prtg_cncheck_value = _prtg_cert_cncheck_result ( cert , _validation , _sni_domainname )
313342 csr .add_channel (name = "Common Name Check" ,
314343 unit = ValueUnit .CUSTOM ,
315344 value_lookup = 'prtg.standardlookups.sslcertificatesensor.cncheck' ,
@@ -350,12 +379,21 @@ def main():
350379 is_float = False ,
351380 is_limit_mode = False )
352381
353- # Print sensor JSON result
354- print ( csr . json_result )
355-
356- except Exception as e :
382+ except json . JSONDecodeError as sensor_error :
383+ csr = CustomSensorResult ( text = "Python Script execution error" )
384+ csr . error = f"Failed to decode 'params' into valid JSON object: { str ( sensor_error ) } "
385+ except KeyError as sensor_error :
357386 csr = CustomSensorResult (text = "Python Script execution error" )
358- csr .error = "Python Script execution error: {}" .format (str (e ))
387+ csr .error = f"Invalid key in 'protocol'|'cert_domainname_validation': { str (sensor_error )} "
388+ except ssl .SSLEOFError as sensor_error :
389+ csr = CustomSensorResult (text = "Python Script execution error" )
390+ csr .error = f"Protocol handshake prior to STARTTLS possibly failed': { str (sensor_error )} "
391+ except (TypeError , OSError , RuntimeError ) as sensor_error :
392+ csr = CustomSensorResult (text = "Python Script execution error" )
393+ csr .error = f"Python Script runtime error: { str (sensor_error )} "
394+
395+ finally :
396+ # Print sensor JSON result
359397 print (csr .json_result )
360398
361399if __name__ == "__main__" :
0 commit comments