Skip to content
This repository was archived by the owner on Jun 5, 2025. It is now read-only.

Commit 947f594

Browse files
authored
Merge pull request #7 from astrobl1904/develop
Develop
2 parents 141b994 + 703052f commit 947f594

File tree

2 files changed

+104
-66
lines changed

2 files changed

+104
-66
lines changed

Custom Sensors/python/starttls_certificate_sensor.py

Lines changed: 102 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
Monitor certificates of services that require STARTTLS and return a JSON formatted sensor result.
44
55
This 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.
89
The list of protocols is currently limited to `SMTP`, `LMTP`, and `LDAP`.
910
1011
The sensor result in JSON contains the same channels as the `SSL Certificate` sensor with channel
1112
`Days to Expiration` set as primary channel.
1213
1314
Keyword for additional parameters:
1415
port -- 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
1718
cert_domainname -- Common Name as contained in the certificate
1819
cert_domainname_validation -- Type of validation of the cert domain name (default: None)
@@ -33,47 +34,53 @@
3334
from cryptography.hazmat.backends import default_backend
3435
from cryptography.hazmat.primitives import hashes
3536

36-
37-
from prtg.sensor.result import CustomSensorResult
37+
from prtg.sensor.result import CustomSensorResult
3838
from prtg.sensor.units import ValueUnit
3939

4040
class Protocol(Enum):
41+
"""Application Layer protocols
42+
"""
4143
SMTP = 1
4244
LMTP = 2
4345
IMAP = 3
4446
LDAP = 4
4547

4648
class Validation(Enum):
49+
"""Certificate Name validations
50+
"""
4751
NONE = 1
4852
CN = 2
4953
CN_SAN = 3
5054

5155
def 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}\nSTARTTLS\n".format(socket.gethostname()), 'ascii'),
142-
Protocol.LMTP: bytes("LHLO {0}\nSTARTTLS\n".format(socket.gethostname()), 'ascii'),
159+
Protocol.SMTP: bytes(f'EHLO {socket.gethostname()}\nSTARTTLS\n', 'ascii'),
160+
Protocol.LMTP: bytes(f'LHLO {socket.gethostname()}\nSTARTTLS\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

361399
if __name__ == "__main__":

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This repository contains a PRTG Python Script Advanced sensor to monitor SSL Cer
55
## Sensor Summary
66

77
Script Language: Python 3.7+
8-
Version: 0.1.0
8+
Version: 1.0.0
99
Author: Andreas Strobl <astroblx@asgraphics.at>
1010
Verified PRTG Version: 22.2.76.1705
1111
Dependencies: cryptography >=37.0.0, prtg == 1.0.0
@@ -14,7 +14,7 @@ This repository contains a PRTG Python Script Advanced sensor to monitor SSL Cer
1414

1515
This custom _Python Script Advanced_ sensor will monitor SSL certificates that require a protocol handshake prior to reading certificate data, and exposes the collected data in channels similar to PRTG's built-in _SSL Certificate_ sensor.
1616

17-
As of version v0.1.0 this sensor supports the following application layer protocols:
17+
As of version v1.0.0 this sensor supports the following application layer protocols:
1818

1919
* `SMTP`: Simple Mail Transfer Protocol, [RFC 5321](https://www.rfc-editor.org/rfc/rfc5321)
2020
* `LMTP`: Local Mail Transfer Protocol, [RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)

0 commit comments

Comments
 (0)