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 #84

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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, rewrite):
"""
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.rewrite = rewrite

@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.rewrite,
)
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', 'rewrite', '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),
rewrite=server_process_config.get('rewrite', '/'),
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.

rewrite
Proxy requests default to being rewritten to '/'. If this is ''
(empty) 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
)
)
85 changes: 59 additions & 26 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.rewrite = kwargs.pop('rewrite', '/')
super().__init__(*args, **kwargs)

async def open(self, port, proxied_path=''):
"""
Called when a client opens a websocket connection.
Expand Down Expand Up @@ -126,14 +132,57 @@ 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.rewrite == '/':
return url_path_join(self.base_url, 'proxy', str(port))
if self.rewrite == '':
return url_path_join(self.base_url, 'proxy', 'absolute', str(port))
raise ValueError('Unsupported rewrite: "{}"'.format(self.rewrite))

def _build_proxy_request(self, port, proxied_path, body):
context_path = self._get_context_path(port)
if self.rewrite:
client_path = proxied_path
else:
client_path = url_path_join(context_path, proxied_path)

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

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.
if self.rewrite == '/':
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 @@ -376,10 +405,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, {'rewrite': '/'}),
(url_path_join(web_app.settings['base_url'], r'/proxy/absolute/(\d+)(.*)'),
LocalProxyHandler, {'rewrite': ''}),
])

# 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}'],
'rewrite': '',
},
}
#c.Application.log_level = 'DEBUG'
32 changes: 32 additions & 0 deletions tests/test_proxies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#from submodule import

#def setup_module(module):
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_rewrite():
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