From 4119ad46dc35a7285c7019f91b8ffef08703103f Mon Sep 17 00:00:00 2001
From: Varac <varac@varac.net>
Date: Tue, 7 May 2019 12:25:35 +0200
Subject: [PATCH] Test certs with openssl and requests

We now add the letsencrypt staging CA bundle to the trust store
and validate certs against it.

Closes #132
---
 test/{pytest => }/README.md       | 12 ++++
 test/pytest/le-staging-bundle.pem | 57 ++++++++++++++++++
 test/pytest/test_certs.py         | 99 +++++++++++++++++++++++--------
 test/requirements.txt             |  2 +
 4 files changed, 144 insertions(+), 26 deletions(-)
 rename test/{pytest => }/README.md (55%)
 create mode 100644 test/pytest/le-staging-bundle.pem
 mode change 100644 => 100755 test/pytest/test_certs.py

diff --git a/test/pytest/README.md b/test/README.md
similarity index 55%
rename from test/pytest/README.md
rename to test/README.md
index a7dbbc11b..1bc4c9b90 100644
--- a/test/pytest/README.md
+++ b/test/README.md
@@ -8,6 +8,18 @@ Specify host manually:
 
     py.test -v --hosts='ssh://root@varac-oas.openappstack.net'
 
+Run cert test manually using the ansible inventory file:
+
+    OAS_DOMAIN='varac-oas.openappstack.net' py.test -v -m 'certs' \
+      --connection=ansible \
+      --ansible-inventory=../ansible/inventory.yml \
+      --hosts='ansible://*'
+
+Run cert test manually against a different cluster, not configured in any
+ansible inventory file:
+
+	OAS_DOMAIN='varac-oas.openappstack.net' py.test -v -m 'certs'
+
 # Issues
 
 - Default ssh backend is `paramiko`, which doesn't work oout of the
diff --git a/test/pytest/le-staging-bundle.pem b/test/pytest/le-staging-bundle.pem
new file mode 100644
index 000000000..b7654a101
--- /dev/null
+++ b/test/pytest/le-staging-bundle.pem
@@ -0,0 +1,57 @@
+-----BEGIN CERTIFICATE-----
+MIIEqzCCApOgAwIBAgIRAIvhKg5ZRO08VGQx8JdhT+UwDQYJKoZIhvcNAQELBQAw
+GjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDUyMzIyMDc1OVoXDTM2
+MDUyMzIyMDc1OVowIjEgMB4GA1UEAwwXRmFrZSBMRSBJbnRlcm1lZGlhdGUgWDEw
+ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtWKySDn7rWZc5ggjz3ZB0
+8jO4xti3uzINfD5sQ7Lj7hzetUT+wQob+iXSZkhnvx+IvdbXF5/yt8aWPpUKnPym
+oLxsYiI5gQBLxNDzIec0OIaflWqAr29m7J8+NNtApEN8nZFnf3bhehZW7AxmS1m0
+ZnSsdHw0Fw+bgixPg2MQ9k9oefFeqa+7Kqdlz5bbrUYV2volxhDFtnI4Mh8BiWCN
+xDH1Hizq+GKCcHsinDZWurCqder/afJBnQs+SBSL6MVApHt+d35zjBD92fO2Je56
+dhMfzCgOKXeJ340WhW3TjD1zqLZXeaCyUNRnfOmWZV8nEhtHOFbUCU7r/KkjMZO9
+AgMBAAGjgeMwgeAwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAw
+HQYDVR0OBBYEFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHoGCCsGAQUFBwEBBG4wbDA0
+BggrBgEFBQcwAYYoaHR0cDovL29jc3Auc3RnLXJvb3QteDEubGV0c2VuY3J5cHQu
+b3JnLzA0BggrBgEFBQcwAoYoaHR0cDovL2NlcnQuc3RnLXJvb3QteDEubGV0c2Vu
+Y3J5cHQub3JnLzAfBgNVHSMEGDAWgBTBJnSkikSg5vogKNhcI5pFiBh54DANBgkq
+hkiG9w0BAQsFAAOCAgEABYSu4Il+fI0MYU42OTmEj+1HqQ5DvyAeyCA6sGuZdwjF
+UGeVOv3NnLyfofuUOjEbY5irFCDtnv+0ckukUZN9lz4Q2YjWGUpW4TTu3ieTsaC9
+AFvCSgNHJyWSVtWvB5XDxsqawl1KzHzzwr132bF2rtGtazSqVqK9E07sGHMCf+zp
+DQVDVVGtqZPHwX3KqUtefE621b8RI6VCl4oD30Olf8pjuzG4JKBFRFclzLRjo/h7
+IkkfjZ8wDa7faOjVXx6n+eUQ29cIMCzr8/rNWHS9pYGGQKJiY2xmVC9h12H99Xyf
+zWE9vb5zKP3MVG6neX1hSdo7PEAb9fqRhHkqVsqUvJlIRmvXvVKTwNCP3eCjRCCI
+PTAvjV+4ni786iXwwFYNz8l3PmPLCyQXWGohnJ8iBm+5nk7O2ynaPVW0U2W+pt2w
+SVuvdDM5zGv2f9ltNWUiYZHJ1mmO97jSY/6YfdOUH66iRtQtDkHBRdkNBsMbD+Em
+2TgBldtHNSJBfB3pm9FblgOcJ0FSWcUDWJ7vO0+NTXlgrRofRT6pVywzxVo6dND0
+WzYlTWeUVsO40xJqhgUQRER9YLOLxJ0O6C8i0xFxAMKOtSdodMB3RIwt7RFQ0uyt
+n5Z5MqkYhlMI3J1tPRTp1nEt9fyGspBOO05gi148Qasp+3N+svqKomoQglNoAxU=
+-----END CERTIFICATE-----
+
+-----BEGIN CERTIFICATE-----
+MIIFATCCAumgAwIBAgIRAKc9ZKBASymy5TLOEp57N98wDQYJKoZIhvcNAQELBQAw
+GjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDMyMzIyNTM0NloXDTM2
+MDMyMzIyNTM0NlowGjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMIICIjANBgkq
+hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA+pYHvQw5iU3v2b3iNuYNKYgsWD6KU7aJ
+diddtZQxSWYzUI3U0I1UsRPTxnhTifs/M9NW4ZlV13ZfB7APwC8oqKOIiwo7IwlP
+xg0VKgyz+kT8RJfYr66PPIYP0fpTeu42LpMJ+CKo9sbpgVNDZN2z/qiXrRNX/VtG
+TkPV7a44fZ5bHHVruAxvDnylpQxJobtCBWlJSsbIRGFHMc2z88eUz9NmIOWUKGGj
+EmP76x8OfRHpIpuxRSCjn0+i9+hR2siIOpcMOGd+40uVJxbRRP5ZXnUFa2fF5FWd
+O0u0RPI8HON0ovhrwPJY+4eWKkQzyC611oLPYGQ4EbifRsTsCxUZqyUuStGyp8oa
+aoSKfF6X0+KzGgwwnrjRTUpIl19A92KR0Noo6h622OX+4sZiO/JQdkuX5w/HupK0
+A0M0WSMCvU6GOhjGotmh2VTEJwHHY4+TUk0iQYRtv1crONklyZoAQPD76hCrC8Cr
+IbgsZLfTMC8TWUoMbyUDgvgYkHKMoPm0VGVVuwpRKJxv7+2wXO+pivrrUl2Q9fPe
+Kk055nJLMV9yPUdig8othUKrRfSxli946AEV1eEOhxddfEwBE3Lt2xn0hhiIedbb
+Ftf/5kEWFZkXyUmMJK8Ra76Kus2ABueUVEcZ48hrRr1Hf1N9n59VbTUaXgeiZA50
+qXf2bymE6F8CAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB
+Af8wHQYDVR0OBBYEFMEmdKSKRKDm+iAo2FwjmkWIGHngMA0GCSqGSIb3DQEBCwUA
+A4ICAQBCPw74M9X/Xx04K1VAES3ypgQYH5bf9FXVDrwhRFSVckria/7dMzoF5wln
+uq9NGsjkkkDg17AohcQdr8alH4LvPdxpKr3BjpvEcmbqF8xH+MbbeUEnmbSfLI8H
+sefuhXF9AF/9iYvpVNC8FmJ0OhiVv13VgMQw0CRKkbtjZBf8xaEhq/YqxWVsgOjm
+dm5CAQ2X0aX7502x8wYRgMnZhA5goC1zVWBVAi8yhhmlhhoDUfg17cXkmaJC5pDd
+oenZ9NVhW8eDb03MFCrWNvIh89DDeCGWuWfDltDq0n3owyL0IeSn7RfpSclpxVmV
+/53jkYjwIgxIG7Gsv0LKMbsf6QdBcTjhvfZyMIpBRkTe3zuHd2feKzY9lEkbRvRQ
+zbh4Ps5YBnG6CKJPTbe2hfi3nhnw/MyEmF3zb0hzvLWNrR9XW3ibb2oL3424XOwc
+VjrTSCLzO9Rv6s5wi03qoWvKAQQAElqTYRHhynJ3w6wuvKYF5zcZF3MDnrVGLbh1
+Q9ePRFBCiXOQ6wPLoUhrrbZ8LpFUFYDXHMtYM7P9sc9IAWoONXREJaO08zgFtMp4
+8iyIYUyQAbsvx8oD2M8kRvrIRSrRJSl6L957b4AFiLIQ/GgV2curs0jje7Edx34c
+idWw1VrejtwclobqNMVtG3EiPUIpJGpbMcJgbiLSmKkrvQtGng==
+-----END CERTIFICATE-----
diff --git a/test/pytest/test_certs.py b/test/pytest/test_certs.py
old mode 100644
new mode 100755
index 75789c212..d7d35a991
--- a/test/pytest/test_certs.py
+++ b/test/pytest/test_certs.py
@@ -1,44 +1,91 @@
 #!/usr/bin/env python3
-"""Validates remote TLS certs."""
 
-
-import pycurl
 import certifi
-from io import BytesIO
 import os
 import pytest
+import requests
+import socket
+import shutil
+import sys
+from OpenSSL import SSL
+
+
+def add_custom_cert_authorities(ca_file: str,
+                                custom_ca_files: list,
+                                destination_file: str =
+                                '/tmp/custom_ca_bundle.crt'):
+    """Concanates existing cert bundle with custom CAs."""
 
+    destination = open(destination_file, 'wb')
+    shutil.copyfileobj(open(ca_file, 'rb'), destination)
+    for custom_ca_file in custom_ca_files:
+        shutil.copyfileobj(open(custom_ca_file, 'rb'), destination)
+    destination.close()
 
-def check_cert_url(url: str):
-    print('Testing URL: ', url)
 
-    buffer = BytesIO()
-    c = pycurl.Curl()
-    c.setopt(c.URL, url)
-    c.setopt(c.WRITEDATA, buffer)
-    c.setopt(c.CAINFO, certifi.where())
-    c.setopt(c.VERBOSE, True)
+def fetch_certs(domain: str, port: int = 443):
+    """Fetches cert fom given domain."""
+
+    s = socket.socket()
+    context = SSL.Context(SSL.TLSv1_2_METHOD)
+    conn = SSL.Connection(context, s)
+    conn.set_tlsext_host_name(domain.encode('utf-8'))
+    certs = []
 
     try:
-        c.perform()
-        valid_cert = True
-    except pycurl.error as e:
-        valid_cert = False
-        print('Cert error!')
-        if e.args[0] == pycurl.E_COULDNT_CONNECT and c.exception:
-            print(c.exception)
-        else:
-            print(e)
-    c.close()
+        conn.connect((domain, port))
+    except socket.gaierror as e:
+        print('socket.gaierror: {0}'.format(str(e)))
+        sys.exit(1)
+
+    conn.do_handshake()
+    try:
+        certs = conn.get_peer_cert_chain()
+    except SSL.Error as e:
+        print('SSL Error: {0}'.format(str(e)))
+        sys.exit(1)
+
+    return certs
+
+
+def inspect_certs(certs: list):
+    """Show CN and issuer CN of cert list."""
 
-    return valid_cert
+    for cert in certs:
+        subject = cert.get_subject()
+        cn = subject.CN
+        issuer = cert.get_issuer().CN
+        # issued_by = issuer.CN
+        print('CN: {0} (Issuer: {1})'.format(cn, issuer))
+
+
+def valid_cert(domain: str, ca_file: str = '/tmp/custom_ca_bundle.crt'):
+    """Validate cert of given domain against a ca_file bundle."""
+
+    url = 'https://' + domain
+    print('Validating cert from {0} ...'.format(url))
+    inspect_certs(fetch_certs(domain))
+
+    try:
+        requests.get(url, verify=ca_file)
+    except requests.exceptions.SSLError as ex:
+        print('SSL Verification Error %s' % ex)
+        return False
+    print('Successfully Verified SSL Cert.\n')
+    return True
 
 
 @pytest.mark.certs
 def test_cert_validation(host):
-
     domain = os.environ.get("OAS_DOMAIN")
     assert domain, "Please export OAS_DOMAIN as environment variable."
 
-    # Check traefik cert
-    assert check_cert_url('https://traefi.%s/' % domain)
+    add_custom_cert_authorities(certifi.where(),
+                                ['pytest/le-staging-bundle.pem'])
+
+    # Check nextcloud cert
+    assert valid_cert('files.%s' % domain)
+
+
+if __name__ == "__main__":
+    test_cert_validation('')
diff --git a/test/requirements.txt b/test/requirements.txt
index 3f168e536..e3927fc39 100644
--- a/test/requirements.txt
+++ b/test/requirements.txt
@@ -1,9 +1,11 @@
 ansible>=2.7.0
 behave-webdriver>=0.2.2
+certifi>=2019.3.9
 # Needed for ansible k8s resource
 openshift>=0.8.6
 psutil>=5.5.0
 pycurl>=7.43.0.2
+pyopenssl>=19.0.0
 pytest>=4.3.0
 requests>=2.19.1
 tabulate>=0.8.3
-- 
GitLab