From d0be2942c512e7b6fa8cb425481d68a519b96b13 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Tue, 10 Sep 2024 16:54:12 +0200 Subject: [PATCH 01/21] Merge jhsingle-native-proxy. --- jupyter_server_proxy/native_proxy.py | 473 +++++++++++++++++++++++++++ pyproject.toml | 6 + 2 files changed, 479 insertions(+) create mode 100644 jupyter_server_proxy/native_proxy.py diff --git a/jupyter_server_proxy/native_proxy.py b/jupyter_server_proxy/native_proxy.py new file mode 100644 index 00000000..230d35d0 --- /dev/null +++ b/jupyter_server_proxy/native_proxy.py @@ -0,0 +1,473 @@ +import argparse +import json +import logging +import os +import re +from datetime import datetime +from urllib.parse import urlparse + +from jupyterhub import __version__ as __jh_version__ +from jupyterhub.services.auth import HubOAuthCallbackHandler +from jupyterhub.utils import exponential_backoff, isoformat, make_ssl_context +from tornado import httpclient, ioloop +from tornado.httpserver import HTTPServer +from tornado.log import app_log +from tornado.web import Application, RedirectHandler, RequestHandler + +from .handlers import SuperviseAndProxyHandler + + +def configure_http_client(): + keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE", "") + certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE", "") + client_ca = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA", "") + + if keyfile == "" and certfile == "" and client_ca == "": + return + + ssl_context = make_ssl_context(keyfile, certfile, cafile=client_ca) + httpclient.AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context}) + + +def _make_native_proxy_handler(command, environment, port, mappath): + """ + Create a SuperviseAndProxyHandler subclass with given parameters + """ + + class _Proxy(SuperviseAndProxyHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.proxy_base = command[0] + self.requested_port = port + self.mappath = mappath + self.command = command + + # ToDo? + self.origin_host = None + + # ToDo! + def prepare(self, *args, **kwargs): + pass + + # ToDo! + def skip_check_origin(self) -> bool: + return True + + @property + def process_args(self): + return { + "port": self.port, + "base_url": self.base_url, + # "presentation_path": self.presentation_path, + # "presentation_basename": self.presentation_basename, + # "presentation_dirname": self.presentation_dirname, + "origin_host": self.request.host, # ToDo! + "-": "-", + "--": "--", + } + + @property + def base_url(self): + return self.settings.get("base_url", "/") + + # @property + # def presentation_path(self): + # return self.settings.get("presentation_path", ".") + # + # @property + # def presentation_basename(self): + # return self.settings.get("presentation_basename", "") + # + # @property + # def presentation_dirname(self): + # return self.settings.get("presentation_dirname", ".") + + @property + def hub_users(self): + return {self.settings["user"]} + + @property + def hub_groups(self): + if self.settings["group"]: + return {self.settings["group"]} + return set() + + @property + def allow_all(self): + if "anyone" in self.settings: + return self.settings["anyone"] == "1" + return super().allow_all + + def _render_template(self, value): + args = self.process_args + if type(value) is str: + return value.format(**args) + elif type(value) is list: + return [self._render_template(v) for v in value] + elif type(value) is dict: + return { + self._render_template(k): self._render_template(v) + for k, v in value.items() + } + else: + raise ValueError(f"Value of unrecognized type {type(value)}") + + def get_env(self): + if callable(environment): + raise Exception( + "return self._render_template(call_with_asked_args(environment, self.process_args))" + ) + else: + return self._render_template(environment) + + def get_timeout(self): + return 60 + + return _Proxy + + +def patch_default_headers(): + if hasattr(RequestHandler, "_orig_set_default_headers"): + return + RequestHandler._orig_set_default_headers = RequestHandler.set_default_headers + + def set_jupyterhub_header(self): + self._orig_set_default_headers() + self.set_header("X-JupyterHub-Version", __jh_version__) + + RequestHandler.set_default_headers = set_jupyterhub_header + + +def make_app( + destport, + prefix, + command, + authtype, + request_timeout, + debug, + logs, + forward_user_info, + query_user_info, + progressive, + websocket_max_message_size, +): + # ToDo: Presentation_path? + # presentation_basename = "" + # presentation_dirname = "" + # + # if presentation_path: + # if not os.path.isabs(presentation_path): + # presentation_path = os.path.join(os.getcwd(), presentation_path) + # presentation_basename = os.path.basename(presentation_path) + # presentation_dirname = os.path.dirname(presentation_path) + + patch_default_headers() + + proxy_handler = _make_native_proxy_handler(command, {}, destport, {}) + + options = dict( + debug=debug, + logs=logs, + cookie_secret=os.urandom(32), + user=os.environ.get("JUPYTERHUB_USER") or "", + group=os.environ.get("JUPYTERHUB_GROUP") or "", + anyone=os.environ.get("JUPYTERHUB_ANYONE") or "", + base_url=prefix, # This is a confusing name, sorry + # presentation_path=presentation_path, + # presentation_basename=presentation_basename, + # presentation_dirname=presentation_dirname, + request_timeout=request_timeout, + ) + + if websocket_max_message_size: + options["websocket_max_message_size"] = websocket_max_message_size + + return Application( + [ + ( + r"^" + re.escape(prefix) + r"/oauth_callback", + HubOAuthCallbackHandler, + ), + ( + r"^" + re.escape(prefix) + r"/(.*)", + proxy_handler, + dict( + state={}, + # ToDo: authtype=authtype, forward_user_info=forward_user_info, query_user_info=query_user_info, progressive=progressive + ), + ), + ( + r"^" + re.escape(prefix.replace("@", "%40")) + r"/(.*)", + RedirectHandler, + dict(url=prefix + "/{0}"), + ), + ], + **options, + ) + + +def get_ssl_options(): + ssl_options = {} + keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE") or "" + certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE") or "" + client_ca = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA") or "" + + if keyfile: + ssl_options["keyfile"] = keyfile + + if certfile: + ssl_options["certfile"] = certfile + + if client_ca: + ssl_options["ca_certs"] = client_ca + + if not ssl_options: + # None indicates no SSL config + ssl_options = None + else: + # SSL may be missing, so only import it if it"s to be used + import ssl + + # PROTOCOL_TLS selects the highest ssl/tls protocol version that both the client and + # server support. When PROTOCOL_TLS is not available use PROTOCOL_SSLv23. + # PROTOCOL_TLS is new in version 2.7.13, 3.5.3 and 3.6 + ssl_options.setdefault( + "ssl_version", getattr(ssl, "PROTOCOL_TLS", ssl.PROTOCOL_SSLv23) + ) + if ssl_options.get("ca_certs", False): + ssl_options.setdefault("cert_reqs", ssl.CERT_REQUIRED) + + return ssl_options + + +# https://github.com/jupyterhub/jupyterhub/blob/2.0.0rc3/jupyterhub/singleuser/mixins.py#L340-L349 +def get_port_from_env(): + if os.environ.get("JUPYTERHUB_SERVICE_URL"): + url = urlparse(os.environ["JUPYTERHUB_SERVICE_URL"]) + if url.port: + return url.port + elif url.scheme == "http": + return 80 + elif url.scheme == "https": + return 443 + return 8888 + + +def start_keep_alive(last_activity_interval, force_alive, settings): + client = httpclient.AsyncHTTPClient() + + hub_activity_url = os.environ.get("JUPYTERHUB_ACTIVITY_URL", "") + server_name = os.environ.get("JUPYTERHUB_SERVER_NAME", "") + api_token = os.environ.get("JUPYTERHUB_API_TOKEN", "") + + if api_token == "" or server_name == "" or hub_activity_url == "": + print( + "The following env vars are required to report activity back to the hub for keep alive: " + "JUPYTERHUB_ACTIVITY_URL ({}), JUPYTERHUB_SERVER_NAME({})".format( + hub_activity_url, server_name, api_token + ) + ) + return + + async def send_activity(): + async def notify(): + print("About to notify Hub of activity") + + last_activity_timestamp = None + + if force_alive: + last_activity_timestamp = datetime.utcnow() + else: + last_activity_timestamp = settings.get("api_last_activity", None) + + if last_activity_timestamp: + last_activity_timestamp = isoformat(last_activity_timestamp) + req = httpclient.HTTPRequest( + url=hub_activity_url, + method="POST", + headers={ + "Authorization": f"token {api_token}", + "Content-Type": "application/json", + }, + body=json.dumps( + { + "servers": { + server_name: {"last_activity": last_activity_timestamp} + }, + "last_activity": last_activity_timestamp, + } + ), + ) + try: + await client.fetch(req) + except Exception as e: + print(f"Error notifying Hub of activity: {e}") + return False + else: + return True + + return True # Nothing to report, so really it worked + + await exponential_backoff( + notify, + fail_message="Failed to notify Hub of activity", + start_wait=1, + max_wait=15, + timeout=60, + ) + + pc = ioloop.PeriodicCallback(send_activity, 1e3 * last_activity_interval, 0.1) + pc.start() + + +def run( + command: list[str], + port=None, + destport=0, + ip="localhost", + debug=False, + logs=True, + authtype="oauth", + request_timeout=300, + last_activity_interval=300, + force_alive=True, + forward_user_info=False, + query_user_info=False, + progressive=False, + websocket_max_message_size=0, +): + if port is None: + get_port_from_env() + + if debug: + app_log.setLevel(logging.DEBUG) + elif logs: + app_log.setLevel(logging.INFO) + + prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") + + if len(prefix) > 0 and prefix[-1] == "/": + prefix = prefix[:-1] + + configure_http_client() + + app = make_app( + destport, + prefix, + list(command), + authtype, + request_timeout, + debug, + logs, + forward_user_info, + query_user_info, + progressive, + websocket_max_message_size, + ) + + ssl_options = get_ssl_options() + + http_server = HTTPServer(app, ssl_options=ssl_options, xheaders=True) + + http_server.listen(port or get_port_from_env(), ip) + + print( + f"Starting jhsingle-native-proxy server on address {ip} port {port}, proxying to port {destport}" + ) + print(f"URL Prefix: {prefix}") + print(f"Auth Type: {authtype}") + print(f"Command: {command}") + + if last_activity_interval > 0: + start_keep_alive(last_activity_interval, force_alive, app.settings) + + ioloop.IOLoop.current().start() + + +def main(): + parser = argparse.ArgumentParser( + "jupyter-native-proxy", + description="Wrap an arbitrary WebApp so it can be used in place of 'singleuser' in a JupyterHub setting", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "--port", + default=None, + type=int, + help="Port for the proxy server to listen on. Defaults to JupyterHub default.", + ) + parser.add_argument( + "--destport", + default=0, + type=int, + help="Port for the WebApp should end up running on. Leave at 0 for a random open port.", + ) + parser.add_argument("--ip", default="localhost", help="Address to listen on.") + parser.add_argument( + "--debug", action="store_true", default=False, help="Display debug level logs." + ) + parser.add_argument( + "--logs", + action="store_true", + default=True, + help="Display logs generated by the subprocess.", + ) + parser.add_argument( + "--authtype", + choices=["oauth", "none"], + default="oauth", + help="Authentication Metod.", + ) + parser.add_argument( + "--request-timeout", + default=300, + type=int, + help="Timeout for proxied HTTP calls to subprocess in seconds.", + ) + parser.add_argument( + "--last-activity-interval", + default=300, + type=int, + help="Frequency to notify Hub that the WebApp is still running in seconds. 0 for never.", + ) + parser.add_argument( + "--force-alive", + action="store_true", + default=True, + help="Always report, that there has been activity (force keep alive) - only if last-activity-interval > 0.", + ) + parser.add_argument( + "--forward-user-info", + action="store_true", + default=False, + help="Forward a 'X-CDSDASHBOARDS-JH-USER' HTTP header to process containing JupyterHub user data.", + ) + parser.add_argument( + "--query-user-info", + action="store_true", + default=False, + help="Add a 'CDSDASHBOARDS_JH_USER GET' query arg in HTTP request to process containing JupyterHub user data.", + ) + parser.add_argument( + "--progressive", + action="store_true", + default=False, + help="Progressively flush responses as they arrive (good for Voila).", + ) + parser.add_argument( + "--websocket-max-message-size", + default=0, + type=int, + help="Max size of websocket data (leave at 0 for library defaults).", + ) + parser.add_argument( + "command", nargs="+", help="The command executed for starting the WebApp" + ) + + args = parser.parse_args() + run(**vars(args)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 9334e5e6..84e97bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ acceptance = [ "jupyter-server-proxy[test]", "robotframework-jupyterlibrary >=0.4.2", ] +nativeproxy = [ + "jupyterhub" +] classic = [ "jupyter-server <2", "jupyterlab >=3.0.0,<4.0.0a0", @@ -79,6 +82,9 @@ Documentation = "https://jupyter-server-proxy.readthedocs.io" Source = "https://github.com/jupyterhub/jupyter-server-proxy" Tracker = "https://github.com/jupyterhub/jupyter-server-proxy/issues" +[project.scripts] +jupyter-nativeproxy = "jupyter_server_proxy.native_proxy:main" + # hatch ref: https://hatch.pypa.io/latest/ # From 26059b0a2d609376437d9f55e401e0c558fb7331 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Wed, 11 Sep 2024 11:56:57 +0200 Subject: [PATCH 02/21] Rename to `standalone` --- jupyter_server_proxy/standalone/__init__.py | 168 ++++++++++++++++++ .../{native_proxy.py => standalone/proxy.py} | 159 +---------------- pyproject.toml | 4 +- 3 files changed, 171 insertions(+), 160 deletions(-) create mode 100644 jupyter_server_proxy/standalone/__init__.py rename jupyter_server_proxy/{native_proxy.py => standalone/proxy.py} (69%) diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py new file mode 100644 index 00000000..87e87bf2 --- /dev/null +++ b/jupyter_server_proxy/standalone/__init__.py @@ -0,0 +1,168 @@ +import argparse +import logging +import os + +from tornado import ioloop +from tornado.httpserver import HTTPServer +from tornado.log import app_log + +from .proxy import ( + configure_http_client, + get_port_from_env, + get_ssl_options, + make_app, + start_keep_alive, +) + + +def run( + command: list[str], + port=None, + destport=0, + ip="localhost", + debug=False, + logs=True, + authtype="oauth", + request_timeout=300, + last_activity_interval=300, + force_alive=True, + forward_user_info=False, + query_user_info=False, + progressive=False, + websocket_max_message_size=0, +): + if port is None: + get_port_from_env() + + if debug: + app_log.setLevel(logging.DEBUG) + elif logs: + app_log.setLevel(logging.INFO) + + prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") + + if len(prefix) > 0 and prefix[-1] == "/": + prefix = prefix[:-1] + + configure_http_client() + + app = make_app( + destport, + prefix, + list(command), + authtype, + request_timeout, + debug, + logs, + forward_user_info, + query_user_info, + progressive, + websocket_max_message_size, + ) + + ssl_options = get_ssl_options() + + http_server = HTTPServer(app, ssl_options=ssl_options, xheaders=True) + + http_server.listen(port or get_port_from_env(), ip) + + print( + f"Starting jhsingle-native-proxy server on address {ip} port {port}, proxying to port {destport}" + ) + print(f"URL Prefix: {prefix}") + print(f"Auth Type: {authtype}") + print(f"Command: {command}") + + if last_activity_interval > 0: + start_keep_alive(last_activity_interval, force_alive, app.settings) + + ioloop.IOLoop.current().start() + + +def main(): + parser = argparse.ArgumentParser( + "jupyter-native-proxy", + description="Wrap an arbitrary WebApp so it can be used in place of 'singleuser' in a JupyterHub setting", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "--port", + default=None, + type=int, + help="Port for the proxy server to listen on. Defaults to JupyterHub default.", + ) + parser.add_argument( + "--destport", + default=0, + type=int, + help="Port for the WebApp should end up running on. Leave at 0 for a random open port.", + ) + parser.add_argument("--ip", default="localhost", help="Address to listen on.") + parser.add_argument( + "--debug", action="store_true", default=False, help="Display debug level logs." + ) + parser.add_argument( + "--logs", + action="store_true", + default=True, + help="Display logs generated by the subprocess.", + ) + parser.add_argument( + "--authtype", + choices=["oauth", "none"], + default="oauth", + help="Authentication Metod.", + ) + parser.add_argument( + "--request-timeout", + default=300, + type=int, + help="Timeout for proxied HTTP calls to subprocess in seconds.", + ) + parser.add_argument( + "--last-activity-interval", + default=300, + type=int, + help="Frequency to notify Hub that the WebApp is still running in seconds. 0 for never.", + ) + parser.add_argument( + "--force-alive", + action="store_true", + default=True, + help="Always report, that there has been activity (force keep alive) - only if last-activity-interval > 0.", + ) + parser.add_argument( + "--forward-user-info", + action="store_true", + default=False, + help="Forward a 'X-CDSDASHBOARDS-JH-USER' HTTP header to process containing JupyterHub user data.", + ) + parser.add_argument( + "--query-user-info", + action="store_true", + default=False, + help="Add a 'CDSDASHBOARDS_JH_USER GET' query arg in HTTP request to process containing JupyterHub user data.", + ) + parser.add_argument( + "--progressive", + action="store_true", + default=False, + help="Progressively flush responses as they arrive (good for Voila).", + ) + parser.add_argument( + "--websocket-max-message-size", + default=0, + type=int, + help="Max size of websocket data (leave at 0 for library defaults).", + ) + parser.add_argument( + "command", nargs="+", help="The command executed for starting the WebApp" + ) + + args = parser.parse_args() + run(**vars(args)) + + +if __name__ == "__main__": + main() diff --git a/jupyter_server_proxy/native_proxy.py b/jupyter_server_proxy/standalone/proxy.py similarity index 69% rename from jupyter_server_proxy/native_proxy.py rename to jupyter_server_proxy/standalone/proxy.py index 230d35d0..b0fcd413 100644 --- a/jupyter_server_proxy/native_proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -1,6 +1,4 @@ -import argparse import json -import logging import os import re from datetime import datetime @@ -10,11 +8,9 @@ from jupyterhub.services.auth import HubOAuthCallbackHandler from jupyterhub.utils import exponential_backoff, isoformat, make_ssl_context from tornado import httpclient, ioloop -from tornado.httpserver import HTTPServer -from tornado.log import app_log from tornado.web import Application, RedirectHandler, RequestHandler -from .handlers import SuperviseAndProxyHandler +from ..handlers import SuperviseAndProxyHandler def configure_http_client(): @@ -318,156 +314,3 @@ async def notify(): pc = ioloop.PeriodicCallback(send_activity, 1e3 * last_activity_interval, 0.1) pc.start() - - -def run( - command: list[str], - port=None, - destport=0, - ip="localhost", - debug=False, - logs=True, - authtype="oauth", - request_timeout=300, - last_activity_interval=300, - force_alive=True, - forward_user_info=False, - query_user_info=False, - progressive=False, - websocket_max_message_size=0, -): - if port is None: - get_port_from_env() - - if debug: - app_log.setLevel(logging.DEBUG) - elif logs: - app_log.setLevel(logging.INFO) - - prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") - - if len(prefix) > 0 and prefix[-1] == "/": - prefix = prefix[:-1] - - configure_http_client() - - app = make_app( - destport, - prefix, - list(command), - authtype, - request_timeout, - debug, - logs, - forward_user_info, - query_user_info, - progressive, - websocket_max_message_size, - ) - - ssl_options = get_ssl_options() - - http_server = HTTPServer(app, ssl_options=ssl_options, xheaders=True) - - http_server.listen(port or get_port_from_env(), ip) - - print( - f"Starting jhsingle-native-proxy server on address {ip} port {port}, proxying to port {destport}" - ) - print(f"URL Prefix: {prefix}") - print(f"Auth Type: {authtype}") - print(f"Command: {command}") - - if last_activity_interval > 0: - start_keep_alive(last_activity_interval, force_alive, app.settings) - - ioloop.IOLoop.current().start() - - -def main(): - parser = argparse.ArgumentParser( - "jupyter-native-proxy", - description="Wrap an arbitrary WebApp so it can be used in place of 'singleuser' in a JupyterHub setting", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - - parser.add_argument( - "--port", - default=None, - type=int, - help="Port for the proxy server to listen on. Defaults to JupyterHub default.", - ) - parser.add_argument( - "--destport", - default=0, - type=int, - help="Port for the WebApp should end up running on. Leave at 0 for a random open port.", - ) - parser.add_argument("--ip", default="localhost", help="Address to listen on.") - parser.add_argument( - "--debug", action="store_true", default=False, help="Display debug level logs." - ) - parser.add_argument( - "--logs", - action="store_true", - default=True, - help="Display logs generated by the subprocess.", - ) - parser.add_argument( - "--authtype", - choices=["oauth", "none"], - default="oauth", - help="Authentication Metod.", - ) - parser.add_argument( - "--request-timeout", - default=300, - type=int, - help="Timeout for proxied HTTP calls to subprocess in seconds.", - ) - parser.add_argument( - "--last-activity-interval", - default=300, - type=int, - help="Frequency to notify Hub that the WebApp is still running in seconds. 0 for never.", - ) - parser.add_argument( - "--force-alive", - action="store_true", - default=True, - help="Always report, that there has been activity (force keep alive) - only if last-activity-interval > 0.", - ) - parser.add_argument( - "--forward-user-info", - action="store_true", - default=False, - help="Forward a 'X-CDSDASHBOARDS-JH-USER' HTTP header to process containing JupyterHub user data.", - ) - parser.add_argument( - "--query-user-info", - action="store_true", - default=False, - help="Add a 'CDSDASHBOARDS_JH_USER GET' query arg in HTTP request to process containing JupyterHub user data.", - ) - parser.add_argument( - "--progressive", - action="store_true", - default=False, - help="Progressively flush responses as they arrive (good for Voila).", - ) - parser.add_argument( - "--websocket-max-message-size", - default=0, - type=int, - help="Max size of websocket data (leave at 0 for library defaults).", - ) - parser.add_argument( - "command", nargs="+", help="The command executed for starting the WebApp" - ) - - args = parser.parse_args() - run(**vars(args)) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 84e97bc5..abf57f5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ acceptance = [ "jupyter-server-proxy[test]", "robotframework-jupyterlibrary >=0.4.2", ] -nativeproxy = [ +standalone = [ "jupyterhub" ] classic = [ @@ -83,7 +83,7 @@ Source = "https://github.com/jupyterhub/jupyter-server-proxy" Tracker = "https://github.com/jupyterhub/jupyter-server-proxy/issues" [project.scripts] -jupyter-nativeproxy = "jupyter_server_proxy.native_proxy:main" +jupyter-standaloneproxy = "jupyter_server_proxy.standalone:main" # hatch ref: https://hatch.pypa.io/latest/ From fae833b8182d5b226f5e614784bc9a2cd1659361 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Thu, 12 Sep 2024 14:07:00 +0200 Subject: [PATCH 03/21] Remove `forward-user-info`, `query-user-info` and remains of `presentation-path` --- jupyter_server_proxy/standalone/__init__.py | 16 ----- jupyter_server_proxy/standalone/proxy.py | 68 +-------------------- 2 files changed, 2 insertions(+), 82 deletions(-) diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index 87e87bf2..a06ae5dd 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -26,8 +26,6 @@ def run( request_timeout=300, last_activity_interval=300, force_alive=True, - forward_user_info=False, - query_user_info=False, progressive=False, websocket_max_message_size=0, ): @@ -54,8 +52,6 @@ def run( request_timeout, debug, logs, - forward_user_info, - query_user_info, progressive, websocket_max_message_size, ) @@ -132,18 +128,6 @@ def main(): default=True, help="Always report, that there has been activity (force keep alive) - only if last-activity-interval > 0.", ) - parser.add_argument( - "--forward-user-info", - action="store_true", - default=False, - help="Forward a 'X-CDSDASHBOARDS-JH-USER' HTTP header to process containing JupyterHub user data.", - ) - parser.add_argument( - "--query-user-info", - action="store_true", - default=False, - help="Add a 'CDSDASHBOARDS_JH_USER GET' query arg in HTTP request to process containing JupyterHub user data.", - ) parser.add_argument( "--progressive", action="store_true", diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index b0fcd413..b769971a 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -54,60 +54,11 @@ def process_args(self): return { "port": self.port, "base_url": self.base_url, - # "presentation_path": self.presentation_path, - # "presentation_basename": self.presentation_basename, - # "presentation_dirname": self.presentation_dirname, "origin_host": self.request.host, # ToDo! "-": "-", "--": "--", } - @property - def base_url(self): - return self.settings.get("base_url", "/") - - # @property - # def presentation_path(self): - # return self.settings.get("presentation_path", ".") - # - # @property - # def presentation_basename(self): - # return self.settings.get("presentation_basename", "") - # - # @property - # def presentation_dirname(self): - # return self.settings.get("presentation_dirname", ".") - - @property - def hub_users(self): - return {self.settings["user"]} - - @property - def hub_groups(self): - if self.settings["group"]: - return {self.settings["group"]} - return set() - - @property - def allow_all(self): - if "anyone" in self.settings: - return self.settings["anyone"] == "1" - return super().allow_all - - def _render_template(self, value): - args = self.process_args - if type(value) is str: - return value.format(**args) - elif type(value) is list: - return [self._render_template(v) for v in value] - elif type(value) is dict: - return { - self._render_template(k): self._render_template(v) - for k, v in value.items() - } - else: - raise ValueError(f"Value of unrecognized type {type(value)}") - def get_env(self): if callable(environment): raise Exception( @@ -142,21 +93,9 @@ def make_app( request_timeout, debug, logs, - forward_user_info, - query_user_info, progressive, websocket_max_message_size, ): - # ToDo: Presentation_path? - # presentation_basename = "" - # presentation_dirname = "" - # - # if presentation_path: - # if not os.path.isabs(presentation_path): - # presentation_path = os.path.join(os.getcwd(), presentation_path) - # presentation_basename = os.path.basename(presentation_path) - # presentation_dirname = os.path.dirname(presentation_path) - patch_default_headers() proxy_handler = _make_native_proxy_handler(command, {}, destport, {}) @@ -169,9 +108,6 @@ def make_app( group=os.environ.get("JUPYTERHUB_GROUP") or "", anyone=os.environ.get("JUPYTERHUB_ANYONE") or "", base_url=prefix, # This is a confusing name, sorry - # presentation_path=presentation_path, - # presentation_basename=presentation_basename, - # presentation_dirname=presentation_dirname, request_timeout=request_timeout, ) @@ -189,7 +125,7 @@ def make_app( proxy_handler, dict( state={}, - # ToDo: authtype=authtype, forward_user_info=forward_user_info, query_user_info=query_user_info, progressive=progressive + # ToDo: authtype=authtype, progressive=progressive ), ), ( @@ -260,7 +196,7 @@ def start_keep_alive(last_activity_interval, force_alive, settings): print( "The following env vars are required to report activity back to the hub for keep alive: " "JUPYTERHUB_ACTIVITY_URL ({}), JUPYTERHUB_SERVER_NAME({})".format( - hub_activity_url, server_name, api_token + hub_activity_url, server_name ) ) return From 36c8e17949d2e41da3e4115d6168bafc4520bd1d Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 13 Sep 2024 13:39:05 +0200 Subject: [PATCH 04/21] Create StandaloneHubProxyHandler and timeout Argument. Fix authentication and check_origin --- jupyter_server_proxy/standalone/__init__.py | 10 +-- jupyter_server_proxy/standalone/proxy.py | 78 ++++++++++----------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index a06ae5dd..34f36888 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -23,7 +23,7 @@ def run( debug=False, logs=True, authtype="oauth", - request_timeout=300, + timeout=60, last_activity_interval=300, force_alive=True, progressive=False, @@ -49,7 +49,7 @@ def run( prefix, list(command), authtype, - request_timeout, + timeout, debug, logs, progressive, @@ -111,10 +111,10 @@ def main(): help="Authentication Metod.", ) parser.add_argument( - "--request-timeout", - default=300, + "--timeout", + default=60, type=int, - help="Timeout for proxied HTTP calls to subprocess in seconds.", + help="Timeout to wait until the subprocess has started and can be addressed.", ) parser.add_argument( "--last-activity-interval", diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index b769971a..6bf48b72 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -9,6 +9,7 @@ from jupyterhub.utils import exponential_backoff, isoformat, make_ssl_context from tornado import httpclient, ioloop from tornado.web import Application, RedirectHandler, RequestHandler +from tornado.websocket import WebSocketHandler from ..handlers import SuperviseAndProxyHandler @@ -25,50 +26,47 @@ def configure_http_client(): httpclient.AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context}) -def _make_native_proxy_handler(command, environment, port, mappath): +class StandaloneHubProxyHandler(SuperviseAndProxyHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.authtype = "oauth" + self.environment = {} + self.timeout = 60 + + def prepare(self, *args, **kwargs): + # ToDo: Automatically disable if not spawned by JupyterHub + if self.authtype == "oauth": + return super().prepare(*args, **kwargs) + else: + pass + + def check_origin(self, origin: str = None): + # Skip JupyterHandler.check_origin + return WebSocketHandler.check_origin(self, origin) + + def get_env(self): + return self._render_template(self.environment) + + def get_timeout(self): + return self.timeout + + +def _make_native_proxy_handler(command, port, mappath, authtype, environment, timeout): """ - Create a SuperviseAndProxyHandler subclass with given parameters + Create a StandaloneHubProxyHandler subclass with given parameters """ - class _Proxy(SuperviseAndProxyHandler): + class _Proxy(StandaloneHubProxyHandler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.name = command[0] self.proxy_base = command[0] self.requested_port = port self.mappath = mappath self.command = command - - # ToDo? - self.origin_host = None - - # ToDo! - def prepare(self, *args, **kwargs): - pass - - # ToDo! - def skip_check_origin(self) -> bool: - return True - - @property - def process_args(self): - return { - "port": self.port, - "base_url": self.base_url, - "origin_host": self.request.host, # ToDo! - "-": "-", - "--": "--", - } - - def get_env(self): - if callable(environment): - raise Exception( - "return self._render_template(call_with_asked_args(environment, self.process_args))" - ) - else: - return self._render_template(environment) - - def get_timeout(self): - return 60 + self.authtype = authtype + self.environment = environment + self.timeout = timeout return _Proxy @@ -90,7 +88,7 @@ def make_app( prefix, command, authtype, - request_timeout, + timeout, debug, logs, progressive, @@ -98,7 +96,10 @@ def make_app( ): patch_default_headers() - proxy_handler = _make_native_proxy_handler(command, {}, destport, {}) + # ToDo: Environment + proxy_handler = _make_native_proxy_handler( + command, destport, {}, authtype, {}, timeout + ) options = dict( debug=debug, @@ -108,7 +109,6 @@ def make_app( group=os.environ.get("JUPYTERHUB_GROUP") or "", anyone=os.environ.get("JUPYTERHUB_ANYONE") or "", base_url=prefix, # This is a confusing name, sorry - request_timeout=request_timeout, ) if websocket_max_message_size: @@ -125,7 +125,7 @@ def make_app( proxy_handler, dict( state={}, - # ToDo: authtype=authtype, progressive=progressive + # ToDo: progressive=progressive ), ), ( From 087c11d9f5ce10a2253d5731929d74835ea37a87 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 13 Sep 2024 16:14:56 +0200 Subject: [PATCH 05/21] Send Activity Notifications to JupyterHub --- jupyter_server_proxy/standalone/__init__.py | 26 ++----- jupyter_server_proxy/standalone/activity.py | 75 +++++++++++++++++++++ jupyter_server_proxy/standalone/proxy.py | 73 +------------------- 3 files changed, 84 insertions(+), 90 deletions(-) create mode 100644 jupyter_server_proxy/standalone/activity.py diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index 34f36888..c21864fa 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -6,13 +6,8 @@ from tornado.httpserver import HTTPServer from tornado.log import app_log -from .proxy import ( - configure_http_client, - get_port_from_env, - get_ssl_options, - make_app, - start_keep_alive, -) +from .activity import start_activity_update +from .proxy import configure_http_client, get_port_from_env, get_ssl_options, make_app def run( @@ -24,8 +19,7 @@ def run( logs=True, authtype="oauth", timeout=60, - last_activity_interval=300, - force_alive=True, + activity_interval=300, progressive=False, websocket_max_message_size=0, ): @@ -69,8 +63,8 @@ def run( print(f"Auth Type: {authtype}") print(f"Command: {command}") - if last_activity_interval > 0: - start_keep_alive(last_activity_interval, force_alive, app.settings) + if activity_interval > 0: + start_activity_update(activity_interval) ioloop.IOLoop.current().start() @@ -117,16 +111,10 @@ def main(): help="Timeout to wait until the subprocess has started and can be addressed.", ) parser.add_argument( - "--last-activity-interval", + "--activity-interval", default=300, type=int, - help="Frequency to notify Hub that the WebApp is still running in seconds. 0 for never.", - ) - parser.add_argument( - "--force-alive", - action="store_true", - default=True, - help="Always report, that there has been activity (force keep alive) - only if last-activity-interval > 0.", + help="Frequency to notify Hub that the WebApp is still running (In seconds, 0 for never).", ) parser.add_argument( "--progressive", diff --git a/jupyter_server_proxy/standalone/activity.py b/jupyter_server_proxy/standalone/activity.py new file mode 100644 index 00000000..f08085e0 --- /dev/null +++ b/jupyter_server_proxy/standalone/activity.py @@ -0,0 +1,75 @@ +import json +import os +from datetime import datetime + +from jupyterhub.utils import exponential_backoff, isoformat +from tornado import httpclient, ioloop +from tornado.log import app_log as log + + +async def notify_activity(): + """ + Regularly notify JupyterHub of activity. + See `jupyrehub/singleuser/extensions#L396` + """ + + client = httpclient.AsyncHTTPClient() + last_activity_timestamp = isoformat(datetime.utcnow()) + failure_count = 0 + + activity_url = os.environ.get("JUPYTERHUB_ACTIVITY_URL") + server_name = os.environ.get("JUPYTERHUB_SERVER_NAME") + api_token = os.environ.get("JUPYTERHUB_API_TOKEN") + + if not (activity_url and server_name and api_token): + log.error( + "Could not find environment variables to send notification to JupyterHub" + ) + return + + async def notify(): + """Send Notification, return if successful""" + nonlocal failure_count + log.debug(f"Notifying Hub of activity {last_activity_timestamp}") + + req = httpclient.HTTPRequest( + url=activity_url, + method="POST", + headers={ + "Authorization": f"token {api_token}", + "Content-Type": "application/json", + }, + body=json.dumps( + { + "servers": { + server_name: {"last_activity": last_activity_timestamp} + }, + "last_activity": last_activity_timestamp, + } + ), + ) + + try: + await client.fetch(req) + return True + except httpclient.HTTPError as e: + failure_count += 1 + log.error(f"Error notifying Hub of activity: {e}") + return False + + # Try sending notification for 1 minute + await exponential_backoff( + notify, + fail_message="Failed to notify Hub of activity", + start_wait=1, + max_wait=15, + timeout=60, + ) + + if failure_count > 0: + log.info(f"Sent hub activity after {failure_count} retries") + + +def start_activity_update(interval): + pc = ioloop.PeriodicCallback(notify_activity, 1e3 * interval, 0.1) + pc.start() diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 6bf48b72..fe954487 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -1,13 +1,11 @@ -import json import os import re -from datetime import datetime from urllib.parse import urlparse from jupyterhub import __version__ as __jh_version__ from jupyterhub.services.auth import HubOAuthCallbackHandler -from jupyterhub.utils import exponential_backoff, isoformat, make_ssl_context -from tornado import httpclient, ioloop +from jupyterhub.utils import make_ssl_context +from tornado import httpclient from tornado.web import Application, RedirectHandler, RequestHandler from tornado.websocket import WebSocketHandler @@ -183,70 +181,3 @@ def get_port_from_env(): elif url.scheme == "https": return 443 return 8888 - - -def start_keep_alive(last_activity_interval, force_alive, settings): - client = httpclient.AsyncHTTPClient() - - hub_activity_url = os.environ.get("JUPYTERHUB_ACTIVITY_URL", "") - server_name = os.environ.get("JUPYTERHUB_SERVER_NAME", "") - api_token = os.environ.get("JUPYTERHUB_API_TOKEN", "") - - if api_token == "" or server_name == "" or hub_activity_url == "": - print( - "The following env vars are required to report activity back to the hub for keep alive: " - "JUPYTERHUB_ACTIVITY_URL ({}), JUPYTERHUB_SERVER_NAME({})".format( - hub_activity_url, server_name - ) - ) - return - - async def send_activity(): - async def notify(): - print("About to notify Hub of activity") - - last_activity_timestamp = None - - if force_alive: - last_activity_timestamp = datetime.utcnow() - else: - last_activity_timestamp = settings.get("api_last_activity", None) - - if last_activity_timestamp: - last_activity_timestamp = isoformat(last_activity_timestamp) - req = httpclient.HTTPRequest( - url=hub_activity_url, - method="POST", - headers={ - "Authorization": f"token {api_token}", - "Content-Type": "application/json", - }, - body=json.dumps( - { - "servers": { - server_name: {"last_activity": last_activity_timestamp} - }, - "last_activity": last_activity_timestamp, - } - ), - ) - try: - await client.fetch(req) - except Exception as e: - print(f"Error notifying Hub of activity: {e}") - return False - else: - return True - - return True # Nothing to report, so really it worked - - await exponential_backoff( - notify, - fail_message="Failed to notify Hub of activity", - start_wait=1, - max_wait=15, - timeout=60, - ) - - pc = ioloop.PeriodicCallback(send_activity, 1e3 * last_activity_interval, 0.1) - pc.start() From 0d4e701f815cfd7acf5ad95c4a2bd94461a83d5a Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Wed, 18 Sep 2024 12:04:02 +0200 Subject: [PATCH 06/21] Fix Authentication with JupyterHub --- jupyter_server_proxy/standalone/__init__.py | 48 ++++++------ jupyter_server_proxy/standalone/proxy.py | 83 ++++++++++++++------- 2 files changed, 80 insertions(+), 51 deletions(-) diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index c21864fa..487725b6 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -4,32 +4,32 @@ from tornado import ioloop from tornado.httpserver import HTTPServer -from tornado.log import app_log +from tornado.log import app_log as log from .activity import start_activity_update from .proxy import configure_http_client, get_port_from_env, get_ssl_options, make_app def run( - command: list[str], - port=None, - destport=0, - ip="localhost", - debug=False, - logs=True, - authtype="oauth", - timeout=60, - activity_interval=300, - progressive=False, - websocket_max_message_size=0, + command, + port, + destport, + ip, + debug, + logs, + overwrite_authentication, + timeout, + activity_interval, + progressive, + websocket_max_message_size, ): if port is None: get_port_from_env() if debug: - app_log.setLevel(logging.DEBUG) + log.setLevel(logging.DEBUG) elif logs: - app_log.setLevel(logging.INFO) + log.setLevel(logging.INFO) prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") @@ -42,7 +42,7 @@ def run( destport, prefix, list(command), - authtype, + overwrite_authentication, timeout, debug, logs, @@ -56,12 +56,11 @@ def run( http_server.listen(port or get_port_from_env(), ip) - print( - f"Starting jhsingle-native-proxy server on address {ip} port {port}, proxying to port {destport}" + log.info( + f"Starting standaloneproxy on {ip}:{port}, server is started on Port {destport}" ) - print(f"URL Prefix: {prefix}") - print(f"Auth Type: {authtype}") - print(f"Command: {command}") + log.info(f"URL Prefix: {prefix}") + log.info(f"Command: {command}") if activity_interval > 0: start_activity_update(activity_interval) @@ -99,10 +98,9 @@ def main(): help="Display logs generated by the subprocess.", ) parser.add_argument( - "--authtype", - choices=["oauth", "none"], - default="oauth", - help="Authentication Metod.", + "--overwrite-authentication", + default=None, + help="Forcefully enable/disable authentication with JupyterHub.", ) parser.add_argument( "--timeout", @@ -133,6 +131,8 @@ def main(): ) args = parser.parse_args() + log.debug(args) + run(**vars(args)) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index fe954487..18d10ccc 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -3,13 +3,13 @@ from urllib.parse import urlparse from jupyterhub import __version__ as __jh_version__ -from jupyterhub.services.auth import HubOAuthCallbackHandler +from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated from jupyterhub.utils import make_ssl_context from tornado import httpclient -from tornado.web import Application, RedirectHandler, RequestHandler +from tornado.web import Application, RedirectHandler from tornado.websocket import WebSocketHandler -from ..handlers import SuperviseAndProxyHandler +from ..handlers import ProxyHandler, SuperviseAndProxyHandler def configure_http_client(): @@ -24,19 +24,18 @@ def configure_http_client(): httpclient.AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context}) -class StandaloneHubProxyHandler(SuperviseAndProxyHandler): +class StandaloneProxyHandler(SuperviseAndProxyHandler): + """ + Base class for standalone proxies. Will not ensure any authentication! + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.authtype = "oauth" self.environment = {} self.timeout = 60 def prepare(self, *args, **kwargs): - # ToDo: Automatically disable if not spawned by JupyterHub - if self.authtype == "oauth": - return super().prepare(*args, **kwargs) - else: - pass + pass def check_origin(self, origin: str = None): # Skip JupyterHandler.check_origin @@ -49,12 +48,57 @@ def get_timeout(self): return self.timeout -def _make_native_proxy_handler(command, port, mappath, authtype, environment, timeout): +class StandaloneHubProxyHandler(StandaloneProxyHandler, HubOAuthenticated): + """ + Standalone Proxy used when spawned by a JupyterHub. + Will restrict access to the application by authentication with the JupyterHub API. + """ + + @property + def hub_users(self): + return {self.settings["user"]} + + @property + def hub_groups(self): + if self.settings["group"]: + return {self.settings["group"]} + return set() + + @property + def allow_all(self): + if "anyone" in self.settings: + return self.settings["anyone"] == "1" + return super().allow_all + + def prepare(self, *args, **kwargs): + # Enable Authentication Check + return ProxyHandler.prepare(self, *args, **kwargs) + + def set_default_headers(self): + self.set_header("X-JupyterHub-Version", __jh_version__) + + +def _make_native_proxy_handler( + command, port, mappath, overwrite_authentication, environment, timeout +): """ Create a StandaloneHubProxyHandler subclass with given parameters """ - class _Proxy(StandaloneHubProxyHandler): + # Try to determine if we are launched by a JupyterHub via environment variables. + # See jupyterhub/spawner.py#L1035 + if overwrite_authentication is not None: + base = ( + StandaloneHubProxyHandler + if overwrite_authentication is True + else StandaloneProxyHandler + ) + elif "JUPYTERHUB_API_TOKEN" in os.environ and "JUPYTERHUB_API_URL" in os.environ: + base = StandaloneHubProxyHandler + else: + base = StandaloneProxyHandler + + class _Proxy(base): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.name = command[0] @@ -62,25 +106,12 @@ def __init__(self, *args, **kwargs): self.requested_port = port self.mappath = mappath self.command = command - self.authtype = authtype self.environment = environment self.timeout = timeout return _Proxy -def patch_default_headers(): - if hasattr(RequestHandler, "_orig_set_default_headers"): - return - RequestHandler._orig_set_default_headers = RequestHandler.set_default_headers - - def set_jupyterhub_header(self): - self._orig_set_default_headers() - self.set_header("X-JupyterHub-Version", __jh_version__) - - RequestHandler.set_default_headers = set_jupyterhub_header - - def make_app( destport, prefix, @@ -92,8 +123,6 @@ def make_app( progressive, websocket_max_message_size, ): - patch_default_headers() - # ToDo: Environment proxy_handler = _make_native_proxy_handler( command, destport, {}, authtype, {}, timeout From fa8621fb4cc4142618a0b955616f09fc0bef9f67 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 6 Dec 2024 13:00:51 +0100 Subject: [PATCH 07/21] Remove jupyter-server Authentication --- jupyter_server_proxy/standalone/proxy.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 18d10ccc..f6cbd61e 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -5,11 +5,11 @@ from jupyterhub import __version__ as __jh_version__ from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated from jupyterhub.utils import make_ssl_context -from tornado import httpclient +from tornado import httpclient, web from tornado.web import Application, RedirectHandler from tornado.websocket import WebSocketHandler -from ..handlers import ProxyHandler, SuperviseAndProxyHandler +from ..handlers import SuperviseAndProxyHandler def configure_http_client(): @@ -48,7 +48,7 @@ def get_timeout(self): return self.timeout -class StandaloneHubProxyHandler(StandaloneProxyHandler, HubOAuthenticated): +class StandaloneHubProxyHandler(HubOAuthenticated, StandaloneProxyHandler): """ Standalone Proxy used when spawned by a JupyterHub. Will restrict access to the application by authentication with the JupyterHub API. @@ -70,9 +70,9 @@ def allow_all(self): return self.settings["anyone"] == "1" return super().allow_all - def prepare(self, *args, **kwargs): - # Enable Authentication Check - return ProxyHandler.prepare(self, *args, **kwargs) + @web.authenticated + async def proxy(self, port, path): + return await super().proxy(port, path) def set_default_headers(self): self.set_header("X-JupyterHub-Version", __jh_version__) @@ -130,12 +130,10 @@ def make_app( options = dict( debug=debug, - logs=logs, + # Required for JupyterHub Authentication + hub_user=os.environ.get("JUPYTERHUB_USER", ""), + hub_group=os.environ.get("JUPYTERHUB_GROUP", ""), cookie_secret=os.urandom(32), - user=os.environ.get("JUPYTERHUB_USER") or "", - group=os.environ.get("JUPYTERHUB_GROUP") or "", - anyone=os.environ.get("JUPYTERHUB_ANYONE") or "", - base_url=prefix, # This is a confusing name, sorry ) if websocket_max_message_size: From 5cae144c7ca07620b87d4c7733ce3e8f35446d6c Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 6 Dec 2024 13:19:40 +0100 Subject: [PATCH 08/21] Set env & mappath via CLI, add Args for Unix Sockets --- jupyter_server_proxy/standalone/__init__.py | 151 +++++++++++++------- jupyter_server_proxy/standalone/proxy.py | 66 ++++----- 2 files changed, 124 insertions(+), 93 deletions(-) diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index 487725b6..d9f27e55 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -4,63 +4,63 @@ from tornado import ioloop from tornado.httpserver import HTTPServer -from tornado.log import app_log as log +from tornado.log import app_log as log, enable_pretty_logging from .activity import start_activity_update -from .proxy import configure_http_client, get_port_from_env, get_ssl_options, make_app +from .proxy import configure_http_client, get_port_from_env, get_ssl_options, make_proxy_app def run( - command, - port, - destport, - ip, - debug, - logs, - overwrite_authentication, - timeout, - activity_interval, - progressive, - websocket_max_message_size, + command: list[str], + port: int, + address: str, + server_port: int, + socket_path: str | None, + socket_auto: bool, + environment: list[tuple[str, str]] | None, + mappath: list[tuple[str, str]] | None, + debug: bool, + # logs: bool, + overwrite_authentication: bool | None, + timeout: int, + activity_interval: int, + # progressive: bool, + websocket_max_message_size: int, ): - if port is None: - get_port_from_env() - + # Setup Logging + enable_pretty_logging(logger=log) if debug: log.setLevel(logging.DEBUG) - elif logs: - log.setLevel(logging.INFO) - prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") + if not port: + port = get_port_from_env() - if len(prefix) > 0 and prefix[-1] == "/": - prefix = prefix[:-1] + if overwrite_authentication is True: + log.info("Enabling Authentication with JupyterHub") - configure_http_client() + prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") - app = make_app( - destport, - prefix, - list(command), - overwrite_authentication, + app = make_proxy_app( + command, + prefix.removesuffix("/"), + server_port, + socket_path or socket_auto, + dict(environment), + dict(mappath), timeout, + overwrite_authentication is True, debug, - logs, - progressive, websocket_max_message_size, ) ssl_options = get_ssl_options() http_server = HTTPServer(app, ssl_options=ssl_options, xheaders=True) + http_server.listen(port, address) - http_server.listen(port or get_port_from_env(), ip) - - log.info( - f"Starting standaloneproxy on {ip}:{port}, server is started on Port {destport}" - ) - log.info(f"URL Prefix: {prefix}") - log.info(f"Command: {command}") + log.info(f"Starting standaloneproxy on '{address}:{port}'") + log.info(f"URL Prefix: {prefix!r}") + log.info(f"Command: {' '.join(command)!r}") if activity_interval > 0: start_activity_update(activity_interval) @@ -70,36 +70,81 @@ def run( def main(): parser = argparse.ArgumentParser( - "jupyter-native-proxy", - description="Wrap an arbitrary WebApp so it can be used in place of 'singleuser' in a JupyterHub setting", + "jupyter-standalone-proxy", + description="Wrap an arbitrary WebApp so it can be used in place of 'jupyterhub-singleuser' in a JupyterHub setting.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( + "-p", "--port", - default=None, + default=0, type=int, - help="Port for the proxy server to listen on. Defaults to JupyterHub default.", + dest="port", + help="Port for the proxy server to listen on (0 for JupyterHub default).", + ) + parser.add_argument( + "-a", + "--address", + default="localhost", + type=str, + dest="address", + help="Address for the proxy server to listen on.", ) parser.add_argument( - "--destport", + "-s", + "--server-port", default=0, type=int, - help="Port for the WebApp should end up running on. Leave at 0 for a random open port.", + dest="server_port", + help="Port for the WebApp should end up running on (0 for random open port).", + ) + parser.add_argument( + "--socket-path", + type=str, + default=None, + help="Path to the Unix Socket to use for proxying. Takes precedence over '-s/--server_port' and '--socket-auto'.", + ) + parser.add_argument( + "--socket-auto", + action="store_true", + help="Use Unix Socket for proxying, but let Jupyter Server Proxy automatically create one.", + ) + parser.add_argument( + "--env", + "--environment", + type=lambda v: tuple(v.split(":")[:2]), + default=[], + action="append", + dest="environment", + help="Add an environment variable to the server process. Must be of the form :, e.g. --env=MY_VAR:42", ) - parser.add_argument("--ip", default="localhost", help="Address to listen on.") parser.add_argument( - "--debug", action="store_true", default=False, help="Display debug level logs." + "--mappath", + type=lambda v: tuple(v.split(":")[:2]), + default=[], + action="append", + help="Add an path mapping to the proxy. Any requests received under will be redirected to . " + "Must be of the form :, e.g. --mappath=/:/index.html", ) parser.add_argument( - "--logs", + "-d", + "--debug", action="store_true", - default=True, - help="Display logs generated by the subprocess.", + default=False, + dest="debug", + help="Display debug level logs.", ) + # parser.add_argument( + # "--logs", + # action="store_true", + # default=True, + # help="Display logs generated by the subprocess.", + # ) parser.add_argument( "--overwrite-authentication", default=None, + type=lambda v: None if v is None else bool(v), help="Forcefully enable/disable authentication with JupyterHub.", ) parser.add_argument( @@ -114,12 +159,12 @@ def main(): type=int, help="Frequency to notify Hub that the WebApp is still running (In seconds, 0 for never).", ) - parser.add_argument( - "--progressive", - action="store_true", - default=False, - help="Progressively flush responses as they arrive (good for Voila).", - ) + # parser.add_argument( + # "--progressive", + # action="store_true", + # default=False, + # help="Progressively flush responses as they arrive (good for Voila).", + # ) parser.add_argument( "--websocket-max-message-size", default=0, diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index f6cbd61e..7d996850 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -6,6 +6,7 @@ from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated from jupyterhub.utils import make_ssl_context from tornado import httpclient, web +from tornado.log import app_log from tornado.web import Application, RedirectHandler from tornado.websocket import WebSocketHandler @@ -78,57 +79,41 @@ def set_default_headers(self): self.set_header("X-JupyterHub-Version", __jh_version__) -def _make_native_proxy_handler( - command, port, mappath, overwrite_authentication, environment, timeout +def make_proxy_app( + command: list[str], + prefix: str, + port: int, + unix_socket: bool | str, + environment: dict[str, str], + mappath: dict[str, str], + timeout: int, + skip_authentication: bool, + debug: bool, + # progressive: bool, + websocket_max_message_size: int, ): """ Create a StandaloneHubProxyHandler subclass with given parameters """ + app_log.debug(f"Process will use {port = }") + app_log.debug(f"Process will use {unix_socket = }") + app_log.debug(f"Process environment: {environment}") + app_log.debug(f"Proxy mappath: {mappath}") - # Try to determine if we are launched by a JupyterHub via environment variables. - # See jupyterhub/spawner.py#L1035 - if overwrite_authentication is not None: - base = ( - StandaloneHubProxyHandler - if overwrite_authentication is True - else StandaloneProxyHandler - ) - elif "JUPYTERHUB_API_TOKEN" in os.environ and "JUPYTERHUB_API_URL" in os.environ: - base = StandaloneHubProxyHandler - else: - base = StandaloneProxyHandler - - class _Proxy(base): + base = StandaloneProxyHandler if skip_authentication else StandaloneHubProxyHandler + class Proxy(base): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.name = command[0] + self.name = f"{command[0]!r} Process" self.proxy_base = command[0] self.requested_port = port + self.requested_unix_socket = unix_socket self.mappath = mappath self.command = command self.environment = environment self.timeout = timeout - return _Proxy - - -def make_app( - destport, - prefix, - command, - authtype, - timeout, - debug, - logs, - progressive, - websocket_max_message_size, -): - # ToDo: Environment - proxy_handler = _make_native_proxy_handler( - command, destport, {}, authtype, {}, timeout - ) - - options = dict( + settings = dict( debug=debug, # Required for JupyterHub Authentication hub_user=os.environ.get("JUPYTERHUB_USER", ""), @@ -137,7 +122,8 @@ def make_app( ) if websocket_max_message_size: - options["websocket_max_message_size"] = websocket_max_message_size + app_log.debug(f"Restricting WebSocket Messages to {websocket_max_message_size}") + settings["websocket_max_message_size"] = websocket_max_message_size return Application( [ @@ -147,7 +133,7 @@ def make_app( ), ( r"^" + re.escape(prefix) + r"/(.*)", - proxy_handler, + Proxy, dict( state={}, # ToDo: progressive=progressive @@ -159,7 +145,7 @@ def make_app( dict(url=prefix + "/{0}"), ), ], - **options, + **settings, ) From 6b9336da90707bdbbd251251eed96ad1e633172f Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 6 Dec 2024 13:37:43 +0100 Subject: [PATCH 09/21] Merge `StandaloneProxyHandler` into `StandaloneHubProxyHandler`, extract address from `JUPYTERHUB_SERVICE_URL` --- jupyter_server_proxy/standalone/__init__.py | 73 +++++++--- jupyter_server_proxy/standalone/proxy.py | 153 +++++++------------- 2 files changed, 104 insertions(+), 122 deletions(-) diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index d9f27e55..31902ee9 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -1,19 +1,45 @@ import argparse import logging import os +from urllib.parse import urlparse from tornado import ioloop from tornado.httpserver import HTTPServer -from tornado.log import app_log as log, enable_pretty_logging +from tornado.log import app_log as log +from tornado.log import enable_pretty_logging, gen_log from .activity import start_activity_update -from .proxy import configure_http_client, get_port_from_env, get_ssl_options, make_proxy_app +from .proxy import configure_ssl, make_proxy_app + + +def _default_address_and_port() -> tuple[str, int]: + """ + Get the Address and Port for the Proxy, either from JUPYTERHUB_SERVICE_URL or default values. + See https://github.com/jupyterhub/jupyterhub/blob/4.x/jupyterhub/singleuser/mixins.py#L266-L284. + """ + address = "127.0.0.1" + port = 8888 + + if os.environ.get("JUPYTERHUB_SERVICE_URL"): + url = urlparse(os.environ["JUPYTERHUB_SERVICE_URL"]) + + if url.hostname: + address = url.hostname + + if url.port: + port = url.port + elif url.scheme == "http": + port = 80 + elif url.scheme == "https": + port = 443 + + return address, port def run( command: list[str], - port: int, - address: str, + port: int | None, + address: str | None, server_port: int, socket_path: str | None, socket_auto: bool, @@ -21,7 +47,7 @@ def run( mappath: list[tuple[str, str]] | None, debug: bool, # logs: bool, - overwrite_authentication: bool | None, + skip_authentication: bool, timeout: int, activity_interval: int, # progressive: bool, @@ -31,12 +57,14 @@ def run( enable_pretty_logging(logger=log) if debug: log.setLevel(logging.DEBUG) + gen_log.setLevel(logging.DEBUG) - if not port: - port = get_port_from_env() + address_port_default = _default_address_and_port() + address = address or address_port_default[0] + port = port or address_port_default[1] - if overwrite_authentication is True: - log.info("Enabling Authentication with JupyterHub") + if skip_authentication: + log.warn("Disabling Authentication with JuypterHub Server!") prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") @@ -48,13 +76,13 @@ def run( dict(environment), dict(mappath), timeout, - overwrite_authentication is True, + skip_authentication, debug, + # progressive, websocket_max_message_size, ) - ssl_options = get_ssl_options() - + ssl_options = configure_ssl() http_server = HTTPServer(app, ssl_options=ssl_options, xheaders=True) http_server.listen(port, address) @@ -62,7 +90,11 @@ def run( log.info(f"URL Prefix: {prefix!r}") log.info(f"Command: {' '.join(command)!r}") + # Periodically send JupyterHub Notifications, that we are still running if activity_interval > 0: + log.info( + f"Sending Acitivity Notivication to JupyterHub with interval={activity_interval}s" + ) start_activity_update(activity_interval) ioloop.IOLoop.current().start() @@ -78,18 +110,16 @@ def main(): parser.add_argument( "-p", "--port", - default=0, type=int, dest="port", - help="Port for the proxy server to listen on (0 for JupyterHub default).", + help="Set port for the proxy server to listen on. Will use 'JUPYTERHUB_SERVICE_URL' or '127.0.0.1' by default.", ) parser.add_argument( "-a", "--address", - default="localhost", type=str, dest="address", - help="Address for the proxy server to listen on.", + help="Set address for the proxy server to listen on. Will use 'JUPYTERHUB_SERVICE_URL' or '8888' by default.", ) parser.add_argument( "-s", @@ -135,6 +165,7 @@ def main(): dest="debug", help="Display debug level logs.", ) + # ToDo: Split Server and Application Logger # parser.add_argument( # "--logs", # action="store_true", @@ -142,10 +173,9 @@ def main(): # help="Display logs generated by the subprocess.", # ) parser.add_argument( - "--overwrite-authentication", - default=None, - type=lambda v: None if v is None else bool(v), - help="Forcefully enable/disable authentication with JupyterHub.", + "--skip-authentication", + action="store_true", + help="Do not enforce authentication with the JupyterHub Server.", ) parser.add_argument( "--timeout", @@ -159,6 +189,7 @@ def main(): type=int, help="Frequency to notify Hub that the WebApp is still running (In seconds, 0 for never).", ) + # ToDo: Progressive Proxy # parser.add_argument( # "--progressive", # action="store_true", @@ -176,8 +207,6 @@ def main(): ) args = parser.parse_args() - log.debug(args) - run(**vars(args)) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 7d996850..a332c497 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -1,43 +1,62 @@ import os import re -from urllib.parse import urlparse +from logging import Logger from jupyterhub import __version__ as __jh_version__ from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated from jupyterhub.utils import make_ssl_context from tornado import httpclient, web from tornado.log import app_log -from tornado.web import Application, RedirectHandler +from tornado.web import Application from tornado.websocket import WebSocketHandler from ..handlers import SuperviseAndProxyHandler -def configure_http_client(): - keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE", "") - certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE", "") - client_ca = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA", "") - - if keyfile == "" and certfile == "" and client_ca == "": - return - - ssl_context = make_ssl_context(keyfile, certfile, cafile=client_ca) - httpclient.AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context}) - - -class StandaloneProxyHandler(SuperviseAndProxyHandler): +class StandaloneHubProxyHandler(HubOAuthenticated, SuperviseAndProxyHandler): """ - Base class for standalone proxies. Will not ensure any authentication! + Base class for standalone proxies. + Will restrict access to the application by authentication with the JupyterHub API. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.environment = {} self.timeout = 60 + self.skip_authentication = False + + @property + def log(self) -> Logger: + return app_log + + @property + def hub_users(self): + if "hub_user" in self.settings: + return {self.settings["hub_user"]} + return set() + + @property + def hub_groups(self): + if "hub_group" in self.settings: + return {self.settings["hub_group"]} + return set() + + def set_default_headers(self): + self.set_header("X-JupyterHub-Version", __jh_version__) def prepare(self, *args, **kwargs): pass + async def proxy(self, port, path): + if self.skip_authentication: + return await super().proxy(port, path) + else: + return await self.oauth_proxy(port, path) + + @web.authenticated + async def oauth_proxy(self, port, path): + return await super().proxy(port, path) + def check_origin(self, origin: str = None): # Skip JupyterHandler.check_origin return WebSocketHandler.check_origin(self, origin) @@ -49,34 +68,21 @@ def get_timeout(self): return self.timeout -class StandaloneHubProxyHandler(HubOAuthenticated, StandaloneProxyHandler): - """ - Standalone Proxy used when spawned by a JupyterHub. - Will restrict access to the application by authentication with the JupyterHub API. - """ - - @property - def hub_users(self): - return {self.settings["user"]} +def configure_ssl(): + keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE") + certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE") + cafile = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA") - @property - def hub_groups(self): - if self.settings["group"]: - return {self.settings["group"]} - return set() + if not (keyfile and certfile and cafile): + app_log.warn("Could not configure SSL") + return None - @property - def allow_all(self): - if "anyone" in self.settings: - return self.settings["anyone"] == "1" - return super().allow_all + ssl_context = make_ssl_context(keyfile, certfile, cafile) - @web.authenticated - async def proxy(self, port, path): - return await super().proxy(port, path) + # Configure HTTPClient to use SSL for Proxy Requests + httpclient.AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context}) - def set_default_headers(self): - self.set_header("X-JupyterHub-Version", __jh_version__) + return ssl_context def make_proxy_app( @@ -92,16 +98,12 @@ def make_proxy_app( # progressive: bool, websocket_max_message_size: int, ): - """ - Create a StandaloneHubProxyHandler subclass with given parameters - """ app_log.debug(f"Process will use {port = }") app_log.debug(f"Process will use {unix_socket = }") app_log.debug(f"Process environment: {environment}") app_log.debug(f"Proxy mappath: {mappath}") - base = StandaloneProxyHandler if skip_authentication else StandaloneHubProxyHandler - class Proxy(base): + class Proxy(StandaloneHubProxyHandler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.name = f"{command[0]!r} Process" @@ -112,10 +114,11 @@ def __init__(self, *args, **kwargs): self.command = command self.environment = environment self.timeout = timeout + self.skip_authentication = skip_authentication settings = dict( debug=debug, - # Required for JupyterHub Authentication + # Required for JupyterHub hub_user=os.environ.get("JUPYTERHUB_USER", ""), hub_group=os.environ.get("JUPYTERHUB_GROUP", ""), cookie_secret=os.urandom(32), @@ -125,12 +128,8 @@ def __init__(self, *args, **kwargs): app_log.debug(f"Restricting WebSocket Messages to {websocket_max_message_size}") settings["websocket_max_message_size"] = websocket_max_message_size - return Application( + app = Application( [ - ( - r"^" + re.escape(prefix) + r"/oauth_callback", - HubOAuthCallbackHandler, - ), ( r"^" + re.escape(prefix) + r"/(.*)", Proxy, @@ -140,57 +139,11 @@ def __init__(self, *args, **kwargs): ), ), ( - r"^" + re.escape(prefix.replace("@", "%40")) + r"/(.*)", - RedirectHandler, - dict(url=prefix + "/{0}"), + r"^" + re.escape(prefix) + r"/oauth_callback", + HubOAuthCallbackHandler, ), ], **settings, ) - -def get_ssl_options(): - ssl_options = {} - keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE") or "" - certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE") or "" - client_ca = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA") or "" - - if keyfile: - ssl_options["keyfile"] = keyfile - - if certfile: - ssl_options["certfile"] = certfile - - if client_ca: - ssl_options["ca_certs"] = client_ca - - if not ssl_options: - # None indicates no SSL config - ssl_options = None - else: - # SSL may be missing, so only import it if it"s to be used - import ssl - - # PROTOCOL_TLS selects the highest ssl/tls protocol version that both the client and - # server support. When PROTOCOL_TLS is not available use PROTOCOL_SSLv23. - # PROTOCOL_TLS is new in version 2.7.13, 3.5.3 and 3.6 - ssl_options.setdefault( - "ssl_version", getattr(ssl, "PROTOCOL_TLS", ssl.PROTOCOL_SSLv23) - ) - if ssl_options.get("ca_certs", False): - ssl_options.setdefault("cert_reqs", ssl.CERT_REQUIRED) - - return ssl_options - - -# https://github.com/jupyterhub/jupyterhub/blob/2.0.0rc3/jupyterhub/singleuser/mixins.py#L340-L349 -def get_port_from_env(): - if os.environ.get("JUPYTERHUB_SERVICE_URL"): - url = urlparse(os.environ["JUPYTERHUB_SERVICE_URL"]) - if url.port: - return url.port - elif url.scheme == "http": - return 80 - elif url.scheme == "https": - return 443 - return 8888 + return app From 51ee31cf46f03162859f1b7ece749d93f001d5d1 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Wed, 2 Oct 2024 17:40:25 +0200 Subject: [PATCH 10/21] Fix SSL Configuration, Add SlashHandler to Application --- jupyter_server_proxy/standalone/proxy.py | 31 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index a332c497..5d4b87b2 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -1,5 +1,6 @@ import os import re +import ssl from logging import Logger from jupyterhub import __version__ as __jh_version__ @@ -10,7 +11,7 @@ from tornado.web import Application from tornado.websocket import WebSocketHandler -from ..handlers import SuperviseAndProxyHandler +from ..handlers import AddSlashHandler, SuperviseAndProxyHandler class StandaloneHubProxyHandler(HubOAuthenticated, SuperviseAndProxyHandler): @@ -69,20 +70,34 @@ def get_timeout(self): def configure_ssl(): - keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE") - certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE") - cafile = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA") + # See jupyter_server/serverapp:init_webapp + keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE", "") + certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE", "") + client_ca = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA", "") - if not (keyfile and certfile and cafile): + if not (keyfile or certfile or client_ca): app_log.warn("Could not configure SSL") return None - ssl_context = make_ssl_context(keyfile, certfile, cafile) + ssl_options = {} + if keyfile: + ssl_options["keyfile"] = keyfile + if certfile: + ssl_options["certfile"] = certfile + if client_ca: + ssl_options["ca_certs"] = client_ca + + # PROTOCOL_TLS selects the highest ssl/tls protocol version that both the client and + # server support. When PROTOCOL_TLS is not available use PROTOCOL_SSLv23. + ssl_options["ssl_version"] = getattr(ssl, "PROTOCOL_TLS", ssl.PROTOCOL_SSLv23) + if ssl_options.get("ca_certs", False): + ssl_options["cert_reqs"] = ssl.CERT_REQUIRED # Configure HTTPClient to use SSL for Proxy Requests + ssl_context = make_ssl_context(keyfile, certfile, client_ca) httpclient.AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context}) - return ssl_context + return ssl_options def make_proxy_app( @@ -130,6 +145,8 @@ def __init__(self, *args, **kwargs): app = Application( [ + # Redirects from the JupyterHub might not contain a slash + (r"^" + re.escape(prefix) + r"$", AddSlashHandler), ( r"^" + re.escape(prefix) + r"/(.*)", Proxy, From f547e5f1d1f67efd7087a359cda3c8d17094996e Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Mon, 7 Oct 2024 13:36:29 +0200 Subject: [PATCH 11/21] Add Slash to Prefix --- jupyter_server_proxy/standalone/proxy.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 5d4b87b2..0d62db4c 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -3,15 +3,16 @@ import ssl from logging import Logger +from jupyter_server.utils import ensure_async from jupyterhub import __version__ as __jh_version__ from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated from jupyterhub.utils import make_ssl_context from tornado import httpclient, web from tornado.log import app_log -from tornado.web import Application +from tornado.web import Application, RedirectHandler from tornado.websocket import WebSocketHandler -from ..handlers import AddSlashHandler, SuperviseAndProxyHandler +from ..handlers import SuperviseAndProxyHandler class StandaloneHubProxyHandler(HubOAuthenticated, SuperviseAndProxyHandler): @@ -52,7 +53,7 @@ async def proxy(self, port, path): if self.skip_authentication: return await super().proxy(port, path) else: - return await self.oauth_proxy(port, path) + return await ensure_async(self.oauth_proxy(port, path)) @web.authenticated async def oauth_proxy(self, port, path): @@ -133,6 +134,7 @@ def __init__(self, *args, **kwargs): settings = dict( debug=debug, + base_url=prefix, # Required for JupyterHub hub_user=os.environ.get("JUPYTERHUB_USER", ""), hub_group=os.environ.get("JUPYTERHUB_GROUP", ""), @@ -143,12 +145,13 @@ def __init__(self, *args, **kwargs): app_log.debug(f"Restricting WebSocket Messages to {websocket_max_message_size}") settings["websocket_max_message_size"] = websocket_max_message_size + escaped_prefix = re.escape(prefix) app = Application( [ # Redirects from the JupyterHub might not contain a slash - (r"^" + re.escape(prefix) + r"$", AddSlashHandler), + (rf"^{escaped_prefix}$", RedirectHandler, dict(url=rf"^{escaped_prefix}/")), ( - r"^" + re.escape(prefix) + r"/(.*)", + rf"^{escaped_prefix}/(.*)", Proxy, dict( state={}, @@ -156,7 +159,7 @@ def __init__(self, *args, **kwargs): ), ), ( - r"^" + re.escape(prefix) + r"/oauth_callback", + rf"^{escaped_prefix}/oauth_callback", HubOAuthCallbackHandler, ), ], From 2a8334313c2baa15673215f9d2585d3f2776234d Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 11 Oct 2024 12:15:41 +0200 Subject: [PATCH 12/21] Fix error generation --- jupyter_server_proxy/standalone/proxy.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 0d62db4c..9e9f8848 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -9,7 +9,7 @@ from jupyterhub.utils import make_ssl_context from tornado import httpclient, web from tornado.log import app_log -from tornado.web import Application, RedirectHandler +from tornado.web import Application, RedirectHandler, RequestHandler from tornado.websocket import WebSocketHandler from ..handlers import SuperviseAndProxyHandler @@ -49,6 +49,14 @@ def set_default_headers(self): def prepare(self, *args, **kwargs): pass + def check_origin(self, origin: str = None): + # Skip JupyterHandler.check_origin + return WebSocketHandler.check_origin(self, origin) + + def write_error(self, status_code: int, **kwargs): + # ToDo: Return proper error page, like in jupyter-server/JupyterHub + return RequestHandler.write_error(self, status_code, **kwargs) + async def proxy(self, port, path): if self.skip_authentication: return await super().proxy(port, path) @@ -59,10 +67,6 @@ async def proxy(self, port, path): async def oauth_proxy(self, port, path): return await super().proxy(port, path) - def check_origin(self, origin: str = None): - # Skip JupyterHandler.check_origin - return WebSocketHandler.check_origin(self, origin) - def get_env(self): return self._render_template(self.environment) From 325217bf01566a830a16a7806a8775a111162c0f Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Tue, 15 Oct 2024 16:35:29 +0200 Subject: [PATCH 13/21] Fixed ordering in handlers The `oauth_callback` requests were handled by the ProxyHandler, effectively causing the request to ping-pong between JupyterHub Login and `/oauth_callback` --- jupyter_server_proxy/standalone/proxy.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 9e9f8848..610d11e9 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -153,19 +153,16 @@ def __init__(self, *args, **kwargs): app = Application( [ # Redirects from the JupyterHub might not contain a slash - (rf"^{escaped_prefix}$", RedirectHandler, dict(url=rf"^{escaped_prefix}/")), + (f"^{escaped_prefix}$", RedirectHandler, dict(url=f"^{escaped_prefix}/")), + (f"^{escaped_prefix}/oauth_callback", HubOAuthCallbackHandler), ( - rf"^{escaped_prefix}/(.*)", + f"^{escaped_prefix}/(.*)", Proxy, dict( state={}, # ToDo: progressive=progressive ), ), - ( - rf"^{escaped_prefix}/oauth_callback", - HubOAuthCallbackHandler, - ), ], **settings, ) From 7f85c9f428bf347d3b20d1920002f74b864199fe Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 18 Oct 2024 12:03:18 +0200 Subject: [PATCH 14/21] Defer xsrf checking to proxied app --- jupyter_server_proxy/standalone/proxy.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 610d11e9..a00181fd 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -53,6 +53,10 @@ def check_origin(self, origin: str = None): # Skip JupyterHandler.check_origin return WebSocketHandler.check_origin(self, origin) + def check_xsrf_cookie(self): + # Skip HubAuthenticated.check_xsrf_cookie + pass + def write_error(self, status_code: int, **kwargs): # ToDo: Return proper error page, like in jupyter-server/JupyterHub return RequestHandler.write_error(self, status_code, **kwargs) From 87efc56b8b88751b16dbfd225cee5488b7c14dd9 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Tue, 12 Nov 2024 14:54:15 +0100 Subject: [PATCH 15/21] Add Documentation for standalone --- docs/source/index.md | 1 + docs/source/standalone.md | 172 ++++++++++++++++++++ jupyter_server_proxy/standalone/__init__.py | 12 +- 3 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 docs/source/standalone.md diff --git a/docs/source/index.md b/docs/source/index.md index 1ece9d14..fe6f77fa 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -30,6 +30,7 @@ install server-process launchers arbitrary-ports-hosts +standalone ``` ## Convenience packages for popular applications diff --git a/docs/source/standalone.md b/docs/source/standalone.md new file mode 100644 index 00000000..ad930627 --- /dev/null +++ b/docs/source/standalone.md @@ -0,0 +1,172 @@ +(standanlone)= + +# Spawning and proxying a web service from JupyterHub + +The `standalone` feature of Jupyter Server Proxy enables JupyterHub Admins to launch and proxy arbitrary web services +directly, in place of the JupyterLab or Notebook. You can use Jupyter Server Proxy to spawn a single proxy, +without it being attached to a Jupyter server. The proxy securely authenticates and restricts access to authorized +users through JupyterHub, giving a unified way to securely provide arbitrary applications. + +This works similar to {ref}`proxying Server Processes `, where a server process is started and proxied. +The Proxy is usually started from the command line, often by modifying the `Spawner.cmd` in your +[JupyterHub Configuration](https://jupyterhub.readthedocs.io/en/stable/tutorial/getting-started/spawners-basics.html). + +This feature builds upon the work of [Dan Lester](https://github.com/danlester), who originally developed it in the +[jhsingle-native-proxy](https://github.com/ideonate/jhsingle-native-proxy) package. + +## Installation + +This feature has a dependency to JupyterHub and must be explicitly installed via an optional dependency: + +```shell +pip install jupyter-server-proxy[standalone] +``` + +## Usage + +The standalone proxy is controlled with the `jupyter standaloneproxy` command. You always need to specify the +{ref}`command ` of the web service that will be launched and proxied. Let's use +[voilà](https://github.com/voila-dashboards/voila) as an example here: + +```shell +jupyter standaloneproxy -- voila --no-browser --port={port} /path/to/some/Notebook.ipynb +``` + +Executing this command will spawn a new HTTP Server, which will spawn the voilà dashboard and render the notebook. +Any template strings (like the `--port={port}`) inside the command will be automatically replaced when the command is +executed. + +The CLI has multiple advanced options to customize the behavior of the proxy. Execute `jupyter standaloneproxy --help` +to get a complete list of all arguments. + +### Specify address and port + +The proxy will try to extract the address and port from the `JUPYTERHUB_SERVICE_URL` environment variable, which is +set if an application is launched by JupyterHub. Otherwise, it will be launched on `127.0.0.1:8888`. +You can also explicitly overwrite these values: + +```shell +jupyter standaloneproxy --address=localhost --port=8000 ... +``` + +### Disable Authentication + +For testing, it can be useful to disable the authentication with JupyterHub. Passing `--skip-authentication` will +not triggering the login process when accessing the application. + +```{warning} Disabling authentication will leave the application open to anyone! Be careful with it, +especially on multi-user systems. +``` + +## Usage with JupyterHub + +To launch a standalone proxy with JupyterHub, you need to customize the `Spawner` inside the configuration +using traitlets: + +```python +c.Spawner.cmd = "jupyter-standaloneproxy" +c.Spawner.args = ["--", "voila", "--no-browser", "--port={port}", "/path/to/some/Notebook.ipynb"] +``` + +This will hard-code JupyterHub to launch voilà instead of `jupyterhub-singleuser`. In case you want to give the users +of the JupyterHub the ability to select which application to launch (like selecting either JupyterLab or voilà), +you will want to make this configuration optional: + +```python +# Let users select which application start +c.Spawner.options_form = """ + + + """ + +def select_application(spawner): + application = spawner.user_options.get("application", ["lab"])[0] + if application == "voila": + spawner.cmd = "jupyter-standaloneproxy" + spawner.args = ["--", "voila", "--no-browser", "--port={port}", "/path/to/some/Notebook.ipynb"] + +c.Spawner.pre_spawn_hook = select_application +``` + +```{note} This is only a very basic implementation to show a possible approach. For a production setup, you can create +a more rigorous implementation by creating a custom `Spawner` and overwriting the appropriate functions and/or +creating a custom `spawner.html` page. +``` + +## Technical Overview + +The following section should serve as an explanation to developers of the standalone feature of jupyter-server-proxy. +It outlines the basic functionality and will explain the different components of the code in more depth. + +### JupyterHub and jupyterhub-singleuser + +By default, JupyterHub will use the `jupyterhub-singleuser` executable when launching a new instance for a user. +This executable is usually a wrapper around the `JupyterLab` or `Notebook` application, with some +additions regarding authentication and multi-user systems. +In the standalone feature, we try to mimic these additions, but instead of using `JupyterLab` or `Notebook`, we +will wrap them around an arbitrary web application. +This will ensure only authenticated access to the application, while providing direct access to the application +without needing a Jupyter server to be running in the background. +The different additions will be discussed in more detail below. + +### Structure + +The standalone feature is built on top of the `SuperviseAndProxyhandler`, which will spawn a process and proxy +requests to this server. While this process is called _Server_ in the documentation, I will call it _Application_ +here, to avoid confusion with the other server where the `SuperviseAndProxyhandler` is attached to. +When using jupyter-server-proxy, the proxies are attached to the Jupyter server and will proxy requests +to the application. +Since we do not want to use the Jupyter server here, we instead require an alternative server, which will be used +to attach the `SuperviseAndProxyhandler` and all the required additions from `jupyterhub-singleuser`. +For that, we use tornado `HTTPServer`. + +### Login and Authentication + +One central component is the authentication with the JupyterHub Server. +Any client accessing the application will need to authenticate with the JupyterHub API, which will ensure only +the user themselves (or otherwise allowed users, e.g., admins) can access the application. +The Login process is started by deriving our `StandaloneProxyHandler` from +[jupyterub.services.auth.HubOAuthenticated](https://github.com/jupyterhub/jupyterhub/blob/5.0.0/jupyterhub/services/auth.py#L1541) +and decorating any methods we want to authenticate with `tornado.web.authenticated`. +For the proxy, we just decorate the `proxy` method with `web.authenticated`, which will authenticate all routes on all HTTP Methods. +`HubOAuthenticated` will automatically provide the login URL for the authentication process and any +client accessing any path of our server will be redirected to the JupyterHub API. + +After a client has been authenticated with the JupyterHub API, they will be redirected back to our server. +This redirect will be received on the `/oauth_callback` path, from where we need to redirect the client back to the +root of the application. +We use the [HubOAuthCallbackHander](https://github.com/jupyterhub/jupyterhub/blob/5.0.0/jupyterhub/services/auth.py#L1547), +another handler from the JupyterHub package, for this. +It will also cache the received OAuth state from the login, so that we can skip authentication for the next requests +and do not need to go through the whole login process for each request. + +### SSL certificates + +In some JupyterHub configurations, the launched application will be configured to use an SSL certificate for request +between the JupyterLab / Notebook and the JupyterHub API. The path of the certificate is given in the +`JUPYTERHUB_SSL_*` environment variables. We use these variables to create a new SSL Context for both +the `AsyncHTTPClient` (used for Activity Notification, see below) and the `HTTPServer`. + +### Activity Notifications + +The `jupyterhub-singleuser` will periodically send an activity notification to the JupyterHub API and inform it that +the currently running application is still active. Whether this information is actually used or not depends on the +specific configuration of this JupyterHub. + +### Environment Variables + +JupyterHub uses a lot of environment variables to specify how the launched app should be run. +This list is a small overview of all used variables and what they contain and are used for. + +| Variable | Explanation | Typical Value | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `JUPYTERHUB_SERVICE_URL` | URL where the server should be listening. Used to find the Address and Port to start the server on. | `http://127.0.0.1:5555` | +| `JUPYTERHUB_SERVICE_PREFIX` | An URL Prefix where the root of the launched application should be hosted. E.g., when set to `/user/name/`, then the root of the proxied aplication should be `/user/name/index.html` | `/services/service-name/` or `/user/name/` | +| `JUPYTERHUB_ACTIVITY_URL` | URL where to send activity notifications to. | `$JUPYTERHUB_API_URL/user/name/activity` | +| `JUPYTERHUB_API_TOKEN` | Authorization Token for requests to the JupyterHub API. | | +| `JUPYTERHUB_SERVER_NAME` | A name given to all apps launched by the JupyterHub. | | +| `JUPYTERHUB_SSL_KEYFILE`, `JUPYTERHUB_SSL_CERTFILE`, `JUPYTERHUB_SSL_CLIENT_CA` | Paths to keyfile, certfile and client CA for the SSL configuration | | +| `JUPYTERHUB_USER`, `JUPYTERHUB_GROUP` | Name and Group of the user for this application. Required for Authentication | diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index 31902ee9..3ce43754 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -103,7 +103,7 @@ def run( def main(): parser = argparse.ArgumentParser( "jupyter-standalone-proxy", - description="Wrap an arbitrary WebApp so it can be used in place of 'jupyterhub-singleuser' in a JupyterHub setting.", + description="Wrap an arbitrary web service so it can be used in place of 'jupyterhub-singleuser' in a JupyterHub setting.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) @@ -112,14 +112,14 @@ def main(): "--port", type=int, dest="port", - help="Set port for the proxy server to listen on. Will use 'JUPYTERHUB_SERVICE_URL' or '127.0.0.1' by default.", + help="Set port for the proxy server to listen on. Will use 'JUPYTERHUB_SERVICE_URL' or '8888' by default.", ) parser.add_argument( "-a", "--address", type=str, dest="address", - help="Set address for the proxy server to listen on. Will use 'JUPYTERHUB_SERVICE_URL' or '8888' by default.", + help="Set address for the proxy server to listen on. Will use 'JUPYTERHUB_SERVICE_URL' or '127.0.0.1' by default.", ) parser.add_argument( "-s", @@ -127,7 +127,7 @@ def main(): default=0, type=int, dest="server_port", - help="Port for the WebApp should end up running on (0 for random open port).", + help="Port for the web service should end up running on (0 for random open port).", ) parser.add_argument( "--socket-path", @@ -187,7 +187,7 @@ def main(): "--activity-interval", default=300, type=int, - help="Frequency to notify Hub that the WebApp is still running (In seconds, 0 for never).", + help="Frequency to notify Hub that the service is still running (In seconds, 0 for never).", ) # ToDo: Progressive Proxy # parser.add_argument( @@ -203,7 +203,7 @@ def main(): help="Max size of websocket data (leave at 0 for library defaults).", ) parser.add_argument( - "command", nargs="+", help="The command executed for starting the WebApp" + "command", nargs="+", help="The command executed for starting the web service." ) args = parser.parse_args() From 7b33d45bc021c25db472dc9a3c33ebf2b0e0ce74 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 15 Nov 2024 13:37:01 +0100 Subject: [PATCH 16/21] Add Tests for the StandaloneProxy --- jupyter_server_proxy/standalone/__init__.py | 2 + jupyter_server_proxy/standalone/proxy.py | 4 +- pyproject.toml | 1 + tests/test_standalone.py | 125 ++++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/test_standalone.py diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index 3ce43754..521d733d 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations # For Python 3.8 compatibility + import argparse import logging import os diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index a00181fd..24ea63e9 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -1,3 +1,5 @@ +from __future__ import annotations # For Python 3.8 compatibility + import os import re import ssl @@ -157,7 +159,7 @@ def __init__(self, *args, **kwargs): app = Application( [ # Redirects from the JupyterHub might not contain a slash - (f"^{escaped_prefix}$", RedirectHandler, dict(url=f"^{escaped_prefix}/")), + (f"^{escaped_prefix}$", RedirectHandler, dict(url=f"{escaped_prefix}/")), (f"^{escaped_prefix}/oauth_callback", HubOAuthCallbackHandler), ( f"^{escaped_prefix}/(.*)", diff --git a/pyproject.toml b/pyproject.toml index abf57f5f..d70bcba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ [project.optional-dependencies] test = [ + "jupyter-server-proxy[standalone]", "pytest", "pytest-asyncio", "pytest-cov", diff --git a/tests/test_standalone.py b/tests/test_standalone.py new file mode 100644 index 00000000..f630bb44 --- /dev/null +++ b/tests/test_standalone.py @@ -0,0 +1,125 @@ +import sys +from pathlib import Path + +import pytest +from tornado import testing + +from jupyter_server_proxy.standalone import _default_address_and_port, make_proxy_app + +""" +Test if address and port are identified correctly +""" + + +def test_address_and_port_with_http_address(monkeypatch): + monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "http://localhost/") + address, port = _default_address_and_port() + + assert address == "localhost" + assert port == 80 + + +def test_address_and_port_with_https_address(monkeypatch): + monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "https://localhost/") + address, port = _default_address_and_port() + + assert address == "localhost" + assert port == 443 + + +def test_address_and_port_with_address_and_port(monkeypatch): + monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "http://localhost:7777/") + address, port = _default_address_and_port() + + assert address == "localhost" + assert port == 7777 + + +def make_app(unix_socket: bool, skip_authentication: bool): + command = [ + sys.executable, + str(Path(__file__).parent / "resources" / "httpinfo.py"), + "--port={port}", + "--unix-socket={unix_socket}", + ] + + return make_proxy_app( + command=command, + prefix="/some/prefix", + port=0, + unix_socket=unix_socket, + environment={}, + mappath={}, + timeout=60, + skip_authentication=skip_authentication, + debug=True, + websocket_max_message_size=0, + ) + + +class TestStandaloneProxyRedirect(testing.AsyncHTTPTestCase): + """ + Ensure requests are proxied to the application. We need to disable authentication here, + as we do not want to be redirected to the JupyterHub Login. + """ + + runTest = None # Required for Tornado 6.1 + + def get_app(self): + return make_app(False, True) + + def test_add_slash(self): + response = self.fetch("/some/prefix", follow_redirects=False) + + assert response.code == 301 + assert response.headers.get("Location") == "/some/prefix/" + + def test_without_prefix(self): + response = self.fetch("/some/other/prefix") + + assert response.code == 404 + + def test_on_prefix(self): + response = self.fetch("/some/prefix/") + assert response.code == 200 + + body = response.body.decode() + assert body.startswith("GET /") + assert "X-Forwarded-Context: /some/prefix/" in body + assert "X-Proxycontextpath: /some/prefix/" in body + + +@pytest.mark.skipif( + sys.platform == "win32", reason="Unix socket not supported on Windows" +) +class TestStandaloneProxyWithUnixSocket(testing.AsyncHTTPTestCase): + runTest = None # Required for Tornado 6.1 + + def get_app(self): + return make_app(True, True) + + def test_with_unix_socket(self): + response = self.fetch("/some/prefix/") + assert response.code == 200 + + body = response.body.decode() + assert body.startswith("GET /") + assert "X-Forwarded-Context: /some/prefix/" in body + assert "X-Proxycontextpath: /some/prefix/" in body + + +class TestStandaloneProxyLogin(testing.AsyncHTTPTestCase): + """ + Ensure we redirect to JupyterHub login when authentication is enabled + """ + + runTest = None # Required for Tornado 6.1 + + def get_app(self): + return make_app(False, False) + + def test_redirect_to_login_url(self): + response = self.fetch("/some/prefix/", follow_redirects=False) + + assert response.code == 302 + assert "Location" in response.headers From 941356f9761ce549a7acf1a4b4a54e419189e5e9 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Tue, 3 Dec 2024 17:01:04 +0100 Subject: [PATCH 17/21] Fix Typos and minor cleanups --- docs/source/standalone.md | 45 ++++++++++----------- jupyter_server_proxy/standalone/__init__.py | 8 ++-- jupyter_server_proxy/standalone/activity.py | 2 +- jupyter_server_proxy/standalone/proxy.py | 4 +- 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/docs/source/standalone.md b/docs/source/standalone.md index ad930627..a0a9158c 100644 --- a/docs/source/standalone.md +++ b/docs/source/standalone.md @@ -3,11 +3,11 @@ # Spawning and proxying a web service from JupyterHub The `standalone` feature of Jupyter Server Proxy enables JupyterHub Admins to launch and proxy arbitrary web services -directly, in place of the JupyterLab or Notebook. You can use Jupyter Server Proxy to spawn a single proxy, +directly, instead of JupyterLab or Notebook. You can use Jupyter Server Proxy to spawn a single proxy, without it being attached to a Jupyter server. The proxy securely authenticates and restricts access to authorized -users through JupyterHub, giving a unified way to securely provide arbitrary applications. +users through JupyterHub, providing a unified way to access arbitrary applications securely. -This works similar to {ref}`proxying Server Processes `, where a server process is started and proxied. +This works similarly to {ref}`proxying Server Processes `, where a server process is started and proxied. The Proxy is usually started from the command line, often by modifying the `Spawner.cmd` in your [JupyterHub Configuration](https://jupyterhub.readthedocs.io/en/stable/tutorial/getting-started/spawners-basics.html). @@ -16,7 +16,7 @@ This feature builds upon the work of [Dan Lester](https://github.com/danlester), ## Installation -This feature has a dependency to JupyterHub and must be explicitly installed via an optional dependency: +This feature has a dependency on JupyterHub and must be explicitly installed via an optional dependency: ```shell pip install jupyter-server-proxy[standalone] @@ -32,17 +32,17 @@ The standalone proxy is controlled with the `jupyter standaloneproxy` command. Y jupyter standaloneproxy -- voila --no-browser --port={port} /path/to/some/Notebook.ipynb ``` -Executing this command will spawn a new HTTP Server, which will spawn the voilà dashboard and render the notebook. +Executing this command will spawn a new HTTP Server, creating the voilà dashboard and rendering the notebook. Any template strings (like the `--port={port}`) inside the command will be automatically replaced when the command is executed. -The CLI has multiple advanced options to customize the behavior of the proxy. Execute `jupyter standaloneproxy --help` +The CLI has multiple advanced options to customize the proxy behavior. Execute `jupyter standaloneproxy --help` to get a complete list of all arguments. -### Specify address and port +### Specify the address and port -The proxy will try to extract the address and port from the `JUPYTERHUB_SERVICE_URL` environment variable, which is -set if an application is launched by JupyterHub. Otherwise, it will be launched on `127.0.0.1:8888`. +The proxy will try to extract the address and port from the `JUPYTERHUB_SERVICE_URL` environment variable. This variable +will be set by JupyterHub. Otherwise, the server will be launched on `127.0.0.1:8888`. You can also explicitly overwrite these values: ```shell @@ -52,7 +52,7 @@ jupyter standaloneproxy --address=localhost --port=8000 ... ### Disable Authentication For testing, it can be useful to disable the authentication with JupyterHub. Passing `--skip-authentication` will -not triggering the login process when accessing the application. +not trigger the login process when accessing the application. ```{warning} Disabling authentication will leave the application open to anyone! Be careful with it, especially on multi-user systems. @@ -61,7 +61,7 @@ especially on multi-user systems. ## Usage with JupyterHub To launch a standalone proxy with JupyterHub, you need to customize the `Spawner` inside the configuration -using traitlets: +using `traitlets`: ```python c.Spawner.cmd = "jupyter-standaloneproxy" @@ -69,7 +69,7 @@ c.Spawner.args = ["--", "voila", "--no-browser", "--port={port}", "/path/to/some ``` This will hard-code JupyterHub to launch voilà instead of `jupyterhub-singleuser`. In case you want to give the users -of the JupyterHub the ability to select which application to launch (like selecting either JupyterLab or voilà), +of JupyterHub the ability to select which application to launch (like selecting either JupyterLab or voilà), you will want to make this configuration optional: ```python @@ -108,15 +108,14 @@ This executable is usually a wrapper around the `JupyterLab` or `Notebook` appli additions regarding authentication and multi-user systems. In the standalone feature, we try to mimic these additions, but instead of using `JupyterLab` or `Notebook`, we will wrap them around an arbitrary web application. -This will ensure only authenticated access to the application, while providing direct access to the application -without needing a Jupyter server to be running in the background. -The different additions will be discussed in more detail below. +This will ensure direct, authenticated access to the application, without needing a Jupyter server to be running +in the background. The different additions will be discussed in more detail below. ### Structure The standalone feature is built on top of the `SuperviseAndProxyhandler`, which will spawn a process and proxy -requests to this server. While this process is called _Server_ in the documentation, I will call it _Application_ -here, to avoid confusion with the other server where the `SuperviseAndProxyhandler` is attached to. +requests to this server. While this process is called _Server_ in the documentation, the term _Application_ will be +used here, to avoid confusion with the other server where the `SuperviseAndProxyhandler` is attached to. When using jupyter-server-proxy, the proxies are attached to the Jupyter server and will proxy requests to the application. Since we do not want to use the Jupyter server here, we instead require an alternative server, which will be used @@ -127,9 +126,9 @@ For that, we use tornado `HTTPServer`. One central component is the authentication with the JupyterHub Server. Any client accessing the application will need to authenticate with the JupyterHub API, which will ensure only -the user themselves (or otherwise allowed users, e.g., admins) can access the application. +users themselves (or otherwise allowed users, e.g., admins) can access the application. The Login process is started by deriving our `StandaloneProxyHandler` from -[jupyterub.services.auth.HubOAuthenticated](https://github.com/jupyterhub/jupyterhub/blob/5.0.0/jupyterhub/services/auth.py#L1541) +[jupyterhub.services.auth.HubOAuthenticated](https://github.com/jupyterhub/jupyterhub/blob/5.0.0/jupyterhub/services/auth.py#L1541) and decorating any methods we want to authenticate with `tornado.web.authenticated`. For the proxy, we just decorate the `proxy` method with `web.authenticated`, which will authenticate all routes on all HTTP Methods. `HubOAuthenticated` will automatically provide the login URL for the authentication process and any @@ -140,12 +139,12 @@ This redirect will be received on the `/oauth_callback` path, from where we need root of the application. We use the [HubOAuthCallbackHander](https://github.com/jupyterhub/jupyterhub/blob/5.0.0/jupyterhub/services/auth.py#L1547), another handler from the JupyterHub package, for this. -It will also cache the received OAuth state from the login, so that we can skip authentication for the next requests +It will also cache the received OAuth state from the login so that we can skip authentication for the next requests and do not need to go through the whole login process for each request. ### SSL certificates -In some JupyterHub configurations, the launched application will be configured to use an SSL certificate for request +In some JupyterHub configurations, the launched application will be configured to use an SSL certificate for requests between the JupyterLab / Notebook and the JupyterHub API. The path of the certificate is given in the `JUPYTERHUB_SSL_*` environment variables. We use these variables to create a new SSL Context for both the `AsyncHTTPClient` (used for Activity Notification, see below) and the `HTTPServer`. @@ -153,8 +152,8 @@ the `AsyncHTTPClient` (used for Activity Notification, see below) and the `HTTPS ### Activity Notifications The `jupyterhub-singleuser` will periodically send an activity notification to the JupyterHub API and inform it that -the currently running application is still active. Whether this information is actually used or not depends on the -specific configuration of this JupyterHub. +the currently running application is still active. Whether this information is used or not depends on the specific +configuration of this JupyterHub. ### Environment Variables diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index 521d733d..2de7e96b 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -1,4 +1,4 @@ -from __future__ import annotations # For Python 3.8 compatibility +from __future__ import annotations import argparse import logging @@ -66,7 +66,7 @@ def run( port = port or address_port_default[1] if skip_authentication: - log.warn("Disabling Authentication with JuypterHub Server!") + log.warn("Disabling Authentication with JupyterHub Server!") prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") @@ -95,7 +95,7 @@ def run( # Periodically send JupyterHub Notifications, that we are still running if activity_interval > 0: log.info( - f"Sending Acitivity Notivication to JupyterHub with interval={activity_interval}s" + f"Sending Activity Notification to JupyterHub with interval={activity_interval}s" ) start_activity_update(activity_interval) @@ -140,7 +140,7 @@ def main(): parser.add_argument( "--socket-auto", action="store_true", - help="Use Unix Socket for proxying, but let Jupyter Server Proxy automatically create one.", + help="Use Unix Socket for proxying, but let jupyter-server-proxy automatically create one.", ) parser.add_argument( "--env", diff --git a/jupyter_server_proxy/standalone/activity.py b/jupyter_server_proxy/standalone/activity.py index f08085e0..8028ca7d 100644 --- a/jupyter_server_proxy/standalone/activity.py +++ b/jupyter_server_proxy/standalone/activity.py @@ -10,7 +10,7 @@ async def notify_activity(): """ Regularly notify JupyterHub of activity. - See `jupyrehub/singleuser/extensions#L396` + See https://github.com/jupyterhub/jupyterhub/blob/4.x/jupyterhub/singleuser/extension.py#L389 """ client = httpclient.AsyncHTTPClient() diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 24ea63e9..6dc7dea1 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -1,4 +1,4 @@ -from __future__ import annotations # For Python 3.8 compatibility +from __future__ import annotations import os import re @@ -81,7 +81,7 @@ def get_timeout(self): def configure_ssl(): - # See jupyter_server/serverapp:init_webapp + # See https://github.com/jupyter-server/jupyter_server/blob/v2.0.0/jupyter_server/serverapp.py#L2053-L2073 keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE", "") certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE", "") client_ca = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA", "") From 0228e867944491a234ea170214754140f6f94eac Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Fri, 6 Dec 2024 15:59:10 +0100 Subject: [PATCH 18/21] Switch from argparse to traitlets.Application and use config.ServerProcess --- jupyter_server_proxy/standalone/__init__.py | 211 +-------------- jupyter_server_proxy/standalone/app.py | 273 ++++++++++++++++++++ jupyter_server_proxy/standalone/proxy.py | 95 +------ 3 files changed, 282 insertions(+), 297 deletions(-) create mode 100644 jupyter_server_proxy/standalone/app.py diff --git a/jupyter_server_proxy/standalone/__init__.py b/jupyter_server_proxy/standalone/__init__.py index 2de7e96b..117d4ac6 100644 --- a/jupyter_server_proxy/standalone/__init__.py +++ b/jupyter_server_proxy/standalone/__init__.py @@ -1,215 +1,8 @@ -from __future__ import annotations - -import argparse -import logging -import os -from urllib.parse import urlparse - -from tornado import ioloop -from tornado.httpserver import HTTPServer -from tornado.log import app_log as log -from tornado.log import enable_pretty_logging, gen_log - -from .activity import start_activity_update -from .proxy import configure_ssl, make_proxy_app - - -def _default_address_and_port() -> tuple[str, int]: - """ - Get the Address and Port for the Proxy, either from JUPYTERHUB_SERVICE_URL or default values. - See https://github.com/jupyterhub/jupyterhub/blob/4.x/jupyterhub/singleuser/mixins.py#L266-L284. - """ - address = "127.0.0.1" - port = 8888 - - if os.environ.get("JUPYTERHUB_SERVICE_URL"): - url = urlparse(os.environ["JUPYTERHUB_SERVICE_URL"]) - - if url.hostname: - address = url.hostname - - if url.port: - port = url.port - elif url.scheme == "http": - port = 80 - elif url.scheme == "https": - port = 443 - - return address, port - - -def run( - command: list[str], - port: int | None, - address: str | None, - server_port: int, - socket_path: str | None, - socket_auto: bool, - environment: list[tuple[str, str]] | None, - mappath: list[tuple[str, str]] | None, - debug: bool, - # logs: bool, - skip_authentication: bool, - timeout: int, - activity_interval: int, - # progressive: bool, - websocket_max_message_size: int, -): - # Setup Logging - enable_pretty_logging(logger=log) - if debug: - log.setLevel(logging.DEBUG) - gen_log.setLevel(logging.DEBUG) - - address_port_default = _default_address_and_port() - address = address or address_port_default[0] - port = port or address_port_default[1] - - if skip_authentication: - log.warn("Disabling Authentication with JupyterHub Server!") - - prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/") - - app = make_proxy_app( - command, - prefix.removesuffix("/"), - server_port, - socket_path or socket_auto, - dict(environment), - dict(mappath), - timeout, - skip_authentication, - debug, - # progressive, - websocket_max_message_size, - ) - - ssl_options = configure_ssl() - http_server = HTTPServer(app, ssl_options=ssl_options, xheaders=True) - http_server.listen(port, address) - - log.info(f"Starting standaloneproxy on '{address}:{port}'") - log.info(f"URL Prefix: {prefix!r}") - log.info(f"Command: {' '.join(command)!r}") - - # Periodically send JupyterHub Notifications, that we are still running - if activity_interval > 0: - log.info( - f"Sending Activity Notification to JupyterHub with interval={activity_interval}s" - ) - start_activity_update(activity_interval) - - ioloop.IOLoop.current().start() +from .app import StandaloneProxyServer def main(): - parser = argparse.ArgumentParser( - "jupyter-standalone-proxy", - description="Wrap an arbitrary web service so it can be used in place of 'jupyterhub-singleuser' in a JupyterHub setting.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - - parser.add_argument( - "-p", - "--port", - type=int, - dest="port", - help="Set port for the proxy server to listen on. Will use 'JUPYTERHUB_SERVICE_URL' or '8888' by default.", - ) - parser.add_argument( - "-a", - "--address", - type=str, - dest="address", - help="Set address for the proxy server to listen on. Will use 'JUPYTERHUB_SERVICE_URL' or '127.0.0.1' by default.", - ) - parser.add_argument( - "-s", - "--server-port", - default=0, - type=int, - dest="server_port", - help="Port for the web service should end up running on (0 for random open port).", - ) - parser.add_argument( - "--socket-path", - type=str, - default=None, - help="Path to the Unix Socket to use for proxying. Takes precedence over '-s/--server_port' and '--socket-auto'.", - ) - parser.add_argument( - "--socket-auto", - action="store_true", - help="Use Unix Socket for proxying, but let jupyter-server-proxy automatically create one.", - ) - parser.add_argument( - "--env", - "--environment", - type=lambda v: tuple(v.split(":")[:2]), - default=[], - action="append", - dest="environment", - help="Add an environment variable to the server process. Must be of the form :, e.g. --env=MY_VAR:42", - ) - parser.add_argument( - "--mappath", - type=lambda v: tuple(v.split(":")[:2]), - default=[], - action="append", - help="Add an path mapping to the proxy. Any requests received under will be redirected to . " - "Must be of the form :, e.g. --mappath=/:/index.html", - ) - parser.add_argument( - "-d", - "--debug", - action="store_true", - default=False, - dest="debug", - help="Display debug level logs.", - ) - # ToDo: Split Server and Application Logger - # parser.add_argument( - # "--logs", - # action="store_true", - # default=True, - # help="Display logs generated by the subprocess.", - # ) - parser.add_argument( - "--skip-authentication", - action="store_true", - help="Do not enforce authentication with the JupyterHub Server.", - ) - parser.add_argument( - "--timeout", - default=60, - type=int, - help="Timeout to wait until the subprocess has started and can be addressed.", - ) - parser.add_argument( - "--activity-interval", - default=300, - type=int, - help="Frequency to notify Hub that the service is still running (In seconds, 0 for never).", - ) - # ToDo: Progressive Proxy - # parser.add_argument( - # "--progressive", - # action="store_true", - # default=False, - # help="Progressively flush responses as they arrive (good for Voila).", - # ) - parser.add_argument( - "--websocket-max-message-size", - default=0, - type=int, - help="Max size of websocket data (leave at 0 for library defaults).", - ) - parser.add_argument( - "command", nargs="+", help="The command executed for starting the web service." - ) - - args = parser.parse_args() - run(**vars(args)) + StandaloneProxyServer.launch_instance() if __name__ == "__main__": diff --git a/jupyter_server_proxy/standalone/app.py b/jupyter_server_proxy/standalone/app.py new file mode 100644 index 00000000..bae67252 --- /dev/null +++ b/jupyter_server_proxy/standalone/app.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import logging +import os +import re +import ssl +from textwrap import dedent +from urllib.parse import urlparse + +from jupyterhub.services.auth import HubOAuthCallbackHandler +from jupyterhub.utils import make_ssl_context +from tornado import httpclient, httpserver, ioloop, web +from tornado.web import RedirectHandler +from traitlets.config import Application as TraitletsApplication +from traitlets.traitlets import Bool, Int, Unicode, default, validate + +from ..config import ServerProcess +from .activity import start_activity_update +from .proxy import make_proxy + + +class StandaloneProxyServer(TraitletsApplication, ServerProcess): + name = "jupyter-standalone-proxy" + description = """ + Wrap an arbitrary web service so it can be used in place of 'jupyterhub-singleuser' + in a JupyterHub setting. + + Usage: jupyter standaloneproxy [options] -- + + The will be executed to start the web service once the proxy receives the first request. The command can + contain the placeholders '{{port}}', '{{unix_socket}}' and '{{base_url}}', which will be replaced with the + appropriate values once the application starts. + + For more details, see the jupyter-server-proxy documentation. + """ + + base_url = Unicode( + help=""" + Base URL where Requests will be received and proxied. Usually taken from the + "JUPYTERHUB_SERVICE_PREFIX" environment variable (or "/" when not set). + Set to overwrite. + + When setting to "/foo/bar", only incoming requests starting with this prefix will + be answered by the server and proxied to the proxied app. Any other requests will + get a 404 response. + """, + ).tag(config=True) + + @default("prefix") + def _default_prefix(self): + return os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/").removesuffix("/") + + @validate("prefix") + def _validate_prefix(self, proposal): + return proposal["value"].removesuffix("/") + + skip_authentication = Bool( + default=False, + help=""" + Do not authenticate access to the server via JupyterHub. When set, + incoming requests will not be authenticated and anyone can access the + application. + + WARNING: Disabling Authentication can be a major security issue. + """, + ).tag(config=True) + + address = Unicode( + help=""" + The address where the proxy server can be accessed. The address is usually taken from the `JUPYTERHUB_SERVICE_URL` + environment variable or will default to `127.0.0.1`. Used to explicitely overwrite the address of the server. + """ + ).tag(config=True) + + @default("address") + def _default_address(self): + if os.environ.get("JUPYTERHUB_SERVICE_URL"): + url = urlparse(os.environ["JUPYTERHUB_SERVICE_URL"]) + if url.hostname: + return url.hostname + + return "127.0.0.1" + + port = Int( + help=""" + The port where the proxy server can be accessed. The port is usually taken from the `JUPYTERHUB_SERVICE_URL` + environment variable or will default to `8888`. Used to explicitely overwrite the port of the server. + """ + ).tag(config=True) + + @default("port") + def _default_port(self): + if os.environ.get("JUPYTERHUB_SERVICE_URL"): + url = urlparse(os.environ["JUPYTERHUB_SERVICE_URL"]) + + if url.port: + return url.port + elif url.scheme == "http": + return 80 + elif url.scheme == "https": + return 443 + + return 8888 + + server_port = Int(default_value=0, help=ServerProcess.port.help).tag(config=True) + + activity_interval = Int( + default_value=300, + help=""" + Specify an interval to send regulat activity updated to the JupyterHub (in Seconds). + When enabled, the StandaloneProxy will try to send a POST request to the JupyterHub API + containing a timestamp and the name of the server. + The URL for the activity Endpoint needs to be specified in the "JUPYTERHUB_ACTIVITY_URL" + environment variable. This URL usually is "/api/users//activity". + + Set to 0 to disable activity notifications. + """, + ).tag(config=True) + + websocket_max_message_size = Int( + default_value=None, + allow_none=True, + help="Restrict the size of a message in a WebSocket connection (in bytes). Tornado defaults to 10MiB.", + ).tag(config=True) + + @default("command") + def _default_command(self): + # ToDo: Find a better way to do this + return self.extra_args + + def __init__(self): + super().__init__() + + # Flags for CLI + self.flags = { + **super().flags, + "absolute-url": ( + {"ServerProcess": {"absolute_url": True}}, + dedent(ServerProcess.absolute_url.help), + ), + "raw-socket-proxy": ( + {"ServerProcess": {"raw_socket_proxy": True}}, + dedent(ServerProcess.raw_socket_proxy.help), + ), + "skip-authentication": ( + {"StandaloneProxyServer": {"skip_authentication": True}}, + dedent(self.__class__.skip_authentication.help), + ), + } + + # Create an Alias to all Traits defined in ServerProcess, with some + # exeptions we do not need, for easier use of the CLI + # We don't need "command" here, as we will take it from the extra_args + ignore_traits = [ + "launcher_entry", + "new_browser_tab", + "rewrite_response", + "update_last_activity", + "command", + ] + server_process_aliases = { + trait: f"ServerProcess.{trait}" + for trait in ServerProcess.class_traits(config=True) + if trait not in ignore_traits and trait not in self.flags + } + + self.aliases = { + **server_process_aliases, + "base_url": "StandaloneProxyServer.base_url", + "address": "StandaloneProxyServer.address", + "port": "StandaloneProxyServer.port", + "server_port": "StandaloneProxyServer.server_port", + "activity_interval": "StandaloneProxyServer.activity_interval", + "websocket_max_message_size": "StandaloneProxyServer.websocket_max_message_size", + } + + def _create_app(self) -> web.Application: + self.log.debug(f"Process will use port = {self.port}") + self.log.debug(f"Process will use unix_socket = {self.unix_socket}") + self.log.debug(f"Process environment: {self.environment}") + self.log.debug(f"Proxy mappath: {self.mappath}") + + settings = dict( + debug=self.log_level == logging.DEBUG, + base_url=self.base_url, + # Required for JupyterHub + hub_user=os.environ.get("JUPYTERHUB_USER", ""), + hub_group=os.environ.get("JUPYTERHUB_GROUP", ""), + cookie_secret=os.urandom(32), + ) + + if self.websocket_max_message_size: + self.log.debug( + f"Restricting WebSocket Messages to {self.websocket_max_message_size}" + ) + settings["websocket_max_message_size"] = self.websocket_max_message_size + + # Create the proxy class with out arguments + proxy_handler, proxy_kwargs = make_proxy( + self.command, + self.server_port, + self.unix_socket, + self.environment, + self.mappath, + self.timeout, + self.skip_authentication, + ) + + base_url = re.escape(self.base_url) + return web.Application( + [ + # Redirects from the JupyterHub might not contain a slash, so we add one here + (f"^{base_url}$", RedirectHandler, dict(url=f"{base_url}/")), + (f"^{base_url}/oauth_callback", HubOAuthCallbackHandler), + (f"^{base_url}/(.*)", proxy_handler, proxy_kwargs), + ], + **settings, + ) + + def _configure_ssl(self) -> dict | None: + # See https://github.com/jupyter-server/jupyter_server/blob/v2.0.0/jupyter_server/serverapp.py#L2053-L2073 + keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE", "") + certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE", "") + client_ca = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA", "") + + if not (keyfile or certfile or client_ca): + self.log.warn("Could not configure SSL") + return None + + ssl_options = {} + if keyfile: + ssl_options["keyfile"] = keyfile + if certfile: + ssl_options["certfile"] = certfile + if client_ca: + ssl_options["ca_certs"] = client_ca + + # PROTOCOL_TLS selects the highest ssl/tls protocol version that both the client and + # server support. When PROTOCOL_TLS is not available use PROTOCOL_SSLv23. + ssl_options["ssl_version"] = getattr(ssl, "PROTOCOL_TLS", ssl.PROTOCOL_SSLv23) + if ssl_options.get("ca_certs", False): + ssl_options["cert_reqs"] = ssl.CERT_REQUIRED + + # Configure HTTPClient to use SSL for Proxy Requests + ssl_context = make_ssl_context(keyfile, certfile, client_ca) + httpclient.AsyncHTTPClient.configure( + None, defaults={"ssl_options": ssl_context} + ) + + return ssl_options + + def start(self): + if self.skip_authentication: + self.log.warn("Disabling Authentication with JuypterHub Server!") + + app = self._create_app() + + ssl_options = self._configure_ssl() + http_server = httpserver.HTTPServer(app, ssl_options=ssl_options, xheaders=True) + http_server.listen(self.port, self.address) + + self.log.info(f"Starting standaloneproxy on '{self.address}:{self.port}'") + self.log.info(f"Base URL: {self.base_url!r}") + self.log.info(f"Command: {' '.join(self.command)!r}") + + # Periodically send JupyterHub Notifications, that we are still running + if self.activity_interval > 0: + self.log.info( + f"Sending Acitivity Notivication to JupyterHub with interval={self.activity_interval}s" + ) + start_activity_update(self.activity_interval) + + ioloop.IOLoop.current().start() diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 6dc7dea1..645d3941 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -1,17 +1,13 @@ from __future__ import annotations -import os -import re -import ssl from logging import Logger from jupyter_server.utils import ensure_async from jupyterhub import __version__ as __jh_version__ -from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated -from jupyterhub.utils import make_ssl_context -from tornado import httpclient, web +from jupyterhub.services.auth import HubOAuthenticated +from tornado import web from tornado.log import app_log -from tornado.web import Application, RedirectHandler, RequestHandler +from tornado.web import RequestHandler from tornado.websocket import WebSocketHandler from ..handlers import SuperviseAndProxyHandler @@ -80,55 +76,9 @@ def get_timeout(self): return self.timeout -def configure_ssl(): - # See https://github.com/jupyter-server/jupyter_server/blob/v2.0.0/jupyter_server/serverapp.py#L2053-L2073 - keyfile = os.environ.get("JUPYTERHUB_SSL_KEYFILE", "") - certfile = os.environ.get("JUPYTERHUB_SSL_CERTFILE", "") - client_ca = os.environ.get("JUPYTERHUB_SSL_CLIENT_CA", "") - - if not (keyfile or certfile or client_ca): - app_log.warn("Could not configure SSL") - return None - - ssl_options = {} - if keyfile: - ssl_options["keyfile"] = keyfile - if certfile: - ssl_options["certfile"] = certfile - if client_ca: - ssl_options["ca_certs"] = client_ca - - # PROTOCOL_TLS selects the highest ssl/tls protocol version that both the client and - # server support. When PROTOCOL_TLS is not available use PROTOCOL_SSLv23. - ssl_options["ssl_version"] = getattr(ssl, "PROTOCOL_TLS", ssl.PROTOCOL_SSLv23) - if ssl_options.get("ca_certs", False): - ssl_options["cert_reqs"] = ssl.CERT_REQUIRED - - # Configure HTTPClient to use SSL for Proxy Requests - ssl_context = make_ssl_context(keyfile, certfile, client_ca) - httpclient.AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context}) - - return ssl_options - - -def make_proxy_app( - command: list[str], - prefix: str, - port: int, - unix_socket: bool | str, - environment: dict[str, str], - mappath: dict[str, str], - timeout: int, - skip_authentication: bool, - debug: bool, - # progressive: bool, - websocket_max_message_size: int, -): - app_log.debug(f"Process will use {port = }") - app_log.debug(f"Process will use {unix_socket = }") - app_log.debug(f"Process environment: {environment}") - app_log.debug(f"Proxy mappath: {mappath}") - +def make_proxy( + command, port, unix_socket, environment, mappath, timeout, skip_authentication +) -> tuple[type, dict]: class Proxy(StandaloneHubProxyHandler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -142,35 +92,4 @@ def __init__(self, *args, **kwargs): self.timeout = timeout self.skip_authentication = skip_authentication - settings = dict( - debug=debug, - base_url=prefix, - # Required for JupyterHub - hub_user=os.environ.get("JUPYTERHUB_USER", ""), - hub_group=os.environ.get("JUPYTERHUB_GROUP", ""), - cookie_secret=os.urandom(32), - ) - - if websocket_max_message_size: - app_log.debug(f"Restricting WebSocket Messages to {websocket_max_message_size}") - settings["websocket_max_message_size"] = websocket_max_message_size - - escaped_prefix = re.escape(prefix) - app = Application( - [ - # Redirects from the JupyterHub might not contain a slash - (f"^{escaped_prefix}$", RedirectHandler, dict(url=f"{escaped_prefix}/")), - (f"^{escaped_prefix}/oauth_callback", HubOAuthCallbackHandler), - ( - f"^{escaped_prefix}/(.*)", - Proxy, - dict( - state={}, - # ToDo: progressive=progressive - ), - ), - ], - **settings, - ) - - return app + return Proxy, dict(state={}) From 7ed897455b1bd18c4745696300a4f9da5afbb480 Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Sat, 7 Dec 2024 22:39:48 +0100 Subject: [PATCH 19/21] Refactor and reuse Proxy generation in standalone --- jupyter_server_proxy/config.py | 143 +++++++++++++---------- jupyter_server_proxy/standalone/app.py | 34 +++--- jupyter_server_proxy/standalone/proxy.py | 140 ++++++++++------------ 3 files changed, 167 insertions(+), 150 deletions(-) diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 4b21cf70..b816938b 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -2,6 +2,8 @@ Traitlets based configuration for jupyter_server_proxy """ +from __future__ import annotations + import sys from textwrap import dedent, indent from warnings import warn @@ -263,60 +265,83 @@ def cats_only(response, path): """, ).tag(config=True) + def get_proxy_base_class(self) -> tuple[type | None, dict]: + """ + Return the appropriate ProxyHandler Subclass and its kwargs + """ + if self.command: + return ( + SuperviseAndRawSocketHandler + if self.raw_socket_proxy + else SuperviseAndProxyHandler + ), dict(state={}) + + if not (self.port or isinstance(self.unix_socket, str)): + warn( + f"""Server proxy {self.name} does not have a command, port number or unix_socket path. + At least one of these is required.""" + ) + return None, dict() + + return ( + RawSocketHandler if self.raw_socket_proxy else NamedLocalProxyHandler + ), dict() -def _make_proxy_handler(sp: ServerProcess): - """ - Create an appropriate handler with given parameters - """ - if sp.command: - cls = ( - SuperviseAndRawSocketHandler - if sp.raw_socket_proxy - else SuperviseAndProxyHandler - ) - args = dict(state={}) - elif not (sp.port or isinstance(sp.unix_socket, str)): - warn( - f"Server proxy {sp.name} does not have a command, port " - f"number or unix_socket path. At least one of these is " - f"required." - ) - return - else: - cls = RawSocketHandler if sp.raw_socket_proxy else NamedLocalProxyHandler - args = {} - - # FIXME: Set 'name' properly - class _Proxy(cls): - kwargs = args - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.name = sp.name - self.command = sp.command - self.proxy_base = sp.name - self.absolute_url = sp.absolute_url - if sp.command: - self.requested_port = sp.port - self.requested_unix_socket = sp.unix_socket - else: - self.port = sp.port - self.unix_socket = sp.unix_socket - self.mappath = sp.mappath - self.rewrite_response = sp.rewrite_response - self.update_last_activity = sp.update_last_activity - - def get_request_headers_override(self): - return self._realize_rendered_template(sp.request_headers_override) - - # these two methods are only used in supervise classes, but do no harm otherwise - def get_env(self): - return self._realize_rendered_template(sp.environment) - - def get_timeout(self): - return sp.timeout - - return _Proxy + def get_proxy_attributes(self) -> dict: + """ + Return the required attributes, which will be set on the proxy handler + """ + attributes = { + "name": self.name, + "command": self.command, + "proxy_base": self.name, + "absolute_url": self.absolute_url, + "mappath": self.mappath, + "rewrite_response": self.rewrite_response, + "update_last_activity": self.update_last_activity, + "request_headers_override": self.request_headers_override, + } + + if self.command: + attributes["requested_port"] = self.port + attributes["requested_unix_socket"] = self.unix_socket + attributes["environment"] = self.environment + attributes["timeout"] = self.timeout + else: + attributes["port"] = self.port + attributes["unix_socket"] = self.unix_socket + + return attributes + + def make_proxy_handler(self) -> tuple[type | None, dict]: + """ + Create an appropriate handler for this ServerProxy Configuration + """ + cls, proxy_kwargs = self.get_proxy_base_class() + if cls is None: + return None, proxy_kwargs + + # FIXME: Set 'name' properly + attributes = self.get_proxy_attributes() + + class _Proxy(cls): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for name, value in attributes.items(): + setattr(self, name, value) + + def get_request_headers_override(self): + return self._realize_rendered_template(self.request_headers_override) + + # these two methods are only used in supervise classes, but do no harm otherwise + def get_env(self): + return self._realize_rendered_template(self.environment) + + def get_timeout(self): + return self.timeout + + return _Proxy, proxy_kwargs def get_entrypoint_server_processes(serverproxy_config): @@ -332,21 +357,21 @@ def get_entrypoint_server_processes(serverproxy_config): return sps -def make_handlers(base_url, server_processes): +def make_handlers(base_url: str, server_processes: list[ServerProcess]): """ Get tornado handlers for registered server_processes """ handlers = [] - for sp in server_processes: - handler = _make_proxy_handler(sp) + for server in server_processes: + handler, kwargs = server.make_proxy_handler() if not handler: continue - handlers.append((ujoin(base_url, sp.name, r"(.*)"), handler, handler.kwargs)) - handlers.append((ujoin(base_url, sp.name), AddSlashHandler)) + handlers.append((ujoin(base_url, server.name, r"(.*)"), handler, kwargs)) + handlers.append((ujoin(base_url, server.name), AddSlashHandler)) return handlers -def make_server_process(name, server_process_config, serverproxy_config): +def make_server_process(name: str, server_process_config: dict, serverproxy_config): return ServerProcess(name=name, **server_process_config) diff --git a/jupyter_server_proxy/standalone/app.py b/jupyter_server_proxy/standalone/app.py index bae67252..8239ad94 100644 --- a/jupyter_server_proxy/standalone/app.py +++ b/jupyter_server_proxy/standalone/app.py @@ -16,7 +16,7 @@ from ..config import ServerProcess from .activity import start_activity_update -from .proxy import make_proxy +from .proxy import make_standalone_proxy class StandaloneProxyServer(TraitletsApplication, ServerProcess): @@ -128,8 +128,8 @@ def _default_command(self): # ToDo: Find a better way to do this return self.extra_args - def __init__(self): - super().__init__() + def __init__(self, **kwargs): + super().__init__(**kwargs) # Flags for CLI self.flags = { @@ -174,7 +174,21 @@ def __init__(self): "websocket_max_message_size": "StandaloneProxyServer.websocket_max_message_size", } - def _create_app(self) -> web.Application: + def get_proxy_base_class(self) -> tuple[type | None, dict]: + cls, kwargs = super().get_proxy_base_class() + if cls is None: + return None, kwargs + + return make_standalone_proxy(cls, kwargs) + + def get_proxy_attributes(self) -> dict: + attributes = super().get_proxy_attributes() + attributes["requested_port"] = self.server_port + attributes["skip_authentication"] = self.skip_authentication + + return attributes + + def create_app(self) -> web.Application: self.log.debug(f"Process will use port = {self.port}") self.log.debug(f"Process will use unix_socket = {self.unix_socket}") self.log.debug(f"Process environment: {self.environment}") @@ -196,15 +210,7 @@ def _create_app(self) -> web.Application: settings["websocket_max_message_size"] = self.websocket_max_message_size # Create the proxy class with out arguments - proxy_handler, proxy_kwargs = make_proxy( - self.command, - self.server_port, - self.unix_socket, - self.environment, - self.mappath, - self.timeout, - self.skip_authentication, - ) + proxy_handler, proxy_kwargs = self.make_proxy_handler() base_url = re.escape(self.base_url) return web.Application( @@ -253,7 +259,7 @@ def start(self): if self.skip_authentication: self.log.warn("Disabling Authentication with JuypterHub Server!") - app = self._create_app() + app = self.create_app() ssl_options = self._configure_ssl() http_server = httpserver.HTTPServer(app, ssl_options=ssl_options, xheaders=True) diff --git a/jupyter_server_proxy/standalone/proxy.py b/jupyter_server_proxy/standalone/proxy.py index 645d3941..35c30991 100644 --- a/jupyter_server_proxy/standalone/proxy.py +++ b/jupyter_server_proxy/standalone/proxy.py @@ -13,83 +13,69 @@ from ..handlers import SuperviseAndProxyHandler -class StandaloneHubProxyHandler(HubOAuthenticated, SuperviseAndProxyHandler): - """ - Base class for standalone proxies. - Will restrict access to the application by authentication with the JupyterHub API. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.environment = {} - self.timeout = 60 - self.skip_authentication = False - - @property - def log(self) -> Logger: - return app_log - - @property - def hub_users(self): - if "hub_user" in self.settings: - return {self.settings["hub_user"]} - return set() - - @property - def hub_groups(self): - if "hub_group" in self.settings: - return {self.settings["hub_group"]} - return set() - - def set_default_headers(self): - self.set_header("X-JupyterHub-Version", __jh_version__) - - def prepare(self, *args, **kwargs): - pass - - def check_origin(self, origin: str = None): - # Skip JupyterHandler.check_origin - return WebSocketHandler.check_origin(self, origin) - - def check_xsrf_cookie(self): - # Skip HubAuthenticated.check_xsrf_cookie - pass - - def write_error(self, status_code: int, **kwargs): - # ToDo: Return proper error page, like in jupyter-server/JupyterHub - return RequestHandler.write_error(self, status_code, **kwargs) - - async def proxy(self, port, path): - if self.skip_authentication: - return await super().proxy(port, path) - else: - return await ensure_async(self.oauth_proxy(port, path)) - - @web.authenticated - async def oauth_proxy(self, port, path): - return await super().proxy(port, path) - - def get_env(self): - return self._render_template(self.environment) - - def get_timeout(self): - return self.timeout +def make_standalone_proxy( + base_proxy_class: type, proxy_kwargs: dict +) -> tuple[type | None, dict]: + if not issubclass(base_proxy_class, SuperviseAndProxyHandler): + app_log.error( + "Cannot create a 'StandaloneHubProxyHandler' from a class not inheriting from 'SuperviseAndProxyHandler'" + ) + return None, dict() + + class StandaloneHubProxyHandler(HubOAuthenticated, base_proxy_class): + """ + Base class for standalone proxies. + Will restrict access to the application by authentication with the JupyterHub API. + """ - -def make_proxy( - command, port, unix_socket, environment, mappath, timeout, skip_authentication -) -> tuple[type, dict]: - class Proxy(StandaloneHubProxyHandler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.name = f"{command[0]!r} Process" - self.proxy_base = command[0] - self.requested_port = port - self.requested_unix_socket = unix_socket - self.mappath = mappath - self.command = command - self.environment = environment - self.timeout = timeout - self.skip_authentication = skip_authentication - - return Proxy, dict(state={}) + self.environment = {} + self.timeout = 60 + self.skip_authentication = False + + @property + def log(self) -> Logger: + return app_log + + @property + def hub_users(self): + if "hub_user" in self.settings: + return {self.settings["hub_user"]} + return set() + + @property + def hub_groups(self): + if "hub_group" in self.settings: + return {self.settings["hub_group"]} + return set() + + def set_default_headers(self): + self.set_header("X-JupyterHub-Version", __jh_version__) + + def prepare(self, *args, **kwargs): + pass + + def check_origin(self, origin: str = None): + # Skip JupyterHandler.check_origin + return WebSocketHandler.check_origin(self, origin) + + def check_xsrf_cookie(self): + # Skip HubAuthenticated.check_xsrf_cookie + pass + + def write_error(self, status_code: int, **kwargs): + # ToDo: Return proper error page, like in jupyter-server/JupyterHub + return RequestHandler.write_error(self, status_code, **kwargs) + + async def proxy(self, port, path): + if self.skip_authentication: + return await super().proxy(port, path) + else: + return await ensure_async(self.oauth_proxy(port, path)) + + @web.authenticated + async def oauth_proxy(self, port, path): + return await super().proxy(port, path) + + return StandaloneHubProxyHandler, proxy_kwargs From 1ff60517b0396c3f7cf9350899f8951b9311441b Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Sat, 7 Dec 2024 22:45:57 +0100 Subject: [PATCH 20/21] Update standalone tests --- tests/test_standalone.py | 101 +++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 51 deletions(-) diff --git a/tests/test_standalone.py b/tests/test_standalone.py index f630bb44..cc55548d 100644 --- a/tests/test_standalone.py +++ b/tests/test_standalone.py @@ -1,10 +1,11 @@ +import logging import sys from pathlib import Path import pytest from tornado import testing -from jupyter_server_proxy.standalone import _default_address_and_port, make_proxy_app +from jupyter_server_proxy.standalone import StandaloneProxyServer """ Test if address and port are identified correctly @@ -13,60 +14,62 @@ def test_address_and_port_with_http_address(monkeypatch): monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "http://localhost/") - address, port = _default_address_and_port() + proxy_server = StandaloneProxyServer() - assert address == "localhost" - assert port == 80 + assert proxy_server.address == "localhost" + assert proxy_server.port == 80 def test_address_and_port_with_https_address(monkeypatch): monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "https://localhost/") - address, port = _default_address_and_port() + proxy_server = StandaloneProxyServer() - assert address == "localhost" - assert port == 443 + assert proxy_server.address == "localhost" + assert proxy_server.port == 443 def test_address_and_port_with_address_and_port(monkeypatch): monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "http://localhost:7777/") - address, port = _default_address_and_port() - - assert address == "localhost" - assert port == 7777 - - -def make_app(unix_socket: bool, skip_authentication: bool): - command = [ - sys.executable, - str(Path(__file__).parent / "resources" / "httpinfo.py"), - "--port={port}", - "--unix-socket={unix_socket}", - ] - - return make_proxy_app( - command=command, - prefix="/some/prefix", - port=0, - unix_socket=unix_socket, - environment={}, - mappath={}, - timeout=60, - skip_authentication=skip_authentication, - debug=True, - websocket_max_message_size=0, - ) - - -class TestStandaloneProxyRedirect(testing.AsyncHTTPTestCase): + proxy_server = StandaloneProxyServer() + + assert proxy_server.address == "localhost" + assert proxy_server.port == 7777 + + +class _TestStandaloneBase(testing.AsyncHTTPTestCase): + runTest = None # Required for Tornado 6.1 + + unix_socket: bool + skip_authentication: bool + + def get_app(self): + command = [ + sys.executable, + str(Path(__file__).parent / "resources" / "httpinfo.py"), + "--port={port}", + "--unix-socket={unix_socket}", + ] + + proxy_server = StandaloneProxyServer( + command=command, + base_url="/some/prefix", + unix_socket=self.unix_socket, + timeout=60, + skip_authentication=self.skip_authentication, + log_level=logging.DEBUG, + ) + + return proxy_server.create_app() + + +class TestStandaloneProxyRedirect(_TestStandaloneBase): """ Ensure requests are proxied to the application. We need to disable authentication here, as we do not want to be redirected to the JupyterHub Login. """ - runTest = None # Required for Tornado 6.1 - - def get_app(self): - return make_app(False, True) + unix_socket = False + skip_authentication = True def test_add_slash(self): response = self.fetch("/some/prefix", follow_redirects=False) @@ -74,7 +77,7 @@ def test_add_slash(self): assert response.code == 301 assert response.headers.get("Location") == "/some/prefix/" - def test_without_prefix(self): + def test_wrong_prefix(self): response = self.fetch("/some/other/prefix") assert response.code == 404 @@ -92,11 +95,9 @@ def test_on_prefix(self): @pytest.mark.skipif( sys.platform == "win32", reason="Unix socket not supported on Windows" ) -class TestStandaloneProxyWithUnixSocket(testing.AsyncHTTPTestCase): - runTest = None # Required for Tornado 6.1 - - def get_app(self): - return make_app(True, True) +class TestStandaloneProxyWithUnixSocket(_TestStandaloneBase): + unix_socket = True + skip_authentication = True def test_with_unix_socket(self): response = self.fetch("/some/prefix/") @@ -108,15 +109,13 @@ def test_with_unix_socket(self): assert "X-Proxycontextpath: /some/prefix/" in body -class TestStandaloneProxyLogin(testing.AsyncHTTPTestCase): +class TestStandaloneProxyLogin(_TestStandaloneBase): """ Ensure we redirect to JupyterHub login when authentication is enabled """ - runTest = None # Required for Tornado 6.1 - - def get_app(self): - return make_app(False, False) + unix_socket = False + skip_authentication = False def test_redirect_to_login_url(self): response = self.fetch("/some/prefix/", follow_redirects=False) From e07a61533ea0e5d89dd0cfec9ee6717fbc9c0b1a Mon Sep 17 00:00:00 2001 From: Jonathan Windgassen Date: Tue, 7 Jan 2025 13:11:07 +0100 Subject: [PATCH 21/21] Allow configuration via traitlets --- docs/source/standalone.md | 23 +++++++++++++++++++++++ jupyter_server_proxy/standalone/app.py | 24 +++++++++++++++++------- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/source/standalone.md b/docs/source/standalone.md index a0a9158c..a0a8ed03 100644 --- a/docs/source/standalone.md +++ b/docs/source/standalone.md @@ -58,6 +58,29 @@ not trigger the login process when accessing the application. especially on multi-user systems. ``` +### Configuration via traitlets + +Instead of using the commandline, a standalone proxy can also be configured via a `traitlets` configuration file. +The configuration file can be loaded by running `jupyter standaloneproxy --config path/to/config.py`. + +The options mentioned above can also be configured in the config file: + +```python +# Specify the command to execute +c.StandaloneProxyServer.command = [ + "voila", "--no-browser", "--port={port}", "/path/to/some/Notebook.ipynb" +] + +# Specify address and port +c.StandaloneProxyServer.address = "localhost" +c.StandaloneProxyServer.port = 8000 + +# Disable authentication +c.StandaloneProxyServer.skip_authentication = True +``` + +A default config file can be emitted by running `jupyter standaloneproxy --generate-config` + ## Usage with JupyterHub To launch a standalone proxy with JupyterHub, you need to customize the `Spawner` inside the configuration diff --git a/jupyter_server_proxy/standalone/app.py b/jupyter_server_proxy/standalone/app.py index 8239ad94..ebdd2d40 100644 --- a/jupyter_server_proxy/standalone/app.py +++ b/jupyter_server_proxy/standalone/app.py @@ -7,11 +7,11 @@ from textwrap import dedent from urllib.parse import urlparse +from jupyter_core.application import JupyterApp from jupyterhub.services.auth import HubOAuthCallbackHandler from jupyterhub.utils import make_ssl_context from tornado import httpclient, httpserver, ioloop, web from tornado.web import RedirectHandler -from traitlets.config import Application as TraitletsApplication from traitlets.traitlets import Bool, Int, Unicode, default, validate from ..config import ServerProcess @@ -19,7 +19,7 @@ from .proxy import make_standalone_proxy -class StandaloneProxyServer(TraitletsApplication, ServerProcess): +class StandaloneProxyServer(JupyterApp, ServerProcess): name = "jupyter-standalone-proxy" description = """ Wrap an arbitrary web service so it can be used in place of 'jupyterhub-singleuser' @@ -27,12 +27,9 @@ class StandaloneProxyServer(TraitletsApplication, ServerProcess): Usage: jupyter standaloneproxy [options] -- - The will be executed to start the web service once the proxy receives the first request. The command can - contain the placeholders '{{port}}', '{{unix_socket}}' and '{{base_url}}', which will be replaced with the - appropriate values once the application starts. - For more details, see the jupyter-server-proxy documentation. """ + examples = "jupyter standaloneproxy -- voila --port={port} --no-browser /path/to/notebook.ipynb" base_url = Unicode( help=""" @@ -152,6 +149,7 @@ def __init__(self, **kwargs): # exeptions we do not need, for easier use of the CLI # We don't need "command" here, as we will take it from the extra_args ignore_traits = [ + "name", "launcher_entry", "new_browser_tab", "rewrite_response", @@ -159,12 +157,13 @@ def __init__(self, **kwargs): "command", ] server_process_aliases = { - trait: f"ServerProcess.{trait}" + trait: f"StandaloneProxyServer.{trait}" for trait in ServerProcess.class_traits(config=True) if trait not in ignore_traits and trait not in self.flags } self.aliases = { + **super().aliases, **server_process_aliases, "base_url": "StandaloneProxyServer.base_url", "address": "StandaloneProxyServer.address", @@ -174,6 +173,17 @@ def __init__(self, **kwargs): "websocket_max_message_size": "StandaloneProxyServer.websocket_max_message_size", } + def emit_alias_help(self): + yield from super().emit_alias_help() + yield "" + + # Manually yield the help for command, which we will get from extra_args + command_help = StandaloneProxyServer.class_get_trait_help( + ServerProcess.command + ).split("\n") + yield command_help[0].replace("--StandaloneProxyServer.command", "command") + yield from command_help[1:] + def get_proxy_base_class(self) -> tuple[type | None, dict]: cls, kwargs = super().get_proxy_base_class() if cls is None: