Skip to content

Allow proxying to remote host #154

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Nov 15, 2019
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*.pyc
*.egg-info/
docs/_build
node_modules
13 changes: 9 additions & 4 deletions docs/arbitrary-ports.rst → docs/arbitrary-ports-hosts.rst
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
.. _arbitrary-ports:

=========================
Accessing Arbitrary Ports
=========================
==================================
Accessing Arbitrary Ports or Hosts
==================================

If you already have a server running on localhost listening on
a port, you can access it through the notebook at
Expand All @@ -15,6 +15,11 @@ URL in the request.

This works for all ports listening on the local machine.

You can also specify arbitrary hosts in order to proxy traffic from
another machine on the network ``<notebook-base>/proxy/<host>:<port>``.

For security reasons the host must match an entry in the whitelist in your configuration.

With JupyterHub
===============

Expand All @@ -38,7 +43,7 @@ Without JupyterHub
==================

A very similar set up works when you don't use JupyterHub. You
can construct the URL with ``<notebook-url>/proxy/<port>``.
can construct the URL with ``<notebook-url>/proxy/<port>``.

If your notebook url is ``http://localhost:8888`` and you have
a process running listening on port 8080, you can access it with
Expand Down
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The primary use cases are:
#. Allow access from frontend javascript (in classic notebook or
JupyterLab extensions) to access web APIs of other processes
running locally in a safe manner. This is used by the `JupyterLab
extension <https://github.com/dask/dask-labextension>`_ for
extension <https://github.com/dask/dask-labextension>`_ for
`dask <https://dask.org/>`_.


Expand All @@ -33,7 +33,7 @@ Contents
install
server-process
launchers
arbitrary-ports
arbitrary-ports-hosts


Convenience packages for popular applications
Expand Down
4 changes: 2 additions & 2 deletions jupyter_server_proxy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def load_jupyter_server_extension(nbapp):
nbapp.web_app.add_handlers('.*', server_handlers)

# Set up default handler
setup_handlers(nbapp.web_app)
setup_handlers(nbapp.web_app, serverproxy.host_whitelist)

launcher_entries = []
icons = {}
Expand All @@ -40,4 +40,4 @@ def load_jupyter_server_extension(nbapp):
nbapp.web_app.add_handlers('.*', [
(ujoin(base_url, 'server-proxy/servers-info'), ServersInfoHandler, {'server_processes': server_proccesses}),
(ujoin(base_url, 'server-proxy/icon/(.*)'), IconHandler, {'icons': icons})
])
])
35 changes: 34 additions & 1 deletion jupyter_server_proxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
Traitlets based configuration for jupyter_server_proxy
"""
from notebook.utils import url_path_join as ujoin
from traitlets import Dict
from traitlets import Dict, List, Union, default
from traitlets.config import Configurable
from .handlers import SuperviseAndProxyHandler, AddSlashHandler
import pkg_resources
from collections import namedtuple
from .utils import call_with_asked_args

try:
# Traitlets >= 4.3.3
from traitlets import Callable
except ImportError:
from .utils import Callable

def _make_serverproxy_handler(name, command, environment, timeout, absolute_url, port):
"""
Create a SuperviseAndProxyHandler subclass with given parameters
Expand Down Expand Up @@ -162,3 +168,30 @@ class ServerProxy(Configurable):
""",
config=True
)

host_whitelist = Union(
trait_types=[List(), Callable()],
help="""
List of allowed hosts.
Can also be a function that decides whether a host can be proxied.

If implemented as a function, this should return True if a host should
be proxied and False if it should not. Such a function could verify
that the host matches a particular regular expression pattern or falls
into a specific subnet. It should probably not be a slow check against
some external service. Here is an example that could be placed in a
site-wide Jupyter notebook config:

def host_whitelist(handler, host):
handler.log.info("Request to proxy to host " + host)
return host.startswith("10.")
c.ServerProxy.host_whitelist = host_whitelist

Defaults to a list of ["localhost", "127.0.0.1"].
""",
config=True
)

@default("host_whitelist")
def _host_whitelist_default(self):
return ["localhost", "127.0.0.1"]
61 changes: 60 additions & 1 deletion jupyter_server_proxy/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ProxyHandler(WebSocketHandlerMixin, IPythonHandler):
def __init__(self, *args, **kwargs):
self.proxy_base = ''
self.absolute_url = kwargs.pop('absolute_url', False)
self.host_whitelist = kwargs.pop('host_whitelist', ['localhost', '127.0.0.1'])
super().__init__(*args, **kwargs)

# Support all the methods that torando does by default except for GET which
Expand Down Expand Up @@ -166,6 +167,12 @@ def _build_proxy_request(self, host, port, proxied_path, body):
headers=headers, **self.proxy_request_options())
return req

def _check_host_whitelist(self, host):
if callable(self.host_whitelist):
return self.host_whitelist(self, host)
else:
return host in self.host_whitelist

@web.authenticated
async def proxy(self, host, port, proxied_path):
'''
Expand All @@ -175,6 +182,12 @@ async def proxy(self, host, port, proxied_path):
{base_url}/{proxy_base}/{proxied_path}
'''

if not self._check_host_whitelist(host):
self.set_status(403)
self.write("Host '{host}' is not whitelisted. "
"See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format(host=host))
return

if 'Proxy-Connection' in self.request.headers:
del self.request.headers['Proxy-Connection']

Expand Down Expand Up @@ -226,6 +239,14 @@ async def proxy_open(self, host, port, proxied_path=''):
We establish a websocket connection to the proxied backend &
set up a callback to relay messages through.
"""

if not self._check_host_whitelist(host):
self.set_status(403)
self.log.info("Host '{host}' is not whitelisted. "
"See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format(host=host))
self.close()
return

if not proxied_path.startswith('/'):
proxied_path = '/' + proxied_path

Expand Down Expand Up @@ -334,6 +355,40 @@ def proxy(self, port, proxied_path):
return super().proxy('localhost', port, proxied_path)


class RemoteProxyHandler(ProxyHandler):
"""
A tornado request handler that proxies HTTP and websockets
from a port on a specified remote system.
"""

async def http_get(self, host, port, proxied_path):
return await self.proxy(host, port, proxied_path)

def post(self, host, port, proxied_path):
return self.proxy(host, port, proxied_path)

def put(self, host, port, proxied_path):
return self.proxy(host, port, proxied_path)

def delete(self, host, port, proxied_path):
return self.proxy(host, port, proxied_path)

def head(self, host, port, proxied_path):
return self.proxy(host, port, proxied_path)

def patch(self, host, port, proxied_path):
return self.proxy(host, port, proxied_path)

def options(self, host, port, proxied_path):
return self.proxy(host, port, proxied_path)

async def open(self, host, port, proxied_path):
return await self.proxy_open(host, port, proxied_path)

def proxy(self, host, port, proxied_path):
return super().proxy(host, port, proxied_path)


# FIXME: Move this to its own file. Too many packages now import this from nbrserverproxy.handlers
class SuperviseAndProxyHandler(LocalProxyHandler):
'''Manage a given process and requests to it '''
Expand Down Expand Up @@ -467,9 +522,13 @@ def options(self, path):
return self.proxy(self.port, path)


def setup_handlers(web_app):
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}),
(url_path_join(web_app.settings['base_url'], r'/proxy/absolute/(.*):(\d+)(.*)'),
RemoteProxyHandler, {'absolute_url': True, 'host_whitelist': host_whitelist}),
(url_path_join(web_app.settings['base_url'], r'/proxy/(\d+)(.*)'),
LocalProxyHandler, {'absolute_url': False}),
(url_path_join(web_app.settings['base_url'], r'/proxy/absolute/(\d+)(.*)'),
Expand Down
19 changes: 19 additions & 0 deletions jupyter_server_proxy/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from traitlets import TraitType
import six

def call_with_asked_args(callback, args):
"""
Call callback with only the args it wants from args
Expand Down Expand Up @@ -29,3 +32,19 @@ def call_with_asked_args(callback, args):
)
)
return callback(*asked_arg_values)

# copy-pasted from the master of Traitlets source
class Callable(TraitType):
"""A trait which is callable.
Notes
-----
Classes are callable, as are instances
with a __call__() method."""

info_text = 'a callable'

def validate(self, obj, value):
if six.callable(value):
return value
else:
self.error(obj, value)