diff --git a/.gitignore b/.gitignore index 3e323945..2578d682 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules dist .vscode package-lock.json +tests/resources/cert.pem +tests/resources/key.pem diff --git a/.travis.yml b/.travis.yml index 677d9abc..d4c49dda 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,7 @@ install: - pip3 install . - pip3 install pytest script: + - openssl req -x509 -newkey rsa:4096 -keyout ./tests/resources/key.pem -out ./tests/resources/cert.pem -days 365 -nodes -subj /CN=localhost - JUPYTER_TOKEN=secret jupyter-notebook --config=./tests/resources/jupyter_server_config.py & - sleep 5 - pytest -v diff --git a/jupyter_server_proxy/__init__.py b/jupyter_server_proxy/__init__.py index 986b3ee1..043f4aad 100644 --- a/jupyter_server_proxy/__init__.py +++ b/jupyter_server_proxy/__init__.py @@ -1,5 +1,3 @@ -import ssl - from .handlers import setup_handlers, SuperviseAndProxyHandler from .config import ServerProxy, make_handlers, get_entrypoint_server_processes, make_server_process from notebook.utils import url_path_join as ujoin @@ -30,19 +28,8 @@ def load_jupyter_server_extension(nbapp): server_handlers = make_handlers(base_url, server_proccesses) nbapp.web_app.add_handlers('.*', server_handlers) - # Configure SSL support - ssl_options = None - if serverproxy.https: - ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=serverproxy.cafile) - if serverproxy.certfile or serverproxy.keyfile: - ssl_context.load_cert_chain(serverproxy.certfile, serverproxy.keyfile or None) - else: - ssl_context.load_default_certs() - ssl_context.check_hostname = serverproxy.check_hostname - ssl_options = ssl_context - # Set up default handler - setup_handlers(nbapp.web_app, serverproxy.host_whitelist, ssl_options) + setup_handlers(nbapp.web_app, serverproxy.host_whitelist) launcher_entries = [] icons = {} diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index a81a9564..30e74a1a 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -2,6 +2,7 @@ Traitlets based configuration for jupyter_server_proxy """ from notebook.utils import url_path_join as ujoin +import ssl from traitlets import Bool, Dict, List, Unicode, Union, default from traitlets.config import Configurable from .handlers import SuperviseAndProxyHandler, AddSlashHandler @@ -15,7 +16,7 @@ except ImportError: from .utils import Callable -def _make_serverproxy_handler(name, command, environment, timeout, absolute_url, port, mappath): +def _make_serverproxy_handler(name, command, environment, timeout, absolute_url, port, mappath, ssl_options): """ Create a SuperviseAndProxyHandler subclass with given parameters """ @@ -28,6 +29,7 @@ def __init__(self, *args, **kwargs): self.absolute_url = absolute_url self.requested_port = port self.mappath = mappath + self.ssl_options = ssl_options @property def process_args(self): @@ -90,6 +92,7 @@ def make_handlers(base_url, server_processes): sp.absolute_url, sp.port, sp.mappath, + sp.ssl_options, ) handlers.append(( ujoin(base_url, sp.name, r'(.*)'), handler, dict(state={}), @@ -101,7 +104,23 @@ def make_handlers(base_url, server_processes): LauncherEntry = namedtuple('LauncherEntry', ['enabled', 'icon_path', 'title']) ServerProcess = namedtuple('ServerProcess', [ - 'name', 'command', 'environment', 'timeout', 'absolute_url', 'port', 'mappath', 'launcher_entry']) + 'name', 'command', 'environment', 'timeout', 'absolute_url', 'port', 'mappath', 'ssl_options', 'launcher_entry']) + + +def _create_ssl_options(config): + # Configure SSL support + ssl_options = None + if config.get('https', False): + ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=config.get('cafile')) + certfile = config.get('certfile') + keyfile = config.get('keyfile') + if certfile or keyfile: + ssl_context.load_cert_chain(certfile, keyfile) + else: + ssl_context.load_default_certs() + ssl_context.check_hostname = config.get('check_hostname', False) + ssl_options = ssl_context + return ssl_options def make_server_process(name, server_process_config): le = server_process_config.get('launcher_entry', {}) @@ -113,6 +132,7 @@ def make_server_process(name, server_process_config): absolute_url=server_process_config.get('absolute_url', False), port=server_process_config.get('port', 0), mappath=server_process_config.get('mappath', {}), + ssl_options=_create_ssl_options(server_process_config), launcher_entry=LauncherEntry( enabled=le.get('enabled', True), icon_path=le.get('icon_path'), diff --git a/jupyter_server_proxy/handlers.py b/jupyter_server_proxy/handlers.py index ac54cfa1..e26688ca 100644 --- a/jupyter_server_proxy/handlers.py +++ b/jupyter_server_proxy/handlers.py @@ -471,16 +471,19 @@ def get_timeout(self): return 5 async def _http_ready_func(self, p): - url = 'http://localhost:{}'.format(self.port) + protocol = 'http' if self.ssl_options is None else 'https' + url = '{}://localhost:{}'.format(protocol, self.port) async with aiohttp.ClientSession() as session: try: - async with session.get(url) as resp: + # Disable ssl verification + async with session.get(url, ssl=False) as resp: # We only care if we get back *any* response, not just 200 # If there's an error response, that can be shown directly to the user self.log.debug('Got code {} back from {}'.format(resp.status, url)) return True - except aiohttp.ClientConnectionError: + except aiohttp.ClientConnectionError as err: self.log.debug('Connection to {} refused'.format(url)) + self.log.debug(err) return False async def ensure_process(self): @@ -561,21 +564,17 @@ def options(self, path): return self.proxy(self.port, path) -def setup_handlers(web_app, host_whitelist, ssl_options): +def setup_handlers(web_app, host_whitelist): host_pattern = '.*$' web_app.add_handlers('.*', [ (url_path_join(web_app.settings['base_url'], r'/proxy/(.*):(\d+)(.*)'), - RemoteProxyHandler, {'absolute_url': False, 'host_whitelist': host_whitelist, - 'ssl_options': ssl_options}), + RemoteProxyHandler, {'absolute_url': False, 'host_whitelist': host_whitelist}), (url_path_join(web_app.settings['base_url'], r'/proxy/absolute/(.*):(\d+)(.*)'), - RemoteProxyHandler, {'absolute_url': True, 'host_whitelist': host_whitelist, - 'ssl_options': ssl_options}), + RemoteProxyHandler, {'absolute_url': True, 'host_whitelist': host_whitelist}), (url_path_join(web_app.settings['base_url'], r'/proxy/(\d+)(.*)'), - LocalProxyHandler, {'absolute_url': False, - 'ssl_options': ssl_options}), + LocalProxyHandler, {'absolute_url': False}), (url_path_join(web_app.settings['base_url'], r'/proxy/absolute/(\d+)(.*)'), - LocalProxyHandler, {'absolute_url': True, - 'ssl_options': ssl_options}), + LocalProxyHandler, {'absolute_url': True}), ]) # vim: set et ts=4 sw=4: diff --git a/tests/resources/httpinfo.py b/tests/resources/httpinfo.py index 3ca94155..7445c03f 100644 --- a/tests/resources/httpinfo.py +++ b/tests/resources/httpinfo.py @@ -1,4 +1,6 @@ +from argparse import ArgumentParser from http.server import HTTPServer, BaseHTTPRequestHandler +import ssl import sys class EchoRequestInfo(BaseHTTPRequestHandler): @@ -11,7 +13,15 @@ def do_GET(self): if __name__ == '__main__': - port = int(sys.argv[1]) - server_address = ('', port) + parser = ArgumentParser() + parser.add_argument('port', type=int) + parser.add_argument('--keyfile') + parser.add_argument('--certfile') + args = parser.parse_args() + server_address = ('', args.port) httpd = HTTPServer(server_address, EchoRequestInfo) + if args.keyfile or args.certfile: + httpd.socket = ssl.wrap_socket( + httpd.socket, server_side=True, + certfile=args.certfile, keyfile=args.keyfile) httpd.serve_forever() diff --git a/tests/resources/jupyter_server_config.py b/tests/resources/jupyter_server_config.py index 8005cde5..c4ebeeb1 100644 --- a/tests/resources/jupyter_server_config.py +++ b/tests/resources/jupyter_server_config.py @@ -24,6 +24,13 @@ def mappathf(path): 'command': ['python3', './tests/resources/httpinfo.py', '{port}'], 'mappath': mappathf, }, + 'python-https' : { + 'command': ['python3', './tests/resources/httpinfo.py', '--keyfile=./tests/resources/key.pem', '--certfile=./tests/resources/cert.pem', '{port}'], + 'https': True, + # Self-signed cert, use certificate as ca + 'cafile': './tests/resources/cert.pem', + 'check_hostname': True, + }, 'python-websocket' : { 'command': ['python3', './tests/resources/websocket.py', '--port={port}'], } diff --git a/tests/test_proxies.py b/tests/test_proxies.py index 746b3cde..a8c785ef 100644 --- a/tests/test_proxies.py +++ b/tests/test_proxies.py @@ -124,6 +124,15 @@ def test_server_proxy_mappath_callable(requestpath, expected): assert 'X-Proxycontextpath: /python-http-mappathf\n' in s +def test_server_proxy_https(): + r = request_get(PORT, '/python-https/abc', TOKEN) + assert r.code == 200 + s = r.read().decode('ascii') + assert s.startswith('GET /abc?token=') + assert 'X-Forwarded-Context: /python-https\n' in s + assert 'X-Proxycontextpath: /python-https\n' in s + + def test_server_proxy_remote(): r = request_get(PORT, '/newproxy', TOKEN, host='127.0.0.1') assert r.code == 200