From a1d2280e1c1ed86aaef32f4a91d2662a3fd8568d Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Sat, 3 Feb 2024 19:10:39 -0800 Subject: [PATCH 1/3] Switch to using a Server Extension I want to provide a button to the Hub Control Panel when we are running under a JupyterHub. This requires conditional rendering in some way - either we render the initial HTML as a template, or setup an API endpoint that JS can use. This PR templatizes the initial rendering. The following changes are made: 1. Convert into a Jupyter Server extension. This gives us the flexibility we need here. 2. Inherit a handler directly from jupyter_server_proxy, enabling us to use the *same* URL for both the initial HTTP rendering and the websockify! However, this means we lose the launcher icon - let's try mitigate that somehow. --- Dockerfile | 3 +- js/index.js | 4 +- .../jupyter_remote_desktop_proxy.json | 7 ++ .../jupyter_remote_desktop_proxy.json | 7 ++ jupyter_remote_desktop_proxy/__init__.py | 67 +++------------ jupyter_remote_desktop_proxy/handlers.py | 84 +++++++++++++++++++ .../server_extension.py | 29 +++++++ .../{static => templates}/index.html | 11 ++- setup.py | 19 +++-- 9 files changed, 163 insertions(+), 68 deletions(-) create mode 100644 jupyter-config/jupyter_notebook_config.d/jupyter_remote_desktop_proxy.json create mode 100644 jupyter-config/jupyter_server_config.d/jupyter_remote_desktop_proxy.json create mode 100644 jupyter_remote_desktop_proxy/handlers.py create mode 100644 jupyter_remote_desktop_proxy/server_extension.py rename jupyter_remote_desktop_proxy/{static => templates}/index.html (77%) diff --git a/Dockerfile b/Dockerfile index bc7edca3..87a8c9fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,4 +31,5 @@ RUN . /opt/conda/bin/activate && \ COPY --chown=$NB_UID:$NB_GID . /opt/install RUN . /opt/conda/bin/activate && \ - pip install -e /opt/install + pip install -e /opt/install && \ + jupyter server extension enable jupyter_remote_desktop_proxy diff --git a/js/index.js b/js/index.js index 1c054a29..cb1e187a 100644 --- a/js/index.js +++ b/js/index.js @@ -30,8 +30,8 @@ function status(text) { document.getElementById("status").textContent = text; } -// Construct the websockify websocket URL we want to connect to -let websockifyUrl = new URL("websockify", window.location); +// The websockify URL to connect to is the same URL we are rendering this page on! +let websockifyUrl = new URL(window.location); websockifyUrl.protocol = window.location.protocol === "https:" ? "wss" : "ws"; // Creating a new RFB object will start a new connection diff --git a/jupyter-config/jupyter_notebook_config.d/jupyter_remote_desktop_proxy.json b/jupyter-config/jupyter_notebook_config.d/jupyter_remote_desktop_proxy.json new file mode 100644 index 00000000..4c83e333 --- /dev/null +++ b/jupyter-config/jupyter_notebook_config.d/jupyter_remote_desktop_proxy.json @@ -0,0 +1,7 @@ +{ + "NotebookApp": { + "nbserver_extensions": { + "jupyter_remote_desktop_proxy": true + } + } +} diff --git a/jupyter-config/jupyter_server_config.d/jupyter_remote_desktop_proxy.json b/jupyter-config/jupyter_server_config.d/jupyter_remote_desktop_proxy.json new file mode 100644 index 00000000..9a00ed40 --- /dev/null +++ b/jupyter-config/jupyter_server_config.d/jupyter_remote_desktop_proxy.json @@ -0,0 +1,7 @@ +{ + "ServerApp": { + "jpserver_extensions": { + "jupyter_remote_desktop_proxy": true + } + } +} diff --git a/jupyter_remote_desktop_proxy/__init__.py b/jupyter_remote_desktop_proxy/__init__.py index c9d9c764..3a63d23d 100644 --- a/jupyter_remote_desktop_proxy/__init__.py +++ b/jupyter_remote_desktop_proxy/__init__.py @@ -1,64 +1,17 @@ import os -import shlex -import tempfile -from shutil import which -HERE = os.path.dirname(os.path.abspath(__file__)) - - -def setup_desktop(): - # make a secure temporary directory for sockets - # This is only readable, writeable & searchable by our uid - sockets_dir = tempfile.mkdtemp() - sockets_path = os.path.join(sockets_dir, 'vnc-socket') - vncserver = which('vncserver') +from .server_extension import load_jupyter_server_extension - if vncserver is None: - # Use bundled tigervnc - vncserver = os.path.join(HERE, 'share/tigervnc/bin/vncserver') - - # TigerVNC provides the option to connect a Unix socket. TurboVNC does not. - # TurboVNC and TigerVNC share the same origin and both use a Perl script - # as the executable vncserver. We can determine if vncserver is TigerVNC - # by searching TigerVNC string in the Perl script. - with open(vncserver) as vncserver_file: - is_tigervnc = "TigerVNC" in vncserver_file.read() +HERE = os.path.dirname(os.path.abspath(__file__)) - if is_tigervnc: - vnc_args = [vncserver, '-rfbunixpath', sockets_path] - socket_args = ['--unix-target', sockets_path] - else: - vnc_args = [vncserver] - socket_args = [] - if not os.path.exists(os.path.expanduser('~/.vnc/xstartup')): - vnc_args.extend(['-xstartup', os.path.join(HERE, 'share/xstartup')]) +def _jupyter_server_extension_points(): + """ + Set up the server extension for collecting metrics + """ + return [{"module": "jupyter_remote_desktop_proxy"}] - vnc_command = shlex.join( - vnc_args - + [ - '-verbose', - '-geometry', - '1680x1050', - '-SecurityTypes', - 'None', - '-fg', - ] - ) - return { - 'command': [ - 'websockify', - '-v', - '--web', - os.path.join(HERE, 'static'), - '--heartbeat', - '30', - '{port}', - ] - + socket_args - + ['--', '/bin/sh', '-c', f'cd {os.getcwd()} && {vnc_command}'], - 'timeout': 30, - 'mappath': {'/': '/index.html'}, - 'new_browser_window': True, - } +# For backward compatibility +_load_jupyter_server_extension = load_jupyter_server_extension +_jupyter_server_extension_paths = _jupyter_server_extension_points diff --git a/jupyter_remote_desktop_proxy/handlers.py b/jupyter_remote_desktop_proxy/handlers.py new file mode 100644 index 00000000..08628733 --- /dev/null +++ b/jupyter_remote_desktop_proxy/handlers.py @@ -0,0 +1,84 @@ +import os +import shlex +import tempfile +from shutil import which + +import jinja2 +from jupyter_server_proxy.handlers import SuperviseAndProxyHandler +from tornado import web + +jinja_env = jinja2.Environment( + loader=jinja2.FileSystemLoader( + os.path.join(os.path.dirname(__file__), 'templates') + ), +) + + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def get_websockify_command(): + # make a secure temporary directory for sockets + # This is only readable, writeable & searchable by our uid + sockets_dir = tempfile.mkdtemp() + sockets_path = os.path.join(sockets_dir, 'vnc-socket') + vncserver = which('vncserver') + + if vncserver is None: + # Use bundled tigervnc + vncserver = os.path.join(HERE, 'share/tigervnc/bin/vncserver') + + # TigerVNC provides the option to connect a Unix socket. TurboVNC does not. + # TurboVNC and TigerVNC share the same origin and both use a Perl script + # as the executable vncserver. We can determine if vncserver is TigerVNC + # by searching TigerVNC string in the Perl script. + with open(vncserver) as vncserver_file: + is_tigervnc = "TigerVNC" in vncserver_file.read() + + if is_tigervnc: + vnc_args = [vncserver, '-rfbunixpath', sockets_path] + socket_args = ['--unix-target', sockets_path] + else: + vnc_args = [vncserver] + socket_args = [] + + if not os.path.exists(os.path.expanduser('~/.vnc/xstartup')): + vnc_args.extend(['-xstartup', os.path.join(HERE, 'share/xstartup')]) + + vnc_command = shlex.join( + vnc_args + + [ + '-verbose', + '-geometry', + '1680x1050', + '-SecurityTypes', + 'None', + '-fg', + ] + ) + + return ( + [ + 'websockify', + '-v', + '--heartbeat', + '30', + '{port}', + ] + + socket_args + + ['--', '/bin/sh', '-c', f'cd {os.getcwd()} && {vnc_command}'] + ) + + +class DesktopHandler(SuperviseAndProxyHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.command = get_websockify_command() + + @web.authenticated + async def http_get(self, *args, **kwargs): + template_params = { + 'base_url': self.base_url, + } + template_params.update(self.serverapp.jinja_template_vars) + self.write(jinja_env.get_template("index.html").render(**template_params)) diff --git a/jupyter_remote_desktop_proxy/server_extension.py b/jupyter_remote_desktop_proxy/server_extension.py new file mode 100644 index 00000000..9a86d9c1 --- /dev/null +++ b/jupyter_remote_desktop_proxy/server_extension.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from jupyter_server.base.handlers import AuthenticatedFileHandler +from jupyter_server.utils import url_path_join +from jupyter_server_proxy.handlers import AddSlashHandler + +from .handlers import DesktopHandler + +HERE = Path(__file__).parent + + +def load_jupyter_server_extension(server_app): + """ + Called during notebook start + """ + base_url = server_app.web_app.settings["base_url"] + + server_app.web_app.add_handlers( + ".*", + [ + ( + url_path_join(base_url, "/desktop/static/(.*)"), + AuthenticatedFileHandler, + {"path": (str(HERE / "static"))}, + ), + (url_path_join(base_url, "/desktop"), AddSlashHandler), + (url_path_join(base_url, "/desktop/()"), DesktopHandler, {'state': {}}), + ], + ) diff --git a/jupyter_remote_desktop_proxy/static/index.html b/jupyter_remote_desktop_proxy/templates/index.html similarity index 77% rename from jupyter_remote_desktop_proxy/static/index.html rename to jupyter_remote_desktop_proxy/templates/index.html index 688f02a7..827d925a 100644 --- a/jupyter_remote_desktop_proxy/static/index.html +++ b/jupyter_remote_desktop_proxy/templates/index.html @@ -13,13 +13,13 @@ Chrome Frame. --> - +
@@ -47,6 +52,6 @@
- + diff --git a/setup.py b/setup.py index f77d0edf..5107f853 100644 --- a/setup.py +++ b/setup.py @@ -58,11 +58,6 @@ def run(self): "Programming Language :: Python :: 3", ], description="Run a desktop environments on Jupyter", - entry_points={ - 'jupyter_serverproxy_servers': [ - 'desktop = jupyter_remote_desktop_proxy:setup_desktop', - ] - }, install_requires=[ 'jupyter-server-proxy>=1.4.0', ], @@ -85,4 +80,18 @@ def run(self): # Handles `pip install` directly "build_py": webpacked_command(build_py), }, + data_files=[ + ( + 'etc/jupyter/jupyter_server_config.d', + [ + 'jupyter-config/jupyter_server_config.d/jupyter_remote_desktop_proxy.json' + ], + ), + ( + 'etc/jupyter/jupyter_notebook_config.d', + [ + 'jupyter-config/jupyter_notebook_config.d/jupyter_remote_desktop_proxy.json' + ], + ), + ], ) From 831fe1f13f45b708b9a4f0c611412a5c9fca56d7 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Sat, 3 Feb 2024 19:40:17 -0800 Subject: [PATCH 2/3] Switch back to using entrypoints to serve desktop-websocket This is simpler than inheriting from the proxying handler from jupyter_server_proxy directly, and more importantly keeps our launcher entry simple and existing! --- js/index.js | 6 +- jupyter_remote_desktop_proxy/handlers.py | 66 +------------------ .../server_extension.py | 5 +- .../setup_websockify.py | 65 ++++++++++++++++++ setup.py | 7 +- 5 files changed, 82 insertions(+), 67 deletions(-) create mode 100644 jupyter_remote_desktop_proxy/setup_websockify.py diff --git a/js/index.js b/js/index.js index cb1e187a..72ae4474 100644 --- a/js/index.js +++ b/js/index.js @@ -30,8 +30,10 @@ function status(text) { document.getElementById("status").textContent = text; } -// The websockify URL to connect to is the same URL we are rendering this page on! -let websockifyUrl = new URL(window.location); +// This page is served under the /desktop/, and the websockify websocket is served +// under /desktop-websockify/ with the same base url as /desktop/. We resolve it relatively +// this way. +let websockifyUrl = new URL("../desktop-websockify/", window.location); websockifyUrl.protocol = window.location.protocol === "https:" ? "wss" : "ws"; // Creating a new RFB object will start a new connection diff --git a/jupyter_remote_desktop_proxy/handlers.py b/jupyter_remote_desktop_proxy/handlers.py index 08628733..3ba4e72b 100644 --- a/jupyter_remote_desktop_proxy/handlers.py +++ b/jupyter_remote_desktop_proxy/handlers.py @@ -1,10 +1,7 @@ import os -import shlex -import tempfile -from shutil import which import jinja2 -from jupyter_server_proxy.handlers import SuperviseAndProxyHandler +from jupyter_server.base.handlers import JupyterHandler from tornado import web jinja_env = jinja2.Environment( @@ -17,66 +14,9 @@ HERE = os.path.dirname(os.path.abspath(__file__)) -def get_websockify_command(): - # make a secure temporary directory for sockets - # This is only readable, writeable & searchable by our uid - sockets_dir = tempfile.mkdtemp() - sockets_path = os.path.join(sockets_dir, 'vnc-socket') - vncserver = which('vncserver') - - if vncserver is None: - # Use bundled tigervnc - vncserver = os.path.join(HERE, 'share/tigervnc/bin/vncserver') - - # TigerVNC provides the option to connect a Unix socket. TurboVNC does not. - # TurboVNC and TigerVNC share the same origin and both use a Perl script - # as the executable vncserver. We can determine if vncserver is TigerVNC - # by searching TigerVNC string in the Perl script. - with open(vncserver) as vncserver_file: - is_tigervnc = "TigerVNC" in vncserver_file.read() - - if is_tigervnc: - vnc_args = [vncserver, '-rfbunixpath', sockets_path] - socket_args = ['--unix-target', sockets_path] - else: - vnc_args = [vncserver] - socket_args = [] - - if not os.path.exists(os.path.expanduser('~/.vnc/xstartup')): - vnc_args.extend(['-xstartup', os.path.join(HERE, 'share/xstartup')]) - - vnc_command = shlex.join( - vnc_args - + [ - '-verbose', - '-geometry', - '1680x1050', - '-SecurityTypes', - 'None', - '-fg', - ] - ) - - return ( - [ - 'websockify', - '-v', - '--heartbeat', - '30', - '{port}', - ] - + socket_args - + ['--', '/bin/sh', '-c', f'cd {os.getcwd()} && {vnc_command}'] - ) - - -class DesktopHandler(SuperviseAndProxyHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.command = get_websockify_command() - +class DesktopHandler(JupyterHandler): @web.authenticated - async def http_get(self, *args, **kwargs): + async def get(self): template_params = { 'base_url': self.base_url, } diff --git a/jupyter_remote_desktop_proxy/server_extension.py b/jupyter_remote_desktop_proxy/server_extension.py index 9a86d9c1..a998f2a7 100644 --- a/jupyter_remote_desktop_proxy/server_extension.py +++ b/jupyter_remote_desktop_proxy/server_extension.py @@ -18,12 +18,15 @@ def load_jupyter_server_extension(server_app): server_app.web_app.add_handlers( ".*", [ + # Serve our own static files ( url_path_join(base_url, "/desktop/static/(.*)"), AuthenticatedFileHandler, {"path": (str(HERE / "static"))}, ), + # To simplify URL mapping, we make sure that /desktop/ always + # has a trailing slash (url_path_join(base_url, "/desktop"), AddSlashHandler), - (url_path_join(base_url, "/desktop/()"), DesktopHandler, {'state': {}}), + (url_path_join(base_url, "/desktop/"), DesktopHandler), ], ) diff --git a/jupyter_remote_desktop_proxy/setup_websockify.py b/jupyter_remote_desktop_proxy/setup_websockify.py new file mode 100644 index 00000000..9365f924 --- /dev/null +++ b/jupyter_remote_desktop_proxy/setup_websockify.py @@ -0,0 +1,65 @@ +import os +import shlex +import tempfile +from shutil import which + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def setup_websockify(): + # make a secure temporary directory for sockets + # This is only readable, writeable & searchable by our uid + sockets_dir = tempfile.mkdtemp() + sockets_path = os.path.join(sockets_dir, 'vnc-socket') + vncserver = which('vncserver') + + if vncserver is None: + # Use bundled tigervnc + vncserver = os.path.join(HERE, 'share/tigervnc/bin/vncserver') + + # TigerVNC provides the option to connect a Unix socket. TurboVNC does not. + # TurboVNC and TigerVNC share the same origin and both use a Perl script + # as the executable vncserver. We can determine if vncserver is TigerVNC + # by searching TigerVNC string in the Perl script. + with open(vncserver) as vncserver_file: + is_tigervnc = "TigerVNC" in vncserver_file.read() + + if is_tigervnc: + vnc_args = [vncserver, '-rfbunixpath', sockets_path] + socket_args = ['--unix-target', sockets_path] + else: + vnc_args = [vncserver] + socket_args = [] + + if not os.path.exists(os.path.expanduser('~/.vnc/xstartup')): + vnc_args.extend(['-xstartup', os.path.join(HERE, 'share/xstartup')]) + + vnc_command = shlex.join( + vnc_args + + [ + '-verbose', + '-geometry', + '1680x1050', + '-SecurityTypes', + 'None', + '-fg', + ] + ) + + return { + 'command': [ + 'websockify', + '-v', + '--heartbeat', + '30', + '{port}', + ] + + socket_args + + ['--', '/bin/sh', '-c', f'cd {os.getcwd()} && {vnc_command}'], + 'timeout': 30, + 'new_browser_window': True, + # We want the launcher entry to point to /desktop/, not to /desktop-websockify/ + # /desktop/ is the user facing URL, while /desktop-websockify/ now *only* serves + # websockets. + "launcher_entry": {"title": "Desktop", "path_info": "desktop"}, + } diff --git a/setup.py b/setup.py index 5107f853..ad636738 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,12 @@ def run(self): "Programming Language :: Python :: 3", ], description="Run a desktop environments on Jupyter", - install_requires=[ + entry_points={ + 'jupyter_serverproxy_servers': [ + 'desktop-websockify = jupyter_remote_desktop_proxy.setup_websockify:setup_websockify', + ] + }, + stall_requires=[ 'jupyter-server-proxy>=1.4.0', ], include_package_data=True, From 0fadd168477465519821bdec5425d35802171766 Mon Sep 17 00:00:00 2001 From: Yuvi Panda Date: Mon, 5 Feb 2024 13:01:30 -0800 Subject: [PATCH 3/3] Fix typo --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ad636738..8ac3a4a0 100644 --- a/setup.py +++ b/setup.py @@ -63,7 +63,7 @@ def run(self): 'desktop-websockify = jupyter_remote_desktop_proxy.setup_websockify:setup_websockify', ] }, - stall_requires=[ + install_requires=[ 'jupyter-server-proxy>=1.4.0', ], include_package_data=True,