diff --git a/README.rst b/README.rst index 1bdfd7a..fae7635 100644 --- a/README.rst +++ b/README.rst @@ -36,17 +36,23 @@ Usage secure={cafile='/path/to/ca/file'}) logger.addHandler(handler2) - # with custom SSLContext - context = ssl.create_default_context(cafile='/path/to/ca/file') + # with custom SSLContext (e.g. for mutual TLS authentication) + context = ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, cafile="/path/to/ca/file" + ) + context.load_cert_chain( + certfile="/path/to/client/cert.pem", + keyfile="/path/to/client/priv.key", + ) handler3 = TLSSysLogHandler(address=('secure-logging.example.com', 6514), socktype=socket.SOCK_STREAM, secure=context) logger.addHandler(handler3) - # or allow TLS without verification + # or allow TLS without verification (not recommended) handler4 = TLSSysLogHandler(address=('secure-logging.example.com', 6514), socktype=socket.SOCK_STREAM, secure="noverify") logger.addHandler(handler4) - logger.info('Hello World!') + logger.info('Hello, World!') diff --git a/tests/test_regress_basic.py b/tests/test_regress_basic.py index 5cf7c2c..02d7a48 100644 --- a/tests/test_regress_basic.py +++ b/tests/test_regress_basic.py @@ -1,48 +1,20 @@ -import datetime -import ipaddress -import multiprocessing -import concurrent.futures import os -import tempfile from time import sleep -from unittest import TestCase, mock +from unittest import mock import uuid -from cryptography import x509 -from cryptography.x509.oid import NameOID -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives import hashes, serialization - import ssl -import logging import socket -import inspect from tlssysloghandler import TLSSysLogHandler -SOCKET_PORT = int(os.environ.get("SOCKET_PORT", 56712)) +from test_util import SOCKET_PORT, TestCertManager + SOCKET_TIMEOUT = 5 SOCKET_BUFFERSIZE = 1024 -RSA_PUBLIC_EXPONENT = 65537 -RSA_KEY_SIZE = 2048 - -# logger = logging.getLogger(__name__) - - -class TestTLSSysLogHandlerE2E(TestCase): - def setUp(self): - self.tmpdir = tempfile.TemporaryDirectory() - self.queue = multiprocessing.Queue(maxsize=1) - self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=2) - self._generate_keys() - - def tearDown(self): - self.executor.shutdown(wait=True) - self.queue.close() - self.tmpdir.cleanup() +class TestTLSSysLogHandlerE2E(TestCertManager): def _start_server_worker(self, sock_family, sock_type, sock_addr, secure): if sock_type != socket.SOCK_DGRAM and sock_type != socket.SOCK_STREAM: raise ValueError( @@ -96,105 +68,6 @@ def _start_server(self, sock_family, sock_type, sock_addr, secure=False): ) sleep(4) - # https://gist.github.com/bloodearnest/9017111a313777b9cce5 - # Copyright 2018 Simon Davy - # - # Permission is hereby granted, free of charge, to any person obtaining a copy - # of this software and associated documentation files (the "Software"), to deal - # in the Software without restriction, including without limitation the rights - # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - # copies of the Software, and to permit persons to whom the Software is - # furnished to do so, subject to the following conditions: - # - # The above copyright notice and this permission notice shall be included in - # all copies or substantial portions of the Software. - # - # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - # SOFTWARE. - def _generate_selfsigned_cert(self, hostname, ip_addresses=None, key=None): - """Generates self signed certificate for a hostname, and optional IP addresses.""" - # Generate our key - if key is None: - key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - backend=default_backend(), - ) - - name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) - - # best practice seem to be to include the hostname in the SAN, which *SHOULD* mean COMMON_NAME is ignored. - alt_names = [x509.DNSName(hostname)] - - # allow addressing by IP, for when you don't have real DNS (common in most testing scenarios - if ip_addresses: - for addr in ip_addresses: - # openssl wants DNSnames for ips... - alt_names.append(x509.DNSName(addr)) - # ... whereas golang's crypto/tls is stricter, and needs IPAddresses - # note: older versions of cryptography do not understand ip_address objects - alt_names.append(x509.IPAddress(ipaddress.ip_address(addr))) - - san = x509.SubjectAlternativeName(alt_names) - - # path_len=0 means this cert can only sign itself, not other certs. - basic_contraints = x509.BasicConstraints(ca=True, path_length=0) - now = datetime.datetime.now() - cert = ( - x509.CertificateBuilder() - .subject_name(name) - .issuer_name(name) - .public_key(key.public_key()) - .serial_number(1000) - .not_valid_before(now) - .not_valid_after(now + datetime.timedelta(days=10 * 365)) - .add_extension(basic_contraints, False) - .add_extension(san, False) - .sign(key, hashes.SHA256(), default_backend()) - ) - cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM) - key_pem = key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - - return cert_pem, key_pem - - def _generate_keys(self): - pub_key_bytes, priv_key_bytes = self._generate_selfsigned_cert( - "localhost", ["::1", "127.0.0.1"] - ) - - pub_key_path = os.path.join(self.tmpdir.name, "syslog.pub") - priv_key_path = os.path.join(self.tmpdir.name, "syslog.key") - - with open(pub_key_path, "wb") as f: - f.write(pub_key_bytes) - - with open(priv_key_path, "wb") as f: - f.write(priv_key_bytes) - - self.priv_key = priv_key_path - self.pub_key = pub_key_path - - def _build_logger(self) -> logging.Logger: - stack = inspect.stack() - logger_name = "{}.{}".format(__name__, stack[1][3]) - - test_logger = logging.getLogger(logger_name) - test_logger.setLevel(logging.DEBUG) - - for handler in test_logger.handlers: - test_logger.removeHandler(handler) - - return test_logger - def test_e2e_unix_DGRAM(self): self.socket_path = os.path.join(self.tmpdir.name, "syslog.sock") self._start_server(socket.AF_UNIX, socket.SOCK_DGRAM, (self.socket_path,)) diff --git a/tests/test_regress_syslogng.py b/tests/test_regress_syslogng.py new file mode 100644 index 0000000..0ebe187 --- /dev/null +++ b/tests/test_regress_syslogng.py @@ -0,0 +1,439 @@ +import os +import socket +import ssl +import subprocess +from time import sleep +import unittest +import uuid + +from tlssysloghandler import TLSSysLogHandler + +from test_util import SOCKET_PORT, TestCertManager + +SOCKET_PORT4_DGRAM = SOCKET_PORT +SOCKET_PORT4_STREAM = SOCKET_PORT + 1 +SOCKET_PORT4_TLS = SOCKET_PORT + 2 +SOCKET_PORT6_DGRAM = SOCKET_PORT + 3 +SOCKET_PORT6_STREAM = SOCKET_PORT + 4 +SOCKET_PORT6_TLS = SOCKET_PORT + 5 +SOCKET_PORT4_MUTUAL_TLS = SOCKET_PORT + 6 +SOCKET_PORT6_MUTUAL_TLS = SOCKET_PORT + 7 + + +# check if syslog-ng is installed else skip tests +try: + subprocess.check_output(["syslog-ng", "--version"]) +except FileNotFoundError: + raise unittest.SkipTest("syslog-ng not installed") + + +class TestSyslogNG(TestCertManager): + def _start_server(self): + # create syslog-ng tls config + config = """ +@version: 4.4 +@include "scl.conf" + +source unix_dgram {{ + unix-dgram("{0}/syslog-dgram.sock"); +}}; +source unix_stream {{ + unix-stream("{0}/syslog-stream.sock"); +}}; +source net4_dgram {{ + network( + ip("127.0.0.1") + transport("udp") + port({1}) + ); +}}; +source net4_stream {{ + network( + ip("127.0.0.1") + transport("tcp") + port({2}) + ); +}}; +source net4_tls {{ + network( + ip("127.0.0.1") + transport("tls") + port({3}) + tls( + key-file("{0}/syslog.key") + cert-file("{0}/syslog.pub") + peer-verify(optional-untrusted) + ) + ); +}}; +source net6_dgram {{ + network( + ip("::1") + ip-protocol(6) + transport("udp") + port({4}) + ); +}}; +source net6_stream {{ + network( + ip("::1") + ip-protocol(6) + transport("tcp") + port({5}) + ); +}}; +source net6_tls {{ + network( + ip("::1") + ip-protocol(6) + transport("tls") + port({6}) + tls( + key-file("{0}/syslog.key") + cert-file("{0}/syslog.pub") + peer-verify(optional-untrusted) + ) + ); +}}; +source net4_mutual_tls {{ + network( + ip("127.0.0.1") + transport("tls") + port({7}) + tls( + key-file("{0}/syslog.key") + cert-file("{0}/syslog.pub") + ca-file("{0}/syslog.pub") + peer-verify(required-trusted) + ) + ); +}}; +source net6_mutual_tls {{ + network( + ip("::1") + ip-protocol(6) + transport("tls") + port({8}) + tls( + key-file("{0}/syslog.key") + cert-file("{0}/syslog.pub") + ca-file("{0}/syslog.pub") + peer-verify(required-trusted) + ) + ); +}}; + + +destination all {{ + file("{0}/syslog.log"); +}}; + +filter f_messages {{ level(debug..crit) }}; + +log {{ + source(unix_dgram); + filter(f_messages); + destination(all); +}}; +log {{ + source(unix_stream); + filter(f_messages); + destination(all); +}}; +log {{ + source(net4_dgram); + filter(f_messages); + destination(all); +}}; +log {{ + source(net4_stream); + filter(f_messages); + destination(all); +}}; +log {{ + source(net4_tls); + filter(f_messages); + destination(all); +}}; +log {{ + source(net6_dgram); + filter(f_messages); + destination(all); +}}; +log {{ + source(net6_stream); + filter(f_messages); + destination(all); +}}; +log {{ + source(net6_tls); + filter(f_messages); + destination(all); +}}; +log {{ + source(net4_mutual_tls); + filter(f_messages); + destination(all); +}}; +log {{ + source(net6_mutual_tls); + filter(f_messages); + destination(all); +}}; + """ + + config = config.format( + self.tmpdir.name, + SOCKET_PORT4_DGRAM, + SOCKET_PORT4_STREAM, + SOCKET_PORT4_TLS, + SOCKET_PORT6_DGRAM, + SOCKET_PORT6_STREAM, + SOCKET_PORT6_TLS, + SOCKET_PORT4_MUTUAL_TLS, + SOCKET_PORT6_MUTUAL_TLS, + ) + + config_path = os.path.join(self.tmpdir.name, "syslog-ng.conf") + with open(config_path, "w") as f: + f.write(config) + + # create output file + open(os.path.join(self.tmpdir.name, "syslog.log"), "w").close() + + # generate certificates + self._generate_keys() + + # start syslog-ng + command = [ + "syslog-ng", + "-F", + "-d", + "-f", + config_path, + "--persist-file", + f"{self.tmpdir.name}/syslog-ng.persist-", + "--pidfile", + f"{self.tmpdir.name}/syslog-ng.pid", + "--control", + f"{self.tmpdir.name}/syslog-ng.ctl", + ] + + self.server_pid = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.tmpdir.name, + ) + + # wait for syslog-ng to start + sleep(1) + + def _stop_server(self): + self.server_pid.kill() + self.server_pid.wait() + + def setUp(self): + super().setUp() + self._start_server() + + def tearDown(self): + self._stop_server() + super().tearDown() + + def test_SYSLOGNG_INET4_DGRAM(self): + test_logger = self._build_logger() + + handler = TLSSysLogHandler( + address=("127.0.0.1", SOCKET_PORT4_DGRAM), socktype=socket.SOCK_DGRAM + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_INET4_STREAM(self): + test_logger = self._build_logger() + + handler = TLSSysLogHandler( + address=("127.0.0.1", SOCKET_PORT4_STREAM), socktype=socket.SOCK_STREAM + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_INET4_TLS(self): + test_logger = self._build_logger() + + handler = TLSSysLogHandler( + address=("127.0.0.1", SOCKET_PORT4_TLS), + socktype=socket.SOCK_STREAM, + secure={"cafile": self.tmpdir.name + "/syslog.pub"}, + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_unix_DGRAM(self): + test_logger = self._build_logger() + + handler = TLSSysLogHandler( + address=self.tmpdir.name + "/syslog-dgram.sock", socktype=socket.SOCK_DGRAM + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_unix_STREAM(self): + test_logger = self._build_logger() + + handler = TLSSysLogHandler( + address=self.tmpdir.name + "/syslog-stream.sock", + socktype=socket.SOCK_STREAM, + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_INET6_DGRAM(self): + test_logger = self._build_logger() + + handler = TLSSysLogHandler( + address=("::1", SOCKET_PORT6_DGRAM), socktype=socket.SOCK_DGRAM + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_INET6_STREAM(self): + test_logger = self._build_logger() + + handler = TLSSysLogHandler( + address=("::1", SOCKET_PORT6_STREAM), socktype=socket.SOCK_STREAM + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_INET6_TLS(self): + test_logger = self._build_logger() + + handler = TLSSysLogHandler( + address=("::1", SOCKET_PORT6_TLS), + socktype=socket.SOCK_STREAM, + secure={"cafile": self.tmpdir.name + "/syslog.pub"}, + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_INET4_MUTUAL_TLS(self): + test_logger = self._build_logger() + + # custom context for mutual TLS + context = ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, cafile=self.tmpdir.name + "/syslog.pub" + ) + context.load_cert_chain( + certfile=self.tmpdir.name + "/syslog.pub", + keyfile=self.tmpdir.name + "/syslog.key", + ) + + handler = TLSSysLogHandler( + address=("127.0.0.1", SOCKET_PORT4_MUTUAL_TLS), + socktype=socket.SOCK_STREAM, + secure=context, + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) + + def test_SYSLOGNG_INET6_MUTUAL_TLS(self): + test_logger = self._build_logger() + + # custom context for mutual TLS + context = ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, cafile=self.tmpdir.name + "/syslog.pub" + ) + context.load_cert_chain( + certfile=self.tmpdir.name + "/syslog.pub", + keyfile=self.tmpdir.name + "/syslog.key", + ) + + handler = TLSSysLogHandler( + address=("::1", SOCKET_PORT6_MUTUAL_TLS), + socktype=socket.SOCK_STREAM, + secure=context, + ) + test_logger.addHandler(handler) + + uuid_message = uuid.uuid4().hex + test_logger.critical(uuid_message) + + sleep(2) + + with open(os.path.join(self.tmpdir.name, "syslog.log")) as f: + data = f.read() + self.assertTrue(uuid_message in data) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..000c739 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,137 @@ +import datetime +import ipaddress +import multiprocessing +import concurrent.futures +import os +import tempfile +from time import sleep +from unittest import TestCase + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import hashes, serialization + +import logging +import inspect + + +SOCKET_PORT = int(os.environ.get("SOCKET_PORT", 56712)) + +RSA_PUBLIC_EXPONENT = 65537 +RSA_KEY_SIZE = 2048 + +# logger = logging.getLogger(__name__) + + +class TestCertManager(TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.queue = multiprocessing.Queue(maxsize=1) + self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=2) + self._generate_keys() + + def tearDown(self): + self.executor.shutdown(wait=True) + self.queue.close() + # self.tmpdir.cleanup() + + def _build_logger(self) -> logging.Logger: + stack = inspect.stack() + logger_name = "{}.{}".format(__name__, stack[1][3]) + + test_logger = logging.getLogger(logger_name) + test_logger.setLevel(logging.DEBUG) + + for handler in test_logger.handlers: + test_logger.removeHandler(handler) + + return test_logger + + # https://gist.github.com/bloodearnest/9017111a313777b9cce5 + # Copyright 2018 Simon Davy + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in + # all copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + def _generate_selfsigned_cert(self, hostname, ip_addresses=None, key=None): + """Generates self signed certificate for a hostname, and optional IP addresses.""" + # Generate our key + if key is None: + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) + + # best practice seem to be to include the hostname in the SAN, which *SHOULD* mean COMMON_NAME is ignored. + alt_names = [x509.DNSName(hostname)] + + # allow addressing by IP, for when you don't have real DNS (common in most testing scenarios + if ip_addresses: + for addr in ip_addresses: + # openssl wants DNSnames for ips... + alt_names.append(x509.DNSName(addr)) + # ... whereas golang's crypto/tls is stricter, and needs IPAddresses + # note: older versions of cryptography do not understand ip_address objects + alt_names.append(x509.IPAddress(ipaddress.ip_address(addr))) + + san = x509.SubjectAlternativeName(alt_names) + + # path_len=0 means this cert can only sign itself, not other certs. + basic_contraints = x509.BasicConstraints(ca=True, path_length=0) + now = datetime.datetime.now() + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(1000) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=10 * 365)) + .add_extension(basic_contraints, False) + .add_extension(san, False) + .sign(key, hashes.SHA256(), default_backend()) + ) + cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + return cert_pem, key_pem + + def _generate_keys(self): + pub_key_bytes, priv_key_bytes = self._generate_selfsigned_cert( + "localhost", ["::1", "127.0.0.1"] + ) + + pub_key_path = os.path.join(self.tmpdir.name, "syslog.pub") + priv_key_path = os.path.join(self.tmpdir.name, "syslog.key") + + with open(pub_key_path, "wb") as f: + f.write(pub_key_bytes) + + with open(priv_key_path, "wb") as f: + f.write(priv_key_bytes) + + self.priv_key = priv_key_path + self.pub_key = pub_key_path