Repository: jupyterhub/the-littlest-jupyterhub (python)

Issue:
Cannot bind traefik on a specific interface without breaking `tljh-config reload hub`
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Bug description

<!-- Use this section to clearly and concisely describe the bug. -->
We want to avoid exposing our JupyterHub server on the internet. To do so, we have a file `vpn_bind.toml` in the `traefik_config.d` directory :
```
[entryPoints]
  [entryPoints.http]
  address = "VPN_IP:80"
  [entryPoints.https]
  address = "VPN_IP:443"
```
So the server is not listening anymore on the public interface but also on the loopback interface.
This causes the `check_hub_ready` to break because `127.0.0.1` is [hardcoded there](https://github.com/jupyterhub/the-littlest-jupyterhub/blob/main/tljh/config.py#L249).

Changing the address of the HTTP entrypoint to 127.0.0.1 only is not enough because requests tries to connect to `127.0.0.1:443` even if the port is 80 in the request (this is a mystery for me why it wants to try 443) :

```
>>> requests.get('http://127.0.0.1:80/hub/api', verify=False)
Traceback (most recent call last):
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/connection.py", line 200, in _new_conn
    sock = connection.create_connection(
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/util/connection.py", line 85, in create_connection
    raise err
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/util/connection.py", line 73, in create_connection
    sock.connect(sa)
ConnectionRefusedError: [Errno 111] Connection refused

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/connectionpool.py", line 790, in urlopen
    response = self._make_request(
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/connectionpool.py", line 491, in _make_request
    raise new_e
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/connectionpool.py", line 467, in _make_request
    self._validate_conn(conn)
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/connectionpool.py", line 1092, in _validate_conn
    conn.connect()
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/connection.py", line 604, in connect
    self.sock = sock = self._new_conn()
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/connection.py", line 215, in _new_conn
    raise NewConnectionError(
urllib3.exceptions.NewConnectionError: <urllib3.connection.HTTPSConnection object at 0x7f90c6c72d90>: Failed to establish a new connection: [Errno 111] Connection refused

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/adapters.py", line 486, in send
    resp = conn.urlopen(
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/connectionpool.py", line 844, in urlopen
    retries = retries.increment(
  File "/opt/tljh/hub/lib/python3.8/site-packages/urllib3/util/retry.py", line 515, in increment
    raise MaxRetryError(_pool, url, reason) from reason  # type: ignore[arg-type]
urllib3.exceptions.MaxRetryError: HTTPSConnectionPool(host='127.0.0.1', port=443): Max retries exceeded with url: /hub/api (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x7f90c6c72d90>: Failed to establish a new connection: [Errno 111] Connection refused'))

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/api.py", line 73, in get
    return request("get", url, params=params, **kwargs)
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/api.py", line 59, in request
    return session.request(method=method, url=url, **kwargs)
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/sessions.py", line 587, in request
    resp = self.send(prep, **send_kwargs)
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/sessions.py", line 723, in send
    history = [resp for resp in gen]
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/sessions.py", line 723, in <listcomp>
    history = [resp for resp in gen]
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/sessions.py", line 266, in resolve_redirects
    resp = self.send(
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/sessions.py", line 701, in send
    r = adapter.send(request, **kwargs)
  File "/opt/tljh/hub/lib/python3.8/site-packages/requests/adapters.py", line 519, in send
    raise ConnectionError(e, request=request)
requests.exceptions.ConnectionError: HTTPSConnectionPool(host='127.0.0.1', port=443): Max retries exceeded with url: /hub/api (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x7f90c6c72d90>: Failed to establish a new connection: [Errno 111] Connection refused'))
```

The only way to make everything work is by not binding to a specific interface and listen to all interfaces, which is not desirable in our case.

#### Expected behaviour

Either the `tljh-config reload hub` command should detect the binding interface of Traefik or the config should allow to change the full check address instead of just the port.

#### Actual behaviour

The `tljh-config reload hub` command hangs because the `check_ready_hub` function is called in a loop.

### How to reproduce

<!-- Use this section to describe the steps that a user would take to experience this bug. -->

1. Create a file in `traefik_config.d` to override the `address` of the `http` and `https` entrypoint so the traefik server is no longer listening on the loopback interface.
2. `tljh-config reload proxy` works.
3. `netstat -tupln | grep 443` to check that the Traefik server is bound to your specific interface.
4. `tljh-config reload hub` will hang.

### Your personal set up

tljh==0.2.0 on ubuntu 20.04

<!--
Tell us a little about the system you're using.
Please include information about how you installed,
e.g. are you using a distribution such as zero-to-jupyterhub or the-littlest-jupyterhub.
 -->

 - OS:
 <!-- [e.g. ubuntu 20.04, macOS 11.0] -->
 - Version(s):
 <!-- e.g. jupyterhub --version, python --version --->

<details><summary>Full environment</summary>
<!-- For reproduction, it's useful to have the full environment. For example, the output of `pip freeze` or `conda list` --->

```
# paste output of `pip freeze` or `conda list` here
```
</details>

<details><summary>Configuration</summary>
<!--
For JupyterHub, especially include information such as what Spawner and Authenticator are being used.
Be careful not to share any sensitive information.
You can paste jupyterhub_config.py below.
To exclude lots of comments and empty lines from auto-generated jupyterhub_config.py, you can do:
    grep -v '\(^#\|^[[:space:]]*$\)' jupyterhub_config.py
-->

```python
# jupyterhub_config.py
```
</details>

<details><summary>Logs</summary>
<!--
Errors are often logged by jupytehub. How you get logs depends on your deployment.
With kubernetes it might be:

    kubectl get pod # hub pod name starts with hub...
    kubectl logs hub-...
    # or for a single-user server
    kubectl logs jupyter-username

Or the-littlest-jupyterhub:

    journalctl -u jupyterhub
    # or for a single-user server
    journalctl -u jupyter-username
-->

```
# paste relevant logs here, if any
```
</details>


Pull request description:
Add a Traefik configuration to listen on a specific address (#903)
This PR allows using `http.address` and `https.address` to set the addresses that TLJH will listen on,
which is an empty address by default (it means it listens on all interfaces by default, as it is the current behavior).

Tested and documented also!

Close #903 

Patch:
diff --git a/docs/topic/tljh-config.md b/docs/topic/tljh-config.md
index 561e4b9..2df93ee 100644
--- a/docs/topic/tljh-config.md
+++ b/docs/topic/tljh-config.md
@@ -85,6 +85,19 @@ sudo tljh-config set https.port 8443
 sudo tljh-config reload proxy
 ```
 
+(tljh-set-listen-address)
+
+### Listen address
+
+Use `http.address` and `https.address` to set the addresses that TLJH will listen on,
+which is an empty address by default (it means it listens on all interfaces by default).
+
+```bash
+sudo tljh-config set http.address 127.0.0.1
+sudo tljh-config set https.address 127.0.0.1
+sudo tljh-config reload proxy
+```
+
 (tljh-set-user-lists)=
 
 ### User Lists
diff --git a/tljh/config.py b/tljh/config.py
index 60d5cc6..d308e9e 100644
--- a/tljh/config.py
+++ b/tljh/config.py
@@ -244,10 +244,15 @@ def check_hub_ready():
 
     base_url = load_config()["base_url"]
     base_url = base_url[:-1] if base_url[-1] == "/" else base_url
+    http_address = load_config()["http"]["address"]
     http_port = load_config()["http"]["port"]
+    # The default config is an empty address, so it binds on all interfaces.
+    # Test the connectivity on the local address.
+    if http_address == "":
+        http_address = "127.0.0.1"
     try:
         r = requests.get(
-            "http://127.0.0.1:%d%s/hub/api" % (http_port, base_url), verify=False
+            "http://%s:%d%s/hub/api" % (http_address, http_port, base_url), verify=False
         )
         if r.status_code != 200:
             print(f"Hub not ready: (HTTP status {r.status_code})")
diff --git a/tljh/configurer.py b/tljh/configurer.py
index 8e49d75..1fb60f6 100644
--- a/tljh/configurer.py
+++ b/tljh/configurer.py
@@ -28,10 +28,12 @@ default = {
         "cpu": None,
     },
     "http": {
+        "address": "",
         "port": 80,
     },
     "https": {
         "enabled": False,
+        "address": "",
         "port": 443,
         "tls": {
             "cert": "",
diff --git a/tljh/requirements-hub-env.txt b/tljh/requirements-hub-env.txt
index dfeaeec..ba81767 100644
--- a/tljh/requirements-hub-env.txt
+++ b/tljh/requirements-hub-env.txt
@@ -11,12 +11,12 @@
 jupyterhub>=4.0.0,<5
 jupyterhub-systemdspawner>=0.17.0,<5
 jupyterhub-firstuseauthenticator>=1.0.0,<2
-jupyterhub-nativeauthenticator>=1.2.0,<2
+jupyterhub-nativeauthenticator>=1.1.0,<2
 jupyterhub-ldapauthenticator>=1.3.2,<2
-jupyterhub-tmpauthenticator>=1.0.0,<2
+jupyterhub-tmpauthenticator>=0.6.0,<0.7
 oauthenticator>=15.1.0,<16
 jupyterhub-idle-culler>=1.2.1,<2
-git+https://github.com/yuvipanda/jupyterhub-configurator@996405d2a7017153d5abe592b8028fed7a1801bb
+git+https://github.com/yuvipanda/jupyterhub-configurator@317759e17c8e48de1b1352b836dac2a230536dba
 
 # pycurl is installed to improve reliability and performance for when JupyterHub
 # makes web requests. JupyterHub will use tornado's CurlAsyncHTTPClient when
diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl
index fa5b6ef..5fc0034 100644
--- a/tljh/traefik.toml.tpl
+++ b/tljh/traefik.toml.tpl
@@ -22,7 +22,7 @@ X-Xsrftoken = "redact"
 
 [entryPoints]
   [entryPoints.http]
-  address = ":{{ http['port'] }}"
+  address = "{{ http['address'] }}:{{ http['port'] }}"
 
   [entryPoints.http.transport.respondingTimeouts]
   idleTimeout = "10m"
@@ -33,7 +33,7 @@ X-Xsrftoken = "redact"
   scheme = "https"
 
   [entryPoints.https]
-  address = ":{{ https['port'] }}"
+  address = "{{ https['address'] }}:{{ https['port'] }}"
 
   [entryPoints.https.http.tls]
   options = "default"


Test patch:
diff --git a/tests/test_traefik.py b/tests/test_traefik.py
index 4098586..f950266 100644
--- a/tests/test_traefik.py
+++ b/tests/test_traefik.py
@@ -240,3 +240,19 @@ def test_extra_config(tmpdir, tljh_dir):
     # Check that the defaults were updated by the extra config
     assert toml_cfg["log"]["level"] == "ERROR"
     assert toml_cfg["api"]["dashboard"] == True
+
+
+def test_listen_address(tmpdir, tljh_dir):
+    state_dir = config.STATE_DIR
+    config.set_config_value(config.CONFIG_FILE, "https.enabled", True)
+    config.set_config_value(config.CONFIG_FILE, "https.tls.key", "/path/to/ssl.key")
+    config.set_config_value(config.CONFIG_FILE, "https.tls.cert", "/path/to/ssl.cert")
+
+    config.set_config_value(config.CONFIG_FILE, "http.address", "127.0.0.1")
+    config.set_config_value(config.CONFIG_FILE, "https.address", "127.0.0.1")
+
+    traefik.ensure_traefik_config(str(state_dir))
+
+    cfg = _read_static_config(state_dir)
+    assert cfg["entryPoints"]["http"]["address"] == "127.0.0.1:80"
+    assert cfg["entryPoints"]["https"]["address"] == "127.0.0.1:443"