From f887a7b547848e9eb603d169aeb1b8c126da6c13 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 26 Jan 2025 15:40:02 +0000 Subject: [PATCH 01/10] JUPYTERHUB_SERVICE_URL: ipv6 must be wrapped in `[]` --- jupyterhub/spawner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 16d173b7..c2c0e7d8 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -1093,7 +1093,8 @@ class Spawner(LoggingConfigurable): base_url = '/' proto = 'https' if self.internal_ssl else 'http' - bind_url = f"{proto}://{self.ip}:{self.port}{base_url}" + ip = f"[{self.ip}]" if ":" in self.ip else self.ip + bind_url = f"{proto}://{ip}:{self.port}{base_url}" env["JUPYTERHUB_SERVICE_URL"] = bind_url # the public URLs of this server and the Hub From c7bb995f29e44212156b8c7c4cbdc871f4c005ec Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 26 Jan 2025 15:52:01 +0000 Subject: [PATCH 02/10] Server.bind_url_default: wrap ipv6 in `[]` --- jupyterhub/objects.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py index eea2eafd..5927a7d8 100644 --- a/jupyterhub/objects.py +++ b/jupyterhub/objects.py @@ -49,7 +49,10 @@ class Server(HasTraits): Never used in APIs, only logging, since it can be non-connectable value, such as '', meaning all interfaces. """ - if self.ip in {'', '0.0.0.0', '::'}: + ip = self.ip + if ":" in ip: + ip = f"[{ip}]" + if ip in {'', '0.0.0.0', '[::]'}: return self.url.replace(self._connect_ip, self.ip or '*', 1) return self.url From ec8335626124f60e622a26a0b2b59c445754432e Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 26 Jan 2025 16:22:15 +0000 Subject: [PATCH 03/10] Wrap ipv6 in `[]` when displaying/logging messages --- jupyterhub/objects.py | 3 ++- jupyterhub/orm.py | 3 ++- jupyterhub/utils.py | 7 ++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py index 5927a7d8..ed361f8d 100644 --- a/jupyterhub/objects.py +++ b/jupyterhub/objects.py @@ -219,4 +219,5 @@ class Hub(Server): return url_path_join(self.url, 'api') def __repr__(self): - return f"<{self.__class__.__name__} {self.ip}:{self.port}>" + ip = f"[{self.ip}]" if ":" in self.ip else self.ip + return f"<{self.__class__.__name__} {ip}:{self.port}>" diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index a08b6967..698f5219 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -157,7 +157,8 @@ class Server(Base): spawner = relationship("Spawner", back_populates="server", uselist=False) def __repr__(self): - return f"" + ip = f"[{self.ip}]" if ":" in self.ip else self.ip + return f"" # lots of things have roles diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 6ee7de66..fc35ff4f 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -269,15 +269,16 @@ async def wait_for_server(ip, port, timeout=10): """Wait for any server to show up at ip:port.""" if ip in {'', '0.0.0.0', '::'}: ip = '127.0.0.1' - app_log.debug("Waiting %ss for server at %s:%s", timeout, ip, port) + display_ip = f"[{ip}]" if ":" in ip else ip + app_log.debug("Waiting %ss for server at %s:%s", timeout, display_ip, port) tic = time.perf_counter() await exponential_backoff( lambda: can_connect(ip, port), - f"Server at {ip}:{port} didn't respond in {timeout} seconds", + f"Server at {display_ip}:{port} didn't respond in {timeout} seconds", timeout=timeout, ) toc = time.perf_counter() - app_log.debug("Server at %s:%s responded in %.2fs", ip, port, toc - tic) + app_log.debug("Server at %s:%s responded in %.2fs", display_ip, port, toc - tic) async def wait_for_http_server(url, timeout=10, ssl_context=None): From 79af8ea26440afa5dd669938fa51764dab868c51 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 26 Jan 2025 16:23:35 +0000 Subject: [PATCH 04/10] Use `::1` for localhost if hub IP is `::` --- jupyterhub/services/service.py | 5 ++++- jupyterhub/utils.py | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py index d3c75e1e..0924eb6d 100644 --- a/jupyterhub/services/service.py +++ b/jupyterhub/services/service.py @@ -436,7 +436,10 @@ class Service(LoggingConfigurable): # since they are always local subprocesses hub = copy.deepcopy(self.hub) hub.connect_url = '' - hub.connect_ip = '127.0.0.1' + if self.hub.ip and ":" in self.hub.ip: + hub.connect_ip = "::1" + else: + hub.connect_ip = "127.0.0.1" self.spawner = _ServiceSpawner( cmd=self.command, diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index fc35ff4f..e7b31a30 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -108,8 +108,10 @@ def can_connect(ip, port): Return True if we can connect, False otherwise. """ - if ip in {'', '0.0.0.0', '::'}: + if ip in {'', '0.0.0.0'}: ip = '127.0.0.1' + elif ip == "::": + ip = "::1" try: socket.create_connection((ip, port)).close() except OSError as e: @@ -267,8 +269,10 @@ async def exponential_backoff( async def wait_for_server(ip, port, timeout=10): """Wait for any server to show up at ip:port.""" - if ip in {'', '0.0.0.0', '::'}: + if ip in {'', '0.0.0.0'}: ip = '127.0.0.1' + elif ip == "::": + ip = "::1" display_ip = f"[{ip}]" if ":" in ip else ip app_log.debug("Waiting %ss for server at %s:%s", timeout, display_ip, port) tic = time.perf_counter() From 948e112bde2856c4f020c953cd1d6edee17e3b4f Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 26 Jan 2025 15:40:02 +0000 Subject: [PATCH 05/10] Spawner.ip: IPv6 should not be wrapped with `[]` JUPYTERHUB_SERVICE_URL: ipv6 must be wrapped in `[]` --- jupyterhub/spawner.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index c2c0e7d8..6628adf9 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -483,6 +483,20 @@ class Spawner(LoggingConfigurable): """, ).tag(config=True) + @validate("ip") + def _strip_ipv6(self, proposal): + """ + Currently (JupyterHub 5.2.1) it's necessary to use [] when specifying an + [ipv6] due to the IP being concatenated with the port when forming URLs + without []. + + To avoid breaking existing workarounds strip []. + """ + v = proposal["value"] + if v.startswith("[") and v.endswith("]"): + v = v[1:-1] + return v + port = Integer( 0, help=""" From 5b02d9c222dc520296453e83796df16fc6e8bcce Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 30 Jan 2025 18:44:50 +0000 Subject: [PATCH 06/10] Add method to handle formatting of IPv6 in URLs --- jupyterhub/objects.py | 11 ++++------- jupyterhub/orm.py | 5 ++--- jupyterhub/spawner.py | 4 ++-- jupyterhub/utils.py | 12 +++++++++++- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py index ed361f8d..588eac98 100644 --- a/jupyterhub/objects.py +++ b/jupyterhub/objects.py @@ -12,6 +12,7 @@ from . import orm from .traitlets import URLPrefix from .utils import ( can_connect, + fmt_ip_url, make_ssl_context, random_port, url_path_join, @@ -49,11 +50,8 @@ class Server(HasTraits): Never used in APIs, only logging, since it can be non-connectable value, such as '', meaning all interfaces. """ - ip = self.ip - if ":" in ip: - ip = f"[{ip}]" - if ip in {'', '0.0.0.0', '[::]'}: - return self.url.replace(self._connect_ip, self.ip or '*', 1) + if self.ip in {'', '0.0.0.0', '::'}: + return self.url.replace(self._connect_ip, fmt_ip_url(self.ip) or '*', 1) return self.url @observe('bind_url') @@ -219,5 +217,4 @@ class Hub(Server): return url_path_join(self.url, 'api') def __repr__(self): - ip = f"[{self.ip}]" if ":" in self.ip else self.ip - return f"<{self.__class__.__name__} {ip}:{self.port}>" + return f"<{self.__class__.__name__} {fmt_ip_url(self.ip)}:{self.port}>" diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 698f5219..0d344e26 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -46,7 +46,7 @@ from sqlalchemy.pool import StaticPool from sqlalchemy.types import LargeBinary, Text, TypeDecorator from tornado.log import app_log -from .utils import compare_token, hash_token, new_token, random_port, utcnow +from .utils import compare_token, fmt_ip_url, hash_token, new_token, random_port, utcnow # top-level variable for easier mocking in tests utcnow = partial(utcnow, with_tz=False) @@ -157,8 +157,7 @@ class Server(Base): spawner = relationship("Spawner", back_populates="server", uselist=False) def __repr__(self): - ip = f"[{self.ip}]" if ":" in self.ip else self.ip - return f"" + return f"" # lots of things have roles diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 6628adf9..9011d708 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -48,6 +48,7 @@ from .traitlets import ByteSpecification, Callable, Command from .utils import ( AnyTimeoutError, exponential_backoff, + fmt_ip_url, maybe_future, random_port, recursive_update, @@ -1107,8 +1108,7 @@ class Spawner(LoggingConfigurable): base_url = '/' proto = 'https' if self.internal_ssl else 'http' - ip = f"[{self.ip}]" if ":" in self.ip else self.ip - bind_url = f"{proto}://{ip}:{self.port}{base_url}" + bind_url = f"{proto}://{fmt_ip_url(self.ip)}:{self.port}{base_url}" env["JUPYTERHUB_SERVICE_URL"] = bind_url # the public URLs of this server and the Hub diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index e7b31a30..54c2208c 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -273,7 +273,7 @@ async def wait_for_server(ip, port, timeout=10): ip = '127.0.0.1' elif ip == "::": ip = "::1" - display_ip = f"[{ip}]" if ":" in ip else ip + display_ip = fmt_ip_url(ip) app_log.debug("Waiting %ss for server at %s:%s", timeout, display_ip, port) tic = time.perf_counter() await exponential_backoff( @@ -967,3 +967,13 @@ def recursive_update(target, new): else: target[k] = v + + +def fmt_ip_url(ip): + """ + Format an IP for use in URLs. IPv6 is wrapped with [], everything else is + unchanged + """ + if ":" in ip: + return f"[{ip}]" + return ip From 0b4c181bf74c15056e418dbc4a3431e052c5c576 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 30 Jan 2025 22:20:27 +0000 Subject: [PATCH 07/10] Add IP:0:0:0:0:0:0:0:1 for internal ssl --- jupyterhub/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index f867a19c..6ee96fe2 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1922,7 +1922,11 @@ class JupyterHub(Application): self.internal_ssl_components_trust ) - default_alt_names = ["IP:127.0.0.1", "DNS:localhost"] + default_alt_names = [ + "IP:127.0.0.1", + "IP:0:0:0:0:0:0:0:1", + "DNS:localhost", + ] if self.subdomain_host: default_alt_names.append( f"DNS:{urlparse(self.subdomain_host).hostname}" From 4fbc7371524fc2ea4655b1b9dec881029b02a1c6 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 30 Jan 2025 22:26:39 +0000 Subject: [PATCH 08/10] Update Spawner.ip doc --- jupyterhub/spawner.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 9011d708..c8991d16 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -470,6 +470,10 @@ class Spawner(LoggingConfigurable): The IP address (or hostname) the single-user server should listen on. Usually either '127.0.0.1' (default) or '0.0.0.0'. + On IPv6 only networks use '::1' or '::'. + + If the spawned singleuser server is running JupyterHub 5.3.0 later + You can set this to the empty string '' to indicate both IPv4 and IPv6. The JupyterHub proxy implementation should be able to send packets to this interface. @@ -477,10 +481,7 @@ class Spawner(LoggingConfigurable): should override the default to '0.0.0.0'. .. versionchanged:: 2.0 - Default changed to '127.0.0.1', from ''. - In most cases, this does not result in a change in behavior, - as '' was interpreted as 'unspecified', - which used the subprocesses' own default, itself usually '127.0.0.1'. + Default changed to '127.0.0.1', from unspecified. """, ).tag(config=True) From b486f9465cab4a11b772a8d603e2697d1ae608df Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 27 Mar 2025 22:33:56 +0000 Subject: [PATCH 09/10] Add versionchanged for Spawner.ip --- jupyterhub/spawner.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index c8991d16..a911850f 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -480,6 +480,10 @@ class Spawner(LoggingConfigurable): Subclasses which launch remotely or in containers should override the default to '0.0.0.0'. + .. versionchanged:: 5.3 + An empty string '' means all interfaces (IPv4 and IPv6). Prior to this + the behaviour of '' was not defined. + .. versionchanged:: 2.0 Default changed to '127.0.0.1', from unspecified. """, From 5fbf7870666c1e5f85f1fbd26cbeace4d156d562 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 27 Mar 2025 22:45:06 +0000 Subject: [PATCH 10/10] Warn if Spawner.ip includes `[]` --- jupyterhub/spawner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index a911850f..76220d62 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -492,7 +492,7 @@ class Spawner(LoggingConfigurable): @validate("ip") def _strip_ipv6(self, proposal): """ - Currently (JupyterHub 5.2.1) it's necessary to use [] when specifying an + Prior to 5.3.0 it was necessary to use [] when specifying an [ipv6] due to the IP being concatenated with the port when forming URLs without []. @@ -500,6 +500,7 @@ class Spawner(LoggingConfigurable): """ v = proposal["value"] if v.startswith("[") and v.endswith("]"): + self.log.warning("Removing '[' ']' from Spawner.ip %s", self.ip) v = v[1:-1] return v