Skip to content

Commit aad1ed9

Browse files
authored
Merge pull request #85 from manics/proxy-base-urls
Configure absolute and server URL prefixes
2 parents 6eea2b7 + 734630d commit aad1ed9

File tree

7 files changed

+167
-37
lines changed

7 files changed

+167
-37
lines changed

.travis.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ python:
33
- 3.5
44

55
script:
6-
- true
6+
- pip3 install .
7+
- pip3 install pytest
8+
- JUPYTER_TOKEN=secret jupyter-notebook --config=./tests/resources/jupyter_server_config.py &
9+
- sleep 5
10+
- pytest
711

812
deploy:
913
provider: pypi

docs/server-process.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@ pairs.
5656
* A callable that takes any :ref:`callable arguments <server-process/callable-argument>`,
5757
and returns a dictionary of strings that are used & treated same as above.
5858

59+
#. **absolute_url**
60+
61+
*True* if the URL as seen by the proxied application should be the full URL
62+
sent by the user. *False* if the URL as seen by the proxied application should
63+
see the URL after the parts specific to jupyter-server-proxy have been stripped.
64+
65+
For example, with the following config:
66+
67+
.. code:: python
68+
69+
c.ServerProxy.servers = {
70+
'test-server': {
71+
'command': ['python3', '-m', 'http.server', '{port}'],
72+
'absolute_url': False
73+
}
74+
}
75+
76+
When a user requests ``/test-server/some-url``, the proxied server will see it
77+
as a request for ``/some-url`` - the ``/test-server`` part is stripped out.
78+
79+
If ``absolute_url`` is set to ``True`` instead, the proxied server will see it
80+
as a request for ``/test-server/some-url`` instead - without any stripping.
81+
82+
This is very useful with applications that require a ``base_url`` to be set.
83+
84+
Defaults to *False*.
85+
86+
5987
#. **launcher_entry**
6088

6189
A dictionary with options on if / how an entry in the classic Jupyter Notebook

jupyter_server_proxy/config.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from collections import namedtuple
1010
from .utils import call_with_asked_args
1111

12-
def _make_serverproxy_handler(name, command, environment, timeout):
12+
def _make_serverproxy_handler(name, command, environment, timeout, absolute_url):
1313
"""
1414
Create a SuperviseAndProxyHandler subclass with given parameters
1515
"""
@@ -18,6 +18,8 @@ class _Proxy(SuperviseAndProxyHandler):
1818
def __init__(self, *args, **kwargs):
1919
super().__init__(*args, **kwargs)
2020
self.name = name
21+
self.proxy_base = name
22+
self.absolute_url = absolute_url
2123

2224
@property
2325
def process_args(self):
@@ -77,6 +79,7 @@ def make_handlers(base_url, server_processes):
7779
sp.command,
7880
sp.environment,
7981
sp.timeout,
82+
sp.absolute_url,
8083
)
8184
handlers.append((
8285
ujoin(base_url, sp.name, r'(.*)'), handler, dict(state={}),
@@ -87,7 +90,8 @@ def make_handlers(base_url, server_processes):
8790
return handlers
8891

8992
LauncherEntry = namedtuple('LauncherEntry', ['enabled', 'icon_path', 'title'])
90-
ServerProcess = namedtuple('ServerProcess', ['name', 'command', 'environment', 'timeout', 'launcher_entry'])
93+
ServerProcess = namedtuple('ServerProcess', [
94+
'name', 'command', 'environment', 'timeout', 'absolute_url', 'launcher_entry'])
9195

9296
def make_server_process(name, server_process_config):
9397
le = server_process_config.get('launcher_entry', {})
@@ -96,6 +100,7 @@ def make_server_process(name, server_process_config):
96100
command=server_process_config['command'],
97101
environment=server_process_config.get('environment', {}),
98102
timeout=server_process_config.get('timeout', 5),
103+
absolute_url=server_process_config.get('absolute_url', False),
99104
launcher_entry=LauncherEntry(
100105
enabled=le.get('enabled', True),
101106
icon_path=le.get('icon_path'),
@@ -129,6 +134,10 @@ class ServerProxy(Configurable):
129134
timeout
130135
Timeout in seconds for the process to become ready, default 5s.
131136
137+
absolute_url
138+
Proxy requests default to being rewritten to '/'. If this is True,
139+
the absolute URL will be sent to the backend instead.
140+
132141
launcher_entry
133142
A dictionary of various options for entries in classic notebook / jupyterlab launchers.
134143
@@ -146,4 +155,4 @@ class ServerProxy(Configurable):
146155
Title to be used for the launcher entry. Defaults to the name of the server if missing.
147156
""",
148157
config=True
149-
)
158+
)

jupyter_server_proxy/handlers.py

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ def get(self, *args):
2929
self.redirect(urlunparse(dest))
3030

3131
class LocalProxyHandler(WebSocketHandlerMixin, IPythonHandler):
32+
33+
def __init__(self, *args, **kwargs):
34+
self.proxy_base = ''
35+
self.absolute_url = kwargs.pop('absolute_url', False)
36+
super().__init__(*args, **kwargs)
37+
3238
async def open(self, port, proxied_path=''):
3339
"""
3440
Called when a client opens a websocket connection.
@@ -39,13 +45,7 @@ async def open(self, port, proxied_path=''):
3945
if not proxied_path.startswith('/'):
4046
proxied_path = '/' + proxied_path
4147

42-
client_uri = '{uri}:{port}{path}'.format(
43-
uri='ws://127.0.0.1',
44-
port=port,
45-
path=proxied_path
46-
)
47-
if self.request.query:
48-
client_uri += '?' + self.request.query
48+
client_uri = self.get_client_uri('ws', port, proxied_path)
4949
headers = self.request.headers
5050

5151
def message_cb(message):
@@ -126,14 +126,63 @@ def _record_activity(self):
126126
"""
127127
self.settings['api_last_activity'] = utcnow()
128128

129+
def _get_context_path(self, port):
130+
"""
131+
Some applications need to know where they are being proxied from.
132+
This is either:
133+
- {base_url}/proxy/{port}
134+
- {base_url}/proxy/absolute/{port}
135+
- {base_url}/{proxy_base}
136+
"""
137+
if self.proxy_base:
138+
return url_path_join(self.base_url, self.proxy_base)
139+
if self.absolute_url:
140+
return url_path_join(self.base_url, 'proxy', 'absolute', str(port))
141+
else:
142+
return url_path_join(self.base_url, 'proxy', str(port))
143+
144+
def get_client_uri(self, protocol, port, proxied_path):
145+
context_path = self._get_context_path(port)
146+
if self.absolute_url:
147+
client_path = url_path_join(context_path, proxied_path)
148+
else:
149+
client_path = proxied_path
150+
151+
client_uri = '{protocol}://{host}:{port}{path}'.format(
152+
protocol=protocol,
153+
host='localhost',
154+
port=port,
155+
path=client_path
156+
)
157+
if self.request.query:
158+
client_uri += '?' + self.request.query
159+
160+
return client_uri
161+
162+
def _build_proxy_request(self, port, proxied_path, body):
163+
164+
headers = self.proxy_request_headers()
165+
166+
client_uri = self.get_client_uri('http', port, proxied_path)
167+
# Some applications check X-Forwarded-Context and X-ProxyContextPath
168+
# headers to see if and where they are being proxied from.
169+
if not self.absolute_url:
170+
context_path = self._get_context_path(port)
171+
headers['X-Forwarded-Context'] = context_path
172+
headers['X-ProxyContextPath'] = context_path
173+
174+
req = httpclient.HTTPRequest(
175+
client_uri, method=self.request.method, body=body,
176+
headers=headers, **self.proxy_request_options())
177+
return req
129178

130179
@web.authenticated
131180
async def proxy(self, port, proxied_path):
132181
'''
133-
While self.request.uri is
134-
(hub) /user/username/proxy/([0-9]+)/something.
135-
(single) /proxy/([0-9]+)/something
136-
This serverextension is given {port}/{everything/after}.
182+
This serverextension handles:
183+
{base_url}/proxy/{port([0-9]+)}/{proxied_path}
184+
{base_url}/proxy/absolute/{port([0-9]+)}/{proxied_path}
185+
{base_url}/{proxy_base}/{proxied_path}
137186
'''
138187

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

157-
client_uri = '{uri}:{port}{path}'.format(
158-
uri='http://localhost',
159-
port=port,
160-
path=proxied_path
161-
)
162-
if self.request.query:
163-
client_uri += '?' + self.request.query
164-
165206
client = httpclient.AsyncHTTPClient()
166207

167-
headers = self.proxy_request_headers()
168-
169-
# Some applications check X-Forwarded-Context and X-ProxyContextPath
170-
# headers to see if and where they are being proxied from. We set
171-
# them to be {base_url}/proxy/{port}.
172-
headers['X-Forwarded-Context'] = headers['X-ProxyContextPath'] = \
173-
url_path_join(self.base_url, 'proxy', str(port))
174-
175-
req = httpclient.HTTPRequest(
176-
client_uri, method=self.request.method, body=body,
177-
headers=headers,
178-
**self.proxy_request_options())
179-
208+
req = self._build_proxy_request(port, proxied_path, body)
180209
response = await client.fetch(req, raise_error=False)
181210
# record activity at start and end of requests
182211
self._record_activity()
@@ -378,10 +407,14 @@ def patch(self, path):
378407
def options(self, path):
379408
return self.proxy(self.port, path)
380409

410+
381411
def setup_handlers(web_app):
382412
host_pattern = '.*$'
383413
web_app.add_handlers('.*', [
384-
(url_path_join(web_app.settings['base_url'], r'/proxy/(\d+)(.*)'), LocalProxyHandler)
414+
(url_path_join(web_app.settings['base_url'], r'/proxy/(\d+)(.*)'),
415+
LocalProxyHandler, {'absolute_url': False}),
416+
(url_path_join(web_app.settings['base_url'], r'/proxy/absolute/(\d+)(.*)'),
417+
LocalProxyHandler, {'absolute_url': True}),
385418
])
386419

387420
# vim: set et ts=4 sw=4:

tests/resources/httpinfo.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from http.server import HTTPServer, BaseHTTPRequestHandler
2+
import sys
3+
4+
class EchoRequestInfo(BaseHTTPRequestHandler):
5+
def do_GET(self):
6+
self.send_response(200)
7+
self.send_header("Content-type", "text/plain")
8+
self.end_headers()
9+
self.wfile.write('{}\n'.format(self.requestline).encode())
10+
self.wfile.write('{}\n'.format(self.headers).encode())
11+
12+
13+
if __name__ == '__main__':
14+
port = int(sys.argv[1])
15+
server_address = ('', port)
16+
httpd = HTTPServer(server_address, EchoRequestInfo)
17+
httpd.serve_forever()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
c.ServerProxy.servers = {
2+
'python-http': {
3+
'command': ['python3', './tests/resources/httpinfo.py', '{port}'],
4+
},
5+
'python-http-abs': {
6+
'command': ['python3', './tests/resources/httpinfo.py', '{port}'],
7+
'absolute_url': True
8+
},
9+
}
10+
#c.Application.log_level = 'DEBUG'

tests/test_proxies.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import os
2+
from http.client import HTTPConnection
3+
4+
PORT = os.getenv('TEST_PORT', 8888)
5+
TOKEN = os.getenv('JUPYTER_TOKEN', 'secret')
6+
7+
8+
def request_get(port, path, token):
9+
h = HTTPConnection('localhost', port, 10)
10+
h.request('GET', '{}?token={}'.format(path, token))
11+
return h.getresponse()
12+
13+
14+
def test_server_proxy_non_absolute():
15+
r = request_get(PORT, '/python-http/abc', TOKEN)
16+
assert r.code == 200
17+
s = r.read().decode('ascii')
18+
assert s.startswith('GET /abc?token=')
19+
assert 'X-Forwarded-Context: /python-http\n' in s
20+
assert 'X-Proxycontextpath: /python-http\n' in s
21+
22+
23+
def test_server_proxy_absolute():
24+
r = request_get(PORT, '/python-http-abs/def', TOKEN)
25+
assert r.code == 200
26+
s = r.read().decode('ascii')
27+
assert s.startswith('GET /python-http-abs/def?token=')
28+
assert 'X-Forwarded-Context' not in s
29+
assert 'X-Proxycontextpath' not in s

0 commit comments

Comments
 (0)