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}" diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py index eea2eafd..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, @@ -50,7 +51,7 @@ class Server(HasTraits): since it can be non-connectable value, such as '', meaning all interfaces. """ if self.ip in {'', '0.0.0.0', '::'}: - return self.url.replace(self._connect_ip, self.ip or '*', 1) + return self.url.replace(self._connect_ip, fmt_ip_url(self.ip) or '*', 1) return self.url @observe('bind_url') @@ -216,4 +217,4 @@ class Hub(Server): return url_path_join(self.url, 'api') def __repr__(self): - return f"<{self.__class__.__name__} {self.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 f01cdcf8..4d1596f8 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,7 +157,7 @@ class Server(Base): spawner = relationship("Spawner", back_populates="server", uselist=False) def __repr__(self): - return f"" + return f"" # lots of things have roles 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/spawner.py b/jupyterhub/spawner.py index 16d173b7..76220d62 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, @@ -469,20 +470,40 @@ 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. 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 ''. - 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) + @validate("ip") + def _strip_ipv6(self, proposal): + """ + 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 []. + + To avoid breaking existing workarounds strip []. + """ + 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 + port = Integer( 0, help=""" @@ -1093,7 +1114,7 @@ class Spawner(LoggingConfigurable): base_url = '/' proto = 'https' if self.internal_ssl else 'http' - bind_url = f"{proto}://{self.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 6ee7de66..54c2208c 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,17 +269,20 @@ 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' - app_log.debug("Waiting %ss for server at %s:%s", timeout, ip, port) + elif ip == "::": + ip = "::1" + 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( 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): @@ -962,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