Repository: jupyterhub/jupyterhub

Issue:
Configuration problems with v0.9 (ECONNREFUSED)
I'm trying to update my below configuration to no use deprecated attributes. The below configuration works:
```
IPADDRESS = public_ips()[0]
c.JupyterHub.ip = IPADDRESS
c.JupyterHub.hub_ip = '0.0.0.0'
c.JupyterHub.hub_port = 8081
c.JupyterHub.hub_connect_ip = IPADDRESS
```
**Output:**
```
[I 2018-05-07 15:35:18.032 JupyterHub app:981] Loading cookie_secret from C:\JupyterHub\jupyterhub_cookie_secret
[I 2018-05-07 15:35:18.097 JupyterHub proxy:429] Generating new CONFIGPROXY_AUTH_TOKEN
[I 2018-05-07 15:35:18.186 JupyterHub app:1145] Not using whitelist. Any authenticated user will be allowed.
[I 2018-05-07 15:35:18.236 JupyterHub app:1742] Hub API listening on http://0.0.0.0:8081/hub/
[I 2018-05-07 15:35:18.240 JupyterHub app:1744] Private Hub API connect url http://IPADDRESS:8081/hub/
[W 2018-05-07 15:35:18.243 JupyterHub proxy:481] Running JupyterHub without SSL.  I hope there is SSL termination happening somewhere else...
[I 2018-05-07 15:35:18.243 JupyterHub proxy:483] Starting proxy @ http://IPADDRESS:8000/
15:35:18.566 - info: [ConfigProxy] Proxying http://IPADDRESS:8000 to (no default)
15:35:18.569 - info: [ConfigProxy] Proxy API at http://127.0.0.1:8001/api/routes
15:35:18.778 - info: [ConfigProxy] 200 GET /api/routes 
[I 2018-05-07 15:35:18.952 JupyterHub proxy:299] Checking routes
[I 2018-05-07 15:35:18.953 JupyterHub proxy:368] Adding default route for Hub: / => http://IPADDRESS:8081
15:35:18.957 - info: [ConfigProxy] Adding route / -> http://IPADDRESS:8081
15:35:18.959 - info: [ConfigProxy] 201 POST /api/routes/ 
[I 2018-05-07 15:35:19.749 JupyterHub app:1799] JupyterHub is now running at http://IPADDRESS:8000
[I 2018-05-07 15:35:31.038 JupyterHub log:158] 302 GET / -> /hub (@10.200.18.81) 2.00ms
[I 2018-05-07 15:35:31.266 JupyterHub log:158] 302 GET /hub -> /hub/login (@10.200.18.81) 1.00ms
[I 2018-05-07 15:35:32.331 JupyterHub log:158] 302 GET /hub/login -> /user/dhirschf/ (dhirschf@10.200.18.81) 92.00ms
[I 2018-05-07 15:35:32.335 JupyterHub log:158] 302 GET /user/dhirschf/ -> /hub/user/dhirschf/ (@10.200.18.81) 2.00ms
...server starts up fine.
```
----

The updated config below results in an `ECONNREFUSED` error:
```
IPADDRESS = public_ips()[0]
c.JupyterHub.bind_url = 'http://0.0.0.0:8000'
c.JupyterHub.hub_bind_url = 'http://0.0.0.0:8081'
c.JupyterHub.hub_connect_ip = IPADDRESS
```
**Output:**
```
[I 2018-05-07 15:26:22.318 JupyterHub app:981] Loading cookie_secret from C:\JupyterHub\jupyterhub_cookie_secret
[I 2018-05-07 15:26:22.377 JupyterHub proxy:429] Generating new CONFIGPROXY_AUTH_TOKEN
[I 2018-05-07 15:26:22.451 JupyterHub app:1145] Not using whitelist. Any authenticated user will be allowed.
[I 2018-05-07 15:26:22.494 JupyterHub app:1742] Hub API listening on http://0.0.0.0:8081
[I 2018-05-07 15:26:22.496 JupyterHub app:1744] Private Hub API connect url http://IPADDRESS:63171/hub/
[W 2018-05-07 15:26:22.499 JupyterHub proxy:481] Running JupyterHub without SSL.  I hope there is SSL termination happening somewhere else...
[I 2018-05-07 15:26:22.499 JupyterHub proxy:483] Starting proxy @ http://0.0.0.0:8000/
15:26:22.806 - info: [ConfigProxy] Proxying http://0.0.0.0:8000 to (no default)
15:26:22.809 - info: [ConfigProxy] Proxy API at http://127.0.0.1:8001/api/routes
15:26:28.532 - info: [ConfigProxy] 200 GET /api/routes 
15:26:28.728 - info: [ConfigProxy] Adding route / -> http://IPADDRESS:63171
[I 2018-05-07 15:26:28.724 JupyterHub proxy:299] Checking routes
[I 2018-05-07 15:26:28.725 JupyterHub proxy:368] Adding default route for Hub: / => http://IPADDRESS:63171
15:26:28.730 - info: [ConfigProxy] 201 POST /api/routes/ 
[I 2018-05-07 15:26:29.521 JupyterHub app:1799] JupyterHub is now running at http://0.0.0.0:8000
15:26:36.135 - error: [ConfigProxy] 503 GET / connect ECONNREFUSED IPADDRESS:63171
15:26:37.142 - error: [ConfigProxy] Failed to get custom error page Error: connect ECONNREFUSED IPADDRESS:63171
    at Object.exports._errnoException (util.js:1020:11)
    at exports._exceptionWithHostPort (util.js:1043:20)
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1105:14)
...Service Unavailable web page
```




Discussion:
I'll continue to try and get to the bottom of it, but if anything jumps out at anyone I'll gladly take any suggestions!
### Note:
> I'm running JupyterHub on win64/py36
I'm not sure where the 63171 port is coming from in the bad config:

**Good:**
```
[I 2018-05-07 15:35:18.952 JupyterHub proxy:299] Checking routes
[I 2018-05-07 15:35:18.953 JupyterHub proxy:368] Adding default route for Hub: / => http://IPADDRESS:8081
15:35:18.957 - info: [ConfigProxy] Adding route / -> http://IPADDRESS:8081
```

**Bad:**
```
15:26:28.728 - info: [ConfigProxy] Adding route / -> http://IPADDRESS:63171
[I 2018-05-07 15:26:28.724 JupyterHub proxy:299] Checking routes
[I 2018-05-07 15:26:28.725 JupyterHub proxy:368] Adding default route for Hub: / => http://IPADDRESS:63171
```

Resolving patch:
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -359,7 +359,6 @@ def _bind_url_changed(self, change):
     @default('base_url')
     def _default_base_url(self):
         # call validate to ensure leading/trailing slashes
-        print(self.bind_url)
         return JupyterHub.base_url.validate(self, urlparse(self.bind_url).path)
 
     subdomain_host = Unicode('',
@@ -451,16 +450,20 @@ def _deprecated_proxy_api(self, change):
         This is the internal port of the hub itself. It should never be accessed directly.
         See JupyterHub.port for the public port to use when accessing jupyterhub.
         It is rare that this port should be set except in cases of port conflict.
+
+        See also `hub_ip` for the ip and `hub_bind_url` for setting the full bind URL.
         """
     ).tag(config=True)
+
     hub_ip = Unicode('127.0.0.1',
         help="""The ip address for the Hub process to *bind* to.
 
-        By default, the hub listens on localhost only. This address must be accessible from 
-        the proxy and user servers. You may need to set this to a public ip or '' for all 
+        By default, the hub listens on localhost only. This address must be accessible from
+        the proxy and user servers. You may need to set this to a public ip or '' for all
         interfaces if the proxy or user servers are in containers or on a different host.
 
-        See `hub_connect_ip` for cases where the bind and connect address should differ.
+        See `hub_connect_ip` for cases where the bind and connect address should differ,
+        or `hub_bind_url` for setting the full bind URL.
         """
     ).tag(config=True)
 
@@ -492,10 +495,12 @@ def _deprecated_proxy_api(self, change):
         .. seealso::
             JupyterHub.hub_connect_ip
             JupyterHub.hub_bind_url
+
         .. versionadded:: 0.9
         """,
         config=True
     )
+
     hub_bind_url = Unicode(
         help="""
         The URL on which the Hub will listen.
@@ -1064,6 +1069,11 @@ def init_hub(self):
             public_host=self.subdomain_host,
         )
         if self.hub_bind_url:
+            # ensure hub_prefix is set on bind_url
+            self.hub_bind_url = urlunparse(
+                urlparse(self.hub_bind_url)
+                ._replace(path=self.hub_prefix)
+            )
             hub_args['bind_url'] = self.hub_bind_url
         else:
             hub_args['ip'] = self.hub_ip
@@ -1081,6 +1091,11 @@ def init_hub(self):
             )
 
         if self.hub_connect_url:
+            # ensure hub_prefix is on connect_url
+            self.hub_connect_url = urlunparse(
+                urlparse(self.hub_connect_url)
+                ._replace(path=self.hub_prefix)
+            )
             self.hub.connect_url = self.hub_connect_url
 
     async def init_users(self):
diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py
--- a/jupyterhub/objects.py
+++ b/jupyterhub/objects.py
@@ -4,12 +4,12 @@
 # Distributed under the terms of the Modified BSD License.
 
 import socket
-from urllib.parse import urlparse
+from urllib.parse import urlparse, urlunparse
 import warnings
 
 from traitlets import (
     HasTraits, Instance, Integer, Unicode,
-    default, observe,
+    default, observe, validate,
 )
 from .traitlets import URLPrefix
 from . import orm
@@ -47,6 +47,28 @@ def bind_url_default(self):
             return self.url.replace(self._connect_ip, self.ip or '*', 1)
         return self.url
 
+    @observe('bind_url')
+    def _bind_url_changed(self, change):
+        urlinfo = urlparse(change.new)
+        self.proto = urlinfo.scheme
+        self.ip = urlinfo.hostname or ''
+        port = urlinfo.port
+        if port is None:
+            if self.proto == 'https':
+                port = 443
+            else:
+                port = 80
+        self.port = port
+
+    @validate('connect_url')
+    def _connect_url_add_prefix(self, proposal):
+        """Ensure connect_url includes base_url"""
+        urlinfo = urlparse(proposal.value)
+        if not urlinfo.path.startswith(self.base_url):
+            urlinfo = urlinfo._replace(path=self.base_url)
+            return urlunparse(urlinfo)
+        return proposal.value
+
     @property
     def _connect_ip(self):
         """The address to use when connecting to this server
@@ -83,16 +105,7 @@ def from_orm(cls, orm_server):
     @classmethod
     def from_url(cls, url):
         """Create a Server from a given URL"""
-        urlinfo = urlparse(url)
-        proto = urlinfo.scheme
-        ip = urlinfo.hostname or ''
-        port = urlinfo.port
-        if not port:
-            if proto == 'https':
-                port = 443
-            else:
-                port = 80
-        return cls(proto=proto, ip=ip, port=port, base_url=urlinfo.path)
+        return cls(bind_url=url, base_url=urlparse(url).path)
 
     @default('port')
     def _default_port(self):


Test patch:
diff --git a/jupyterhub/tests/test_objects.py b/jupyterhub/tests/test_objects.py
new file mode 100644
--- /dev/null
+++ b/jupyterhub/tests/test_objects.py
@@ -0,0 +1,70 @@
+"""Tests for basic object-wrappers"""
+
+import socket
+import pytest
+
+from jupyterhub.objects import Server
+
+
+@pytest.mark.parametrize(
+    'bind_url, attrs',
+    [
+        (
+            'http://abc:123',
+            {
+                'ip': 'abc',
+                'port': 123,
+                'host': 'http://abc:123',
+                'url': 'http://abc:123/x/',
+            }
+        ),
+        (
+            'https://abc',
+            {
+                'ip': 'abc',
+                'port': 443,
+                'proto': 'https',
+                'host': 'https://abc:443',
+                'url': 'https://abc:443/x/',
+            }
+        ),
+    ]
+)
+def test_bind_url(bind_url, attrs):
+    s = Server(bind_url=bind_url, base_url='/x/')
+    for attr, value in attrs.items():
+        assert getattr(s, attr) == value
+
+
+_hostname = socket.gethostname()
+
+
+@pytest.mark.parametrize(
+    'ip, port, attrs',
+    [
+        (
+            '', 123,
+            {
+                'ip': '',
+                'port': 123,
+                'host': 'http://{}:123'.format(_hostname),
+                'url': 'http://{}:123/x/'.format(_hostname),
+                'bind_url': 'http://*:123/x/',
+            }
+        ),
+        (
+            '127.0.0.1', 999,
+            {
+                'ip': '127.0.0.1',
+                'port': 999,
+                'host': 'http://127.0.0.1:999',
+                'url': 'http://127.0.0.1:999/x/',
+                'bind_url': 'http://127.0.0.1:999/x/',
+            }
+        ),
+    ]
+)
+def test_ip_port(ip, port, attrs):
+    s = Server(ip=ip, port=port, base_url='/x/')
+    for attr, value in attrs.items():
+        assert getattr(s, attr) == value