Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrating native-proxy #501

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d0be294
Merge jhsingle-native-proxy.
jwindgassen Sep 10, 2024
26059b0
Rename to `standalone`
jwindgassen Sep 11, 2024
fae833b
Remove `forward-user-info`, `query-user-info` and remains of `present…
jwindgassen Sep 12, 2024
36c8e17
Create StandaloneHubProxyHandler and timeout Argument. Fix authentica…
jwindgassen Sep 13, 2024
087c11d
Send Activity Notifications to JupyterHub
jwindgassen Sep 13, 2024
0d4e701
Fix Authentication with JupyterHub
jwindgassen Sep 18, 2024
fa8621f
Remove jupyter-server Authentication
jwindgassen Dec 6, 2024
5cae144
Set env & mappath via CLI, add Args for Unix Sockets
jwindgassen Dec 6, 2024
6b9336d
Merge `StandaloneProxyHandler` into `StandaloneHubProxyHandler`, extr…
jwindgassen Dec 6, 2024
51ee31c
Fix SSL Configuration, Add SlashHandler to Application
jwindgassen Oct 2, 2024
f547e5f
Add Slash to Prefix
jwindgassen Oct 7, 2024
2a83343
Fix error generation
jwindgassen Oct 11, 2024
325217b
Fixed ordering in handlers
jwindgassen Oct 15, 2024
7f85c9f
Defer xsrf checking to proxied app
jwindgassen Oct 18, 2024
87efc56
Add Documentation for standalone
jwindgassen Nov 12, 2024
7b33d45
Add Tests for the StandaloneProxy
jwindgassen Nov 15, 2024
941356f
Fix Typos and minor cleanups
jwindgassen Dec 3, 2024
0228e86
Switch from argparse to traitlets.Application and use config.ServerPr…
jwindgassen Dec 6, 2024
7ed8974
Refactor and reuse Proxy generation in standalone
jwindgassen Dec 7, 2024
1ff6051
Update standalone tests
jwindgassen Dec 7, 2024
e07a615
Allow configuration via traitlets
jwindgassen Jan 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Merge StandaloneProxyHandler into StandaloneHubProxyHandler, extr…
…act address from `JUPYTERHUB_SERVICE_URL`
jwindgassen committed Dec 6, 2024
commit 6b9336da90707bdbbd251251eed96ad1e633172f
73 changes: 51 additions & 22 deletions jupyter_server_proxy/standalone/__init__.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,53 @@
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]:
jwindgassen marked this conversation as resolved.
Show resolved Hide resolved
"""
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,
environment: list[tuple[str, str]] | None,
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,21 +76,25 @@ 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)

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 Acitivity Notivication to JupyterHub with interval={activity_interval}s"
jwindgassen marked this conversation as resolved.
Show resolved Hide resolved
)
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.",
jwindgassen marked this conversation as resolved.
Show resolved Hide resolved
)
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,17 +165,17 @@ def main():
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(
"--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()
jwindgassen marked this conversation as resolved.
Show resolved Hide resolved
log.debug(args)

run(**vars(args))


153 changes: 53 additions & 100 deletions jupyter_server_proxy/standalone/proxy.py
Original file line number Diff line number Diff line change
@@ -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