Skip to content

Commit 8929f60

Browse files
committed
Allow proxying websockets over simple stream connections
This incorporates the functionality of websockify. In doing so, it eliminates an extra proxy layer and process, improving performance with a fairly small amount of code. Fixes #75. This also generalizes and combines the _make_namedproxy_handler and _make_supervisedproxy_handler functions into a single case to allow more flexibility.
1 parent ecb8143 commit 8929f60

File tree

3 files changed

+141
-40
lines changed

3 files changed

+141
-40
lines changed

docs/source/server-process.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,18 @@ One of:
153153

154154
(server-process:callable-arguments)=
155155

156+
### `websockify`
157+
158+
_True_ to proxy only websocket connections into raw stream connections.
159+
_False_ (default) if the proxied server speaks full HTTP.
160+
161+
If _True_, the proxied server is treated a raw TCP (or unix socket) server that
162+
does not use HTTP.
163+
In this mode, only websockets are handled, and messages are sent to the backend
164+
server as stream data. This is equivalent to running a
165+
[websockify](https://github.com/novnc/websockify) wrapper.
166+
All other HTTP requests return 405.
167+
156168
#### Callable arguments
157169

158170
Any time you specify a callable in the config, it can ask for any arguments it needs

jupyter_server_proxy/config.py

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from traitlets.config import Configurable
1616

1717
from .handlers import AddSlashHandler, NamedLocalProxyHandler, SuperviseAndProxyHandler
18+
from .websockify import WebsockifyHandler, SuperviseAndWebsockifyHandler
1819

1920
try:
2021
# Traitlets >= 4.3.3
@@ -41,52 +42,55 @@
4142
"new_browser_tab",
4243
"request_headers_override",
4344
"rewrite_response",
45+
"websockify",
4446
],
4547
)
4648

4749

48-
def _make_namedproxy_handler(sp: ServerProcess):
49-
class _Proxy(NamedLocalProxyHandler):
50-
def __init__(self, *args, **kwargs):
51-
super().__init__(*args, **kwargs)
52-
self.name = sp.name
53-
self.proxy_base = sp.name
54-
self.absolute_url = sp.absolute_url
55-
self.port = sp.port
56-
self.unix_socket = sp.unix_socket
57-
self.mappath = sp.mappath
58-
self.rewrite_response = sp.rewrite_response
59-
60-
def get_request_headers_override(self):
61-
return self._realize_rendered_template(sp.request_headers_override)
62-
63-
return _Proxy
64-
65-
66-
def _make_supervisedproxy_handler(sp: ServerProcess):
50+
def _make_proxy_handler(sp: ServerProcess):
6751
"""
68-
Create a SuperviseAndProxyHandler subclass with given parameters
52+
Create an appropriate handler with given parameters
6953
"""
54+
if sp.command:
55+
cls = SuperviseAndWebsockifyHandler if sp.websockify else SuperviseAndProxyHandler
56+
args = dict(state={})
57+
elif not (sp.port or isinstance(sp.unix_socket, str)):
58+
warn(
59+
f"Server proxy {sp.name} does not have a command, port "
60+
f"number or unix_socket path. At least one of these is "
61+
f"required."
62+
)
63+
return
64+
else:
65+
cls = WebsockifyHandler if sp.websockify else NamedLocalProxyHandler
66+
args = {}
7067

7168
# FIXME: Set 'name' properly
72-
class _Proxy(SuperviseAndProxyHandler):
69+
class _Proxy(cls):
70+
kwargs = args
71+
7372
def __init__(self, *args, **kwargs):
7473
super().__init__(*args, **kwargs)
7574
self.name = sp.name
7675
self.command = sp.command
7776
self.proxy_base = sp.name
7877
self.absolute_url = sp.absolute_url
79-
self.requested_port = sp.port
80-
self.requested_unix_socket = sp.unix_socket
78+
if sp.command:
79+
self.requested_port = sp.port
80+
self.requested_unix_socket = sp.unix_socket
81+
else:
82+
self.port = sp.port
83+
self.unix_socket = sp.unix_socket
8184
self.mappath = sp.mappath
8285
self.rewrite_response = sp.rewrite_response
8386

84-
def get_env(self):
85-
return self._realize_rendered_template(sp.environment)
86-
8787
def get_request_headers_override(self):
8888
return self._realize_rendered_template(sp.request_headers_override)
8989

90+
# these two methods are only used in supervise classes, but do no harm otherwise
91+
def get_env(self):
92+
return self._realize_rendered_template(sp.environment)
93+
9094
def get_timeout(self):
9195
return sp.timeout
9296

@@ -108,24 +112,14 @@ def make_handlers(base_url, server_processes):
108112
"""
109113
handlers = []
110114
for sp in server_processes:
111-
if sp.command:
112-
handler = _make_supervisedproxy_handler(sp)
113-
kwargs = dict(state={})
114-
else:
115-
if not (sp.port or isinstance(sp.unix_socket, str)):
116-
warn(
117-
f"Server proxy {sp.name} does not have a command, port "
118-
f"number or unix_socket path. At least one of these is "
119-
f"required."
120-
)
121-
continue
122-
handler = _make_namedproxy_handler(sp)
123-
kwargs = {}
115+
handler = _make_proxy_handler(sp)
116+
if not handler:
117+
continue
124118
handlers.append(
125119
(
126120
ujoin(base_url, sp.name, r"(.*)"),
127121
handler,
128-
kwargs,
122+
handler.kwargs
129123
)
130124
)
131125
handlers.append((ujoin(base_url, sp.name), AddSlashHandler))
@@ -157,6 +151,7 @@ def make_server_process(name, server_process_config, serverproxy_config):
157151
"rewrite_response",
158152
tuple(),
159153
),
154+
websockify=server_process_config.get("websockify", False),
160155
)
161156

162157

@@ -273,6 +268,12 @@ def cats_only(response, path):
273268
instead of "dogs not allowed".
274269
275270
Defaults to the empty tuple ``tuple()``.
271+
272+
websockify
273+
Proxy websocket requests as a TCP (or unix socket) stream.
274+
In this mode, only websockets are handled, and messages are sent to the backend,
275+
equivalent to running a websockify layer (https://github.com/novnc/websockify).
276+
All other HTTP requests return 405.
276277
""",
277278
config=True,
278279
)

jupyter_server_proxy/websockify.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
A simple translation layer between tornado websockets and asyncio stream
3+
connections.
4+
5+
This subsumes the functionality of websockify
6+
(https://github.com/novnc/websockify) without needing an extra proxy hop
7+
or process through with all messages pass for translation.
8+
"""
9+
10+
import asyncio
11+
12+
from .handlers import NamedLocalProxyHandler, SuperviseAndProxyHandler
13+
14+
class WebsockifyProtocol(asyncio.Protocol):
15+
"""
16+
A protocol handler for the proxied stream connection.
17+
Sends any received blocks directly as websocket messages.
18+
"""
19+
def __init__(self, handler):
20+
self.handler = handler
21+
22+
def data_received(self, data):
23+
"Send the buffer as a websocket message."
24+
self.handler._record_activity()
25+
self.handler.write_message(data, binary=True) # async, no wait
26+
27+
def connection_lost(self, exc):
28+
"Close the websocket connection."
29+
self.handler.log.info(f"Websockify {self.handler.name} connection lost: {exc}")
30+
self.handler.close()
31+
32+
class WebsockifyHandler(NamedLocalProxyHandler):
33+
"""
34+
HTTP handler that proxies websocket connections into a backend stream.
35+
All other HTTP requests return 405.
36+
"""
37+
def _create_ws_connection(self, proto: asyncio.BaseProtocol):
38+
"Create the appropriate backend asyncio connection"
39+
loop = asyncio.get_running_loop()
40+
if self.unix_socket is not None:
41+
self.log.info(f"Websockify {self.name} connecting to {self.unix_socket}")
42+
return loop.create_unix_connection(proto, self.unix_socket)
43+
else:
44+
self.log.info(f"Websockify {self.name} connecting to port {self.port}")
45+
return loop.create_connection(proto, 'localhost', self.port)
46+
47+
async def proxy(self, port, path):
48+
raise web.HTTPError(405, "websockets only")
49+
50+
async def proxy_open(self, host, port, proxied_path=""):
51+
"""
52+
Open the backend connection. host and port are ignored (as they are in
53+
the parent for unix sockets) since they are always passed known values.
54+
"""
55+
transp, proto = await self._create_ws_connection(lambda: WebsockifyProtocol(self))
56+
self.ws_transp = transp
57+
self.ws_proto = proto
58+
self._record_activity()
59+
self.log.info(f"Websockify {self.name} connected")
60+
61+
def on_message(self, message):
62+
"Send websocket messages as stream writes, encoding if necessary."
63+
self._record_activity()
64+
if hasattr(self, "ws_transp"):
65+
if isinstance(message, str):
66+
message = message.encode('utf-8')
67+
self.ws_transp.write(message) # buffered non-blocking. should block?
68+
69+
def on_ping(self, message):
70+
"No-op"
71+
self._record_activity()
72+
73+
def on_close(self):
74+
"Close the backend connection."
75+
self.log.info(f"Websockify {self.name} connection closed")
76+
if hasattr(self, "ws_transp"):
77+
self.ws_transp.close()
78+
79+
class SuperviseAndWebsockifyHandler(SuperviseAndProxyHandler, WebsockifyHandler):
80+
async def _http_ready_func(self, p):
81+
# not really HTTP here, just try an empty connection
82+
try:
83+
transp, _ = await self._create_ws_connection(asyncio.Protocol)
84+
except OSError as exc:
85+
self.log.debug(f"Websockify {self.name} connection check failed: {exc}")
86+
return False
87+
transp.close()
88+
return True

0 commit comments

Comments
 (0)