Skip to content
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

Configure absolute and server URL prefixes #85

Merged
merged 10 commits into from
Jan 30, 2019
6 changes: 5 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ python:
- 3.5

script:
- true
- pip3 install .
- pip3 install pytest
- JUPYTER_TOKEN=secret jupyter-notebook --config=./tests/resources/jupyter_server_config.py &
- sleep 5
- pytest

deploy:
provider: pypi
Expand Down
28 changes: 28 additions & 0 deletions docs/server-process.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ pairs.
* A callable that takes any :ref:`callable arguments <server-process/callable-argument>`,
and returns a dictionary of strings that are used & treated same as above.

#. **absolute_url**

*True* if the URL as seen by the proxied application should be the full URL
sent by the user. *False* if the URL as seen by the proxied application should
see the URL after the parts specific to jupyter-server-proxy have been stripped.

For example, with the following config:

.. code:: python

c.ServerProxy.servers = {
'test-server': {
'command': ['python3', '-m', 'http.server', '{port}'],
'absolute_url': False
}
}

When a user requests ``/test-server/some-url``, the proxied server will see it
as a request for ``/some-url`` - the ``/test-server`` part is stripped out.

If ``absolute_url`` is set to ``True`` instead, the proxied server will see it
as a request for ``/test-server/some-url`` instead - without any stripping.

This is very useful with applications that require a ``base_url`` to be set.

Defaults to *False*.


#. **launcher_entry**

A dictionary with options on if / how an entry in the classic Jupyter Notebook
Expand Down
15 changes: 12 additions & 3 deletions jupyter_server_proxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from collections import namedtuple
from .utils import call_with_asked_args

def _make_serverproxy_handler(name, command, environment, timeout):
def _make_serverproxy_handler(name, command, environment, timeout, absolute_url):
"""
Create a SuperviseAndProxyHandler subclass with given parameters
"""
Expand All @@ -18,6 +18,8 @@ class _Proxy(SuperviseAndProxyHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.name = name
self.proxy_base = name
self.absolute_url = absolute_url

@property
def process_args(self):
Expand Down Expand Up @@ -77,6 +79,7 @@ def make_handlers(base_url, server_processes):
sp.command,
sp.environment,
sp.timeout,
sp.absolute_url,
)
handlers.append((
ujoin(base_url, sp.name, r'(.*)'), handler, dict(state={}),
Expand All @@ -87,7 +90,8 @@ def make_handlers(base_url, server_processes):
return handlers

LauncherEntry = namedtuple('LauncherEntry', ['enabled', 'icon_path', 'title'])
ServerProcess = namedtuple('ServerProcess', ['name', 'command', 'environment', 'timeout', 'launcher_entry'])
ServerProcess = namedtuple('ServerProcess', [
'name', 'command', 'environment', 'timeout', 'absolute_url', 'launcher_entry'])

def make_server_process(name, server_process_config):
le = server_process_config.get('launcher_entry', {})
Expand All @@ -96,6 +100,7 @@ def make_server_process(name, server_process_config):
command=server_process_config['command'],
environment=server_process_config.get('environment', {}),
timeout=server_process_config.get('timeout', 5),
absolute_url=server_process_config.get('absolute_url', False),
launcher_entry=LauncherEntry(
enabled=le.get('enabled', True),
icon_path=le.get('icon_path'),
Expand Down Expand Up @@ -129,6 +134,10 @@ class ServerProxy(Configurable):
timeout
Timeout in seconds for the process to become ready, default 5s.

absolute_url
Proxy requests default to being rewritten to '/'. If this is True,
the absolute URL will be sent to the backend instead.

launcher_entry
A dictionary of various options for entries in classic notebook / jupyterlab launchers.

Expand All @@ -146,4 +155,4 @@ class ServerProxy(Configurable):
Title to be used for the launcher entry. Defaults to the name of the server if missing.
""",
config=True
)
)
99 changes: 66 additions & 33 deletions jupyter_server_proxy/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ def get(self, *args):
self.redirect(urlunparse(dest))

class LocalProxyHandler(WebSocketHandlerMixin, IPythonHandler):

def __init__(self, *args, **kwargs):
self.proxy_base = ''
self.absolute_url = kwargs.pop('absolute_url', False)
super().__init__(*args, **kwargs)

async def open(self, port, proxied_path=''):
"""
Called when a client opens a websocket connection.
Expand All @@ -39,13 +45,7 @@ async def open(self, port, proxied_path=''):
if not proxied_path.startswith('/'):
proxied_path = '/' + proxied_path

client_uri = '{uri}:{port}{path}'.format(
uri='ws://127.0.0.1',
port=port,
path=proxied_path
)
if self.request.query:
client_uri += '?' + self.request.query
client_uri = self.get_client_uri('ws', port, proxied_path)
headers = self.request.headers

def message_cb(message):
Expand Down Expand Up @@ -126,14 +126,63 @@ def _record_activity(self):
"""
self.settings['api_last_activity'] = utcnow()

def _get_context_path(self, port):
"""
Some applications need to know where they are being proxied from.
This is either:
- {base_url}/proxy/{port}
- {base_url}/proxy/absolute/{port}
- {base_url}/{proxy_base}
"""
if self.proxy_base:
return url_path_join(self.base_url, self.proxy_base)
if self.absolute_url:
return url_path_join(self.base_url, 'proxy', 'absolute', str(port))
else:
return url_path_join(self.base_url, 'proxy', str(port))

def get_client_uri(self, protocol, port, proxied_path):
context_path = self._get_context_path(port)
if self.absolute_url:
client_path = url_path_join(context_path, proxied_path)
else:
client_path = proxied_path

client_uri = '{protocol}://{host}:{port}{path}'.format(
protocol=protocol,
host='localhost',
port=port,
path=client_path
)
if self.request.query:
client_uri += '?' + self.request.query

return client_uri

def _build_proxy_request(self, port, proxied_path, body):

headers = self.proxy_request_headers()

client_uri = self.get_client_uri('http', port, proxied_path)
# Some applications check X-Forwarded-Context and X-ProxyContextPath
# headers to see if and where they are being proxied from.
if not self.absolute_url:
yuvipanda marked this conversation as resolved.
Show resolved Hide resolved
context_path = self._get_context_path(port)
headers['X-Forwarded-Context'] = context_path
headers['X-ProxyContextPath'] = context_path

req = httpclient.HTTPRequest(
client_uri, method=self.request.method, body=body,
headers=headers, **self.proxy_request_options())
return req

@web.authenticated
async def proxy(self, port, proxied_path):
'''
While self.request.uri is
(hub) /user/username/proxy/([0-9]+)/something.
(single) /proxy/([0-9]+)/something
This serverextension is given {port}/{everything/after}.
This serverextension handles:
{base_url}/proxy/{port([0-9]+)}/{proxied_path}
{base_url}/proxy/absolute/{port([0-9]+)}/{proxied_path}
{base_url}/{proxy_base}/{proxied_path}
'''

if 'Proxy-Connection' in self.request.headers:
Expand All @@ -154,29 +203,9 @@ async def proxy(self, port, proxied_path):
else:
body = None

client_uri = '{uri}:{port}{path}'.format(
uri='http://localhost',
port=port,
path=proxied_path
)
if self.request.query:
client_uri += '?' + self.request.query

client = httpclient.AsyncHTTPClient()

headers = self.proxy_request_headers()

# Some applications check X-Forwarded-Context and X-ProxyContextPath
# headers to see if and where they are being proxied from. We set
# them to be {base_url}/proxy/{port}.
headers['X-Forwarded-Context'] = headers['X-ProxyContextPath'] = \
url_path_join(self.base_url, 'proxy', str(port))

req = httpclient.HTTPRequest(
client_uri, method=self.request.method, body=body,
headers=headers,
**self.proxy_request_options())

req = self._build_proxy_request(port, proxied_path, body)
response = await client.fetch(req, raise_error=False)
# record activity at start and end of requests
self._record_activity()
Expand Down Expand Up @@ -378,10 +407,14 @@ def patch(self, path):
def options(self, path):
return self.proxy(self.port, path)


def setup_handlers(web_app):
host_pattern = '.*$'
web_app.add_handlers('.*', [
(url_path_join(web_app.settings['base_url'], r'/proxy/(\d+)(.*)'), LocalProxyHandler)
(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+)(.*)'),
LocalProxyHandler, {'absolute_url': True}),
])

# vim: set et ts=4 sw=4:
17 changes: 17 additions & 0 deletions tests/resources/httpinfo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from http.server import HTTPServer, BaseHTTPRequestHandler
import sys

class EchoRequestInfo(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/plain")
self.end_headers()
self.wfile.write('{}\n'.format(self.requestline).encode())
self.wfile.write('{}\n'.format(self.headers).encode())


if __name__ == '__main__':
port = int(sys.argv[1])
server_address = ('', port)
httpd = HTTPServer(server_address, EchoRequestInfo)
httpd.serve_forever()
10 changes: 10 additions & 0 deletions tests/resources/jupyter_server_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
c.ServerProxy.servers = {
'python-http': {
'command': ['python3', './tests/resources/httpinfo.py', '{port}'],
},
'python-http-abs': {
'command': ['python3', './tests/resources/httpinfo.py', '{port}'],
'absolute_url': True
},
}
#c.Application.log_level = 'DEBUG'
29 changes: 29 additions & 0 deletions tests/test_proxies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import os
from http.client import HTTPConnection

PORT = os.getenv('TEST_PORT', 8888)
TOKEN = os.getenv('JUPYTER_TOKEN', 'secret')


def request_get(port, path, token):
h = HTTPConnection('localhost', port, 10)
h.request('GET', '{}?token={}'.format(path, token))
return h.getresponse()


def test_server_proxy_non_absolute():
r = request_get(PORT, '/python-http/abc', TOKEN)
assert r.code == 200
s = r.read().decode('ascii')
assert s.startswith('GET /abc?token=')
assert 'X-Forwarded-Context: /python-http\n' in s
assert 'X-Proxycontextpath: /python-http\n' in s


def test_server_proxy_absolute():
r = request_get(PORT, '/python-http-abs/def', TOKEN)
assert r.code == 200
s = r.read().decode('ascii')
assert s.startswith('GET /python-http-abs/def?token=')
assert 'X-Forwarded-Context' not in s
assert 'X-Proxycontextpath' not in s