Repository: ionelmc/python-holdup

Issue:
Add support for Insecure HTTPS
The current `HttpCheck` against a https service defaults to requiring SSL Certificate Validation. In certain cases, we would like to perform a check against an IP address instead of a hostname (for e.g., `holdup https://10.1.2.3`). In this case the certificate validation will always fail due to a hostname mismatch.

Can we introduce an option to ignore certificate validation? For e.g., `holdup --insecure https://10.1.2.3`

I have been able to implement this change [here](https://github.com/mithun/python-holdup/commit/da1701f74881de6dd4fa561e601ca9a2737dd7df). However, my change will break support for older pythons.

I'm willing to submit a PR if you can provide suggestions on how backwards compatibility can be maintained.

Comments:

Patch:
diff --git a/.gitignore b/.gitignore
index a74475a..66ad3dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,6 +59,8 @@ docs/_build
 .env
 .cache
 .pytest
+.pytest_cache
 .bootstrap
 .appveyor.token
 *.bak
+.venv/
diff --git a/AUTHORS.rst b/AUTHORS.rst
index d01607a..3268092 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -3,3 +3,4 @@ Authors
 =======
 
 * Ionel Cristian Mărieș - https://blog.ionelmc.ro
+* Mithun Ayachit
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 93aaf2c..b6682f3 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -2,6 +2,10 @@
 Changelog
 =========
 
+1.7.0 ()
+------------------
+
+* Added support for skipping SSL certificate verification for HTTPS services
 
 1.6.0 (2018-03-22)
 ------------------
diff --git a/README.rst b/README.rst
index 7c59423..fc1cbb3 100644
--- a/README.rst
+++ b/README.rst
@@ -72,7 +72,7 @@ Installation
 Documentation
 =============
 
-Usage: ``holdup [-h] [-t SECONDS] [-T SECONDS] [-i SECONDS] [-n] service [service ...] [-- command [arg [arg ...]]]``
+Usage: ``holdup [-h] [-t SECONDS] [-T SECONDS] [-i SECONDS] [-n] [--insecure] service [service ...] [-- command [arg [arg ...]]]``
 
 Wait for services to be ready and optionally exec command.
 
@@ -81,7 +81,7 @@ Positional arguments:
     A service to wait for. Supported protocols:
     "tcp://host:port/", "path:///path/to/something",
     "unix:///path/to/domain.sock", "eval://expr",
-    "http://urn", "http://urn" (status 200 expected). Join
+    "http://urn", "http://urn", "https+insecure//urn" (status 200 expected). Join
     protocols with a comma to make holdup exit at the
     first passing one, eg: tcp://host:1,host:2 or
     tcp://host:1,tcp://host:2 are equivalent and mean "any
@@ -100,6 +100,7 @@ Optional arguments:
                         How often to check. Default: 0.2
   -n, --no-abort        Ignore failed services. This makes `holdup` return 0
                         exit code regardless of services actually responding.
+  --insecure            Skip SSL Certificate verification for HTTPS services.
 
 Suggested use
 -------------
@@ -108,8 +109,8 @@ Assuming you always want the container to wait add this in your ``Dockerfile``::
 
     COPY entrypoint.sh /
     ENTRYPOINT ["/entrypoint.sh"]
-    CMD ["/bin/bash"] 
-    
+    CMD ["/bin/bash"]
+
 Then in ``entrypoint.sh`` you could have::
 
     #!/bin/sh
@@ -122,6 +123,19 @@ Then in ``entrypoint.sh`` you could have::
 
 The only disadvantage is that you might occasionally need to use ``docker run --entrypoint=''`` to avoid running holdup. No biggie.
 
+Insecure HTTPS Service Checks
+-------------------------------
+
+You may choose to skip SSL validation when waiting for an HTTPS service (for e.g., when using an IP Address). This can be done using either of the following methods::
+
+    # Specifying a https+insecure protocol
+    holdup https+insecure://10.1.2.3/
+
+    # Specifying the --insecure` option
+    holdup --insecure https://10.1.2.3/
+
+Skipping SSL Certificate verification requires a minimum of Python-2.7.9 or Python-3.4.3.
+
 Development
 ===========
 
diff --git a/src/holdup/cli.py b/src/holdup/cli.py
index f5417bd..9615dd7 100644
--- a/src/holdup/cli.py
+++ b/src/holdup/cli.py
@@ -50,13 +50,13 @@ except ImportError:
 class Check(object):
     error = None
 
-    def is_passing(self, timeout, verbose):
+    def is_passing(self, options):
         try:
-            self.run(timeout, verbose)
+            self.run(options)
         except Exception as exc:
             self.error = exc
         else:
-            if verbose:
+            if options.verbose:
                 print('holdup: Passed check: %r' % self)
             return True
 
@@ -72,9 +72,9 @@ class TcpCheck(Check):
         self.host = host
         self.port = port
 
-    def run(self, timeout, _):
+    def run(self, options):
         sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-        sock.settimeout(timeout)
+        sock.settimeout(options.check_timeout)
         with closing(sock):
             sock.connect((self.host, self.port))
 
@@ -84,14 +84,49 @@ class TcpCheck(Check):
 
 class HttpCheck(Check):
     def __init__(self, url):
-        self.url = url
-
-    def run(self, timeout, _):
-        if hasattr(ssl, 'create_default_context') and 'context' in getargspec(urlopen).args:
-            kwargs = {'context': ssl.create_default_context()}
+        self.do_insecure = False
+        proto, urn = url.split('://', 1)
+        if proto == 'https+insecure':
+            self.do_insecure = True
+            proto = 'https'
+        self.url = '{}://{}'.format(proto, urn)
+
+    def can_create_default_context(self):
+        """
+            Check if the current python version supports
+                * ssl.create_default_context()
+                * 'context' kwargs for urlopen
+            Supported Python versions are:
+                * >2.7.9
+                * >3.4.3
+        """
+        if hasattr(ssl, 'create_default_context'):
+            urlopen_argspec = getargspec(urlopen)
+            urlopen_args = urlopen_argspec.args
+            if hasattr(urlopen_argspec, 'kwonlyargs'):
+                urlopen_args.extend(urlopen_argspec.kwonlyargs)
+            if 'context' in urlopen_args:
+                return True
+            else:
+                return False
+        else:
+            return False
+
+    def run(self, options):
+        kwargs = {}
+        do_insecure = self.do_insecure
+        if options.insecure:
+            do_insecure = True
+        if self.can_create_default_context():
+            ssl_ctx = ssl.create_default_context()
+            if do_insecure:
+                ssl_ctx.check_hostname = False
+                ssl_ctx.verify_mode = ssl.CERT_NONE
+            kwargs = {'context': ssl_ctx}
         else:
-            kwargs = {}
-        with closing(urlopen(self.url, timeout=timeout, **kwargs)) as req:
+            if do_insecure:
+                raise Exception("Insecure HTTPS is not supported with the current version of python")
+        with closing(urlopen(self.url, timeout=options.check_timeout, **kwargs)) as req:
             status = req.getcode()
             if status != 200:
                 raise Exception("Expected status code 200, got: %r." % status)
@@ -104,9 +139,9 @@ class UnixCheck(Check):
     def __init__(self, path):
         self.path = path
 
-    def run(self, timeout, _verbose):
+    def run(self, options):
         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
-        sock.settimeout(timeout)
+        sock.settimeout(options.check_timeout)
         with closing(sock):
             sock.connect(self.path)
 
@@ -118,7 +153,7 @@ class PathCheck(Check):
     def __init__(self, path):
         self.path = path
 
-    def run(self, _timeout, _verbose):
+    def run(self, _):
         os.stat(self.path)
         if not os.access(self.path, os.R_OK):
             raise Exception("Failed access(%r, 'R_OK') test." % self.path)
@@ -146,7 +181,7 @@ class EvalCheck(Check):
                         raise argparse.ArgumentTypeError('Invalid service spec %r. Import error: %s' % (expr, exc))
                     self.ns[node.id] = sys.modules[node.id]
 
-    def run(self, _timeout, _verbose):
+    def run(self, _):
         result = eval(self.expr, dict(self.ns), dict(self.ns))
         if not result:
             raise Exception("Failed to evaluate %r. Result %r is falsey." % (self.expr, result))
@@ -159,10 +194,10 @@ class AnyCheck(Check):
     def __init__(self, checks):
         self.checks = checks
 
-    def run(self, timeout, verbose):
+    def run(self, options):
         errors = []
         for check in self.checks:
-            if check.is_passing(timeout, verbose):
+            if check.is_passing(options):
                 return
             else:
                 errors.append(check)
@@ -206,7 +241,7 @@ def parse_value(value, proto):
         return UnixCheck(value)
     elif proto == 'path':
         return PathCheck(value)
-    elif proto in ('http', 'https'):
+    elif proto in ('http', 'https', 'https+insecure'):
         return HttpCheck('%s://%s' % (proto, value))
     elif proto == 'eval':
         return EvalCheck(value)
@@ -222,7 +257,7 @@ parser.add_argument('service', nargs=argparse.ONE_OR_MORE, type=parse_service,
                     help='A service to wait for. '
                          'Supported protocols: "tcp://host:port/", "path:///path/to/something", '
                          '"unix:///path/to/domain.sock", "eval://expr", '
-                         '"http://urn", "http://urn" (status 200 expected). '
+                         '"http://urn", "http://urn", "https+insecure//urn" (status 200 expected). '
                          'Join protocols with a comma to make holdup exit at the first '
                          'passing one, eg: tcp://host:1,host:2 or tcp://host:1,tcp://host:2 are equivalent and mean '
                          '"any that pass".')
@@ -239,6 +274,8 @@ parser.add_argument('-v', '--verbose', action='store_true',
 parser.add_argument('-n', '--no-abort', action='store_true',
                     help='Ignore failed services. '
                          'This makes `holdup` return 0 exit code regardless of services actually responding.')
+parser.add_argument('--insecure', action='store_true',
+                    help='Disable SSL Certificate verification for HTTPS services')
 
 
 def main():
@@ -270,7 +307,7 @@ def main():
     at_least_once = True
     while at_least_once or pending and time() - start < options.timeout:
         lapse = time()
-        pending = [check for check in pending if not check.is_passing(options.check_timeout, options.verbose)]
+        pending = [check for check in pending if not check.is_passing(options)]
         sleep(max(0, options.interval - time() + lapse))
         at_least_once = False
 


Test patch:
diff --git a/tests/test_holdup.py b/tests/test_holdup.py
index 106531e..7b69e57 100644
--- a/tests/test_holdup.py
+++ b/tests/test_holdup.py
@@ -1,11 +1,35 @@
 import os
 import socket
+import ssl
+import sys
 import threading
 
 import pytest
 
+try:
+    from inspect import getfullargspec as getargspec
+except ImportError:
+    from inspect import getargspec
+
+try:
+    from urllib.request import urlopen
+except ImportError:
+    from urllib2 import urlopen
+
 pytest_plugins = 'pytester',
 
+def skip_http_insecure_test():
+    if hasattr(ssl, 'create_default_context'):
+        urlopen_argspec = getargspec(urlopen)
+        urlopen_args = urlopen_argspec.args
+        if hasattr(urlopen_argspec, 'kwonlyargs'):
+            urlopen_args.extend(urlopen_argspec.kwonlyargs)
+        if 'context' in urlopen_args:
+            return False
+        else:
+            return True
+    else:
+        return True
 
 @pytest.fixture(params=[[], ['--', 'python', '-c', 'print("success !")']])
 def extra(request):
@@ -47,6 +71,7 @@ def test_http(testdir, extra, status, proto):
     result = testdir.run(
         'holdup',
         '-T', '5',
+        '-t', '5.1',
         '%s://httpbin.org/status/%s' % (proto, status),
         *extra
     )
@@ -57,6 +82,27 @@ def test_http(testdir, extra, status, proto):
             result.stderr.fnmatch_lines(['*HTTP Error 404*'])
 
 
+@pytest.mark.skipif(skip_http_insecure_test(),reason="requires ssl.create_default_context")
+def test_http_insecure_with_option(testdir):
+    result = testdir.run(
+        'holdup',
+        '-t', '2',
+        '--insecure',
+        'https://self-signed.badssl.com/',
+    )
+    assert result.ret == 0
+
+
+@pytest.mark.skipif(skip_http_insecure_test(),reason="requires ssl.create_default_context")
+def test_http_insecure_with_proto(testdir):
+    result = testdir.run(
+        'holdup',
+        '-t', '2',
+        'https+insecure://self-signed.badssl.com/',
+    )
+    assert result.ret == 0
+
+
 def test_any(testdir, extra):
     tcp = socket.socket()
     tcp.bind(('127.0.0.1', 0))
@@ -123,9 +169,9 @@ def test_any_failed(testdir):
     result.stderr.fnmatch_lines([
         'holdup: Failed service checks: any(tcp://localhost:%s,path:///doesnt/exist,unix:///doesnt/exist) '
         '(Nothing succeeded: '
-        'tcp://localhost:%s ([[]Errno 111[]]*), '
-        'path:///doesnt/exist ([[]Errno 2[]]*), '
-        'unix:///doesnt/exist ([[]Errno 2[]]*). Aborting!' % (port, port)
+        'tcp://localhost:%s (*), '
+        'path:///doesnt/exist (*), '
+        'unix:///doesnt/exist (*). Aborting!' % (port, port)
     ])
 
 
@@ -141,11 +187,12 @@ def test_no_abort(testdir, extra):
         *extra
     )
     result.stderr.fnmatch_lines([
-        'holdup: Failed checks: tcp://localhost:0 ([[]Errno 111[]]*), '
-        'path:///doesnt/exist ([[]Errno 2[]]*), unix:///doesnt/exist ([[]Errno 2[]]*)'
+        'holdup: Failed checks: tcp://localhost:0 (*), '
+        'path:///doesnt/exist (*), unix:///doesnt/exist (*)'
     ])
 
 
+@pytest.mark.skipif(os.path.exists('/.dockerenv'),reason="chmod(0) does not work in docker")
 def test_not_readable(testdir, extra):
     foobar = testdir.maketxtfile(foobar='')
     foobar.chmod(0)