Repository: jupyterhub/jupyterhub

Issue:
Any way to give a different hub IP to singleuser servers?
My current setup uses Jupyterhub 0.7.2 in a docker container, using a modified version of the batchspawner to start singleuser servers on a cluster. `JupyterHub.hub_ip` is set to `0.0.0.0`, but it binds to `127.0.0.1`.  This address is given to the singleuser server under the `--hub-api-url` argument. This isn't OK since the singleuser servers are running on different machines than the hub. I can't set the `hub_ip` to the public address because it is running in a docker container and can't bind to that address.

Is there any way from the config to tell the singleuser servers that it should connect to a different IP than the `hub_ip`? I'm currently doing a janky hack in the spawner to inject the correct address that obviously isn't the correct way to do it.


Discussion:
Setting `hub_ip` does set the bind ip. If you set this to 0.0.0.0, 127.0.0.1 is used for connecting unless overridden somewhere. Some Spawners (e.g. DockerSpawner) set a separate `hub_connect_ip`, but this should be handled at the Hub level so that different spawners don't need to reimplement this. I'll work on it.

Resolving patch:
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -392,11 +392,26 @@ def _deprecated_proxy_api(self, change):
         )
 
     hub_port = Integer(8081,
-        help="The port for this process"
+        help="The port for the Hub process"
     ).tag(config=True)
     hub_ip = Unicode('127.0.0.1',
-        help="The ip for this process"
+        help="""The ip address for the Hub process to *bind* to.
+
+        See `hub_connect_ip` for cases where the bind and connect address should differ.
+        """
     ).tag(config=True)
+    hub_connect_ip = Unicode('',
+        help="""The ip or hostname for proxies and spawners to use
+        for connecting to the Hub.
+
+        Use when the bind address (`hub_ip`) is 0.0.0.0 or otherwise different
+        from the connect address.
+
+        Default: when `hub_ip` is 0.0.0.0, use `socket.gethostname()`, otherwise use `hub_ip`.
+
+        .. versionadded:: 0.8
+        """
+    )
     hub_prefix = URLPrefix('/hub/',
         help="The prefix for the hub server.  Always /base_url/hub/"
     )
@@ -836,7 +851,8 @@ def init_hub(self):
             cookie_name='jupyter-hub-token',
             public_host=self.subdomain_host,
         )
-        print(self.hub)
+        if self.hub_connect_ip:
+            self.hub.connect_ip = self.hub_connect_ip
 
     @gen.coroutine
     def init_users(self):
diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py
--- a/jupyterhub/objects.py
+++ b/jupyterhub/objects.py
@@ -3,6 +3,7 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
+import socket
 from urllib.parse import urlparse
 
 from tornado import gen
@@ -26,11 +27,29 @@ class Server(HasTraits):
     orm_server = Instance(orm.Server, allow_none=True)
 
     ip = Unicode()
+    connect_ip = Unicode()
     proto = Unicode('http')
     port = Integer()
     base_url = Unicode('/')
     cookie_name = Unicode('')
 
+    @property
+    def _connect_ip(self):
+        """The address to use when connecting to this server
+
+        When `ip` is set to a real ip address, the same value is used.
+        When `ip` refers to 'all interfaces' (e.g. '0.0.0.0'),
+        clients connect via hostname by default.
+        Setting `connect_ip` explicitly overrides any default behavior.
+        """
+        if self.connect_ip:
+            return self.connect_ip
+        elif self.ip in {'', '0.0.0.0'}:
+            # if listening on all interfaces, default to hostname for connect
+            return socket.gethostname()
+        else:
+            return self.ip
+
     @classmethod
     def from_url(cls, url):
         """Create a Server from a given URL"""
@@ -67,13 +86,9 @@ def _change(self, change):
 
     @property
     def host(self):
-        ip = self.ip
-        if ip in {'', '0.0.0.0'}:
-            # when listening on all interfaces, connect to localhost
-            ip = '127.0.0.1'
         return "{proto}://{ip}:{port}".format(
             proto=self.proto,
-            ip=ip,
+            ip=self._connect_ip,
             port=self.port,
         )
 
@@ -92,7 +107,7 @@ def bind_url(self):
         since it can be non-connectable value, such as '', meaning all interfaces.
         """
         if self.ip in {'', '0.0.0.0'}:
-            return self.url.replace('127.0.0.1', self.ip or '*', 1)
+            return self.url.replace(self._connect_ip, self.ip or '*', 1)
         return self.url
 
     @gen.coroutine
@@ -101,7 +116,7 @@ def wait_up(self, timeout=10, http=False):
         if http:
             yield wait_for_http_server(self.url, timeout=timeout)
         else:
-            yield wait_for_server(self.ip or '127.0.0.1', self.port, timeout=timeout)
+            yield wait_for_server(self._connect_ip, self.port, timeout=timeout)
 
     def is_up(self):
         """Is the server accepting connections?"""


Test patch:
diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py
--- a/jupyterhub/tests/mocking.py
+++ b/jupyterhub/tests/mocking.py
@@ -246,8 +246,8 @@ class StubSingleUserSpawner(MockSpawner):
     _thread = None
     @gen.coroutine
     def start(self):
-        ip = self.user.server.ip
-        port = self.user.server.port = random_port()
+        ip = self.ip = '127.0.0.1'
+        port = self.port = random_port()
         env = self.get_env()
         args = self.get_args()
         evt = threading.Event()
diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py
--- a/jupyterhub/tests/test_orm.py
+++ b/jupyterhub/tests/test_orm.py
@@ -3,6 +3,8 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
+import socket
+
 import pytest
 from tornado import gen
 
@@ -24,13 +26,17 @@ def test_server(db):
 
     # test wrapper
     server = objects.Server(orm_server=server)
-    assert server.host == 'http://127.0.0.1:%i' % server.port
+    assert server.host == 'http://%s:%i' % (socket.gethostname(), server.port)
     assert server.url == server.host + '/'
     assert server.bind_url == 'http://*:%i/' % server.port
     server.ip = '127.0.0.1'
     assert server.host == 'http://127.0.0.1:%i' % server.port
     assert server.url == server.host + '/'
 
+    server.connect_ip = 'hub'
+    assert server.host == 'http://hub:%i' % server.port
+    assert server.url == server.host + '/'
+
 
 def test_user(db):
     user = User(orm.User(name='kaylee',