From eee29a095704a349e0a0b7edb847dcb0456045c9 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 22 Jan 2019 16:24:38 +0200 Subject: [PATCH 01/13] Replace chp with traefik-proxy --- .circleci/integration-test.py | 2 +- bootstrap/bootstrap.py | 8 ++++ integration-tests/test_hub.py | 17 +------ tests/test_config.py | 11 +++-- tests/test_traefik.py | 29 ++++++++---- tljh/config.py | 10 +++-- tljh/installer.py | 18 +------- tljh/jupyterhub_config.py | 9 ++-- .../configurable-http-proxy.service | 27 ----------- tljh/systemd-units/jupyterhub.service | 7 ++- tljh/systemd-units/traefik.service | 2 +- tljh/systemd.py | 45 +++++++++++++++++++ tljh/traefik.py | 7 ++- tljh/traefik.toml.tpl | 24 +++++----- 14 files changed, 119 insertions(+), 97 deletions(-) delete mode 100644 tljh/systemd-units/configurable-http-proxy.service diff --git a/.circleci/integration-test.py b/.circleci/integration-test.py index 731a78a..18dbeeb 100755 --- a/.circleci/integration-test.py +++ b/.circleci/integration-test.py @@ -108,7 +108,7 @@ def show_logs(container_name): ) run_container_command( container_name, - 'systemctl --no-pager status jupyterhub configurable-http-proxy' + 'systemctl --no-pager status jupyterhub traefik' ) def main(): diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 3a42fb2..e448438 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -86,12 +86,20 @@ def main(): 'git+https://github.com/jupyterhub/the-littlest-jupyterhub.git' ) + traefik_proxy_repo_path = 'git+https://github.com/jupyterhub/traefik-proxy.git' + subprocess.check_output([ os.path.join(hub_prefix, 'bin', 'pip'), 'install' ] + pip_flags + [tljh_repo_path], stderr=subprocess.STDOUT) logger.info('Setup tljh package') + subprocess.check_output([ + os.path.join(hub_prefix, 'bin', 'pip'), + 'install' + ] + [traefik_proxy_repo_path], stderr=subprocess.STDOUT) + logger.info('Setup traefik-proxy package') + logger.info('Starting TLJH installer...') os.execv( os.path.join(hub_prefix, 'bin', 'python3'), diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 6ee8661..c598667 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -34,10 +34,6 @@ async def test_user_code_execute(): assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() - # FIXME: wait for reload to finish & hub to come up - # Should be part of tljh-config reload - await asyncio.sleep(1) - async with User(username, hub_url, partial(login_dummy, password='')) as u: await u.login() await u.ensure_server() @@ -62,9 +58,6 @@ async def test_user_admin_add(): assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.admin', username)).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() - # FIXME: wait for reload to finish & hub to come up - # Should be part of tljh-config reload - await asyncio.sleep(1) async with User(username, hub_url, partial(login_dummy, password='')) as u: await u.login() await u.ensure_server() @@ -94,9 +87,6 @@ async def test_user_admin_remove(): assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.admin', username)).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() - # FIXME: wait for reload to finish & hub to come up - # Should be part of tljh-config reload - await asyncio.sleep(1) async with User(username, hub_url, partial(login_dummy, password='')) as u: await u.login() await u.ensure_server() @@ -107,16 +97,14 @@ async def test_user_admin_remove(): # Assert that the user has admin rights assert f'jupyter-{username}' in grp.getgrnam('jupyterhub-admins').gr_mem - assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'remove-item', 'users.admin', username)).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() - await asyncio.sleep(1) await u.stop_server() await u.ensure_server() # Assert that the user does *not* have admin rights - assert f'jupyter-{username}' in grp.getgrnam('jupyterhub-admins').gr_mem + assert f'jupyter-{username}' not in grp.getgrnam('jupyterhub-admins').gr_mem @pytest.mark.asyncio @@ -132,9 +120,6 @@ async def test_long_username(): assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() - # FIXME: wait for reload to finish & hub to come up - # Should be part of tljh-config reload - await asyncio.sleep(1) try: async with User(username, hub_url, partial(login_dummy, password='')) as u: await u.login() diff --git a/tests/test_config.py b/tests/test_config.py index f84582a..7b3dda8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -75,6 +75,7 @@ def test_add_to_config_zero_level(): 'a': ['b'] } + def test_add_to_config_multiple(): conf = {} @@ -116,15 +117,17 @@ def test_remove_from_config_error(): def test_reload_hub(): - with mock.patch('tljh.systemd.restart_service') as restart_service: + with mock.patch('tljh.systemd.restart_service') as restart_service, mock.patch( + 'tljh.systemd.check_service_active' + ) as check_active, mock.patch('tljh.systemd.check_hub_ready') as check_ready: config.reload_component('hub') assert restart_service.called_with('jupyterhub') + assert check_active.called_with('jupyterhub') def test_reload_proxy(tljh_dir): with mock.patch('tljh.systemd.restart_service') as restart_service: config.reload_component('proxy') - assert restart_service.called_with('configurable-http-proxy') assert restart_service.called_with('traefik') assert os.path.exists(os.path.join(config.STATE_DIR, 'traefik.toml')) @@ -140,8 +143,8 @@ def test_cli_no_command(capsys): "arg, value", [ ("true", True), - ("FALSE", False), - ], + ("FALSE", False) + ] ) def test_cli_set_bool(tljh_dir, arg, value): config.main(["set", "https.enabled", arg]) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 0d811ec..302b9e6 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -27,12 +27,14 @@ def test_default_config(tmpdir, tljh_dir): print(toml_cfg) cfg = toml.loads(toml_cfg) assert cfg["defaultEntryPoints"] == ["http"] - assert cfg["entryPoints"] == {"http": {"address": ":80"}} - assert cfg["frontends"] == { - "jupyterhub": {"backend": "jupyterhub", "passHostHeader": True} - } - assert cfg["backends"] == { - "jupyterhub": {"servers": {"chp": {"url": "http://127.0.0.1:15003"}}} + assert cfg["entryPoints"] == { + "http": {"address": ":80"}, + "auth_api": { + "address": ":8099", + "auth": { + "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + }, + }, } @@ -57,7 +59,13 @@ def test_letsencrypt_config(tljh_dir): assert "acme" in cfg assert cfg["entryPoints"] == { "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, - "https": {"address": ":443", "backend": "jupyterhub", "tls": {}}, + "https": {"address": ":443", "tls": {}}, + "auth_api": { + "address": ":8099", + "auth": { + "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + }, + }, } assert cfg["acme"] == { "email": "fake@jupyter.org", @@ -87,11 +95,16 @@ def test_manual_ssl_config(tljh_dir): "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, "https": { "address": ":443", - "backend": "jupyterhub", "tls": { "certificates": [ {"certFile": "/path/to/ssl.cert", "keyFile": "/path/to/ssl.key"} ] }, }, + "auth_api": { + "address": ":8099", + "auth": { + "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + }, + }, } diff --git a/tljh/config.py b/tljh/config.py index 9c862d5..c322421 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -18,6 +18,7 @@ from copy import deepcopy import os import re import sys +import asyncio from .yaml import yaml @@ -85,7 +86,7 @@ def add_item_to_config(config, property_path, value): def remove_item_from_config(config, property_path, value): """ - Add an item to a list in config. + Remove an item from a list in config. """ path_components = property_path.split('.') @@ -183,11 +184,14 @@ def reload_component(component): from tljh import systemd, traefik if component == 'hub': systemd.restart_service('jupyterhub') - # FIXME: Verify hub is back up? + # Ensure hub is back up + while not systemd.check_service_active('jupyterhub'): + asyncio.sleep(1) + while not systemd.check_hub_ready(): + asyncio.sleep(1) print('Hub reload with new configuration complete') elif component == 'proxy': traefik.ensure_traefik_config(STATE_DIR) - systemd.restart_service('configurable-http-proxy') systemd.restart_service('traefik') print('Proxy reload with new configuration complete') diff --git a/tljh/installer.py b/tljh/installer.py index 10c5796..2b35a56 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -120,9 +120,6 @@ def ensure_jupyterhub_service(prefix): with open(os.path.join(HERE, 'systemd-units', 'jupyterhub.service')) as f: hub_unit_template = f.read() - with open(os.path.join(HERE, 'systemd-units', 'configurable-http-proxy.service')) as f: - proxy_unit_template = f.read() - with open(os.path.join(HERE, 'systemd-units', 'traefik.service')) as f: traefik_unit_template = f.read() @@ -133,28 +130,16 @@ def ensure_jupyterhub_service(prefix): jupyterhub_config_path=os.path.join(HERE, 'jupyterhub_config.py'), install_prefix=INSTALL_PREFIX, ) - systemd.install_unit('configurable-http-proxy.service', proxy_unit_template.format(**unit_params)) systemd.install_unit('jupyterhub.service', hub_unit_template.format(**unit_params)) systemd.install_unit('traefik.service', traefik_unit_template.format(**unit_params)) systemd.reload_daemon() - # Set up proxy / hub secret oken if it is not already setup - proxy_secret_path = os.path.join(STATE_DIR, 'configurable-http-proxy.secret') - if not os.path.exists(proxy_secret_path): - with open(proxy_secret_path, 'w') as f: - f.write('CONFIGPROXY_AUTH_TOKEN=' + secrets.token_hex(32)) - # If we are changing CONFIGPROXY_AUTH_TOKEN, restart configurable-http-proxy! - systemd.restart_service('configurable-http-proxy') - - # Start CHP if it has already not been started - systemd.start_service('configurable-http-proxy') # If JupyterHub is running, we want to restart it. systemd.restart_service('jupyterhub') systemd.restart_service('traefik') # Mark JupyterHub & CHP to start at boot time systemd.enable_service('jupyterhub') - systemd.enable_service('configurable-http-proxy') systemd.enable_service('traefik') @@ -275,7 +260,7 @@ def ensure_admins(admins): yaml.dump(config, f) -def ensure_jupyterhub_running(times=4): +def ensure_jupyterhub_running(times=20): """ Ensure that JupyterHub is up and running @@ -432,7 +417,6 @@ def main(): logger.info("Setting up JupyterHub...") ensure_node() ensure_jupyterhub_package(HUB_ENV_PREFIX) - ensure_chp_package(HUB_ENV_PREFIX) ensure_jupyterlab_extensions() ensure_jupyterhub_service(HUB_ENV_PREFIX) ensure_jupyterhub_running() diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index 5012fcc..da1999d 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -10,7 +10,7 @@ from tljh import configurer, user from tljh.config import INSTALL_PREFIX, USER_ENV_PREFIX, CONFIG_DIR from tljh.normalize import generate_system_username from tljh.yaml import yaml - +from jupyterhub_traefik_proxy import TraefikTomlProxy class UserCreatingSpawner(SystemdSpawner): """ @@ -43,8 +43,11 @@ c.JupyterHub.cleanup_servers = False # Use a high port so users can try this on machines with a JupyterHub already present c.JupyterHub.hub_port = 15001 -c.ConfigurableHTTPProxy.should_start = False -c.ConfigurableHTTPProxy.api_url = 'http://127.0.0.1:15002' +c.TraefikTomlProxy.should_start = False +c.TraefikTomlProxy.traefik_api_password = "admin" +c.TraefikTomlProxy.traefik_api_username = "api_admin" +c.TraefikTomlProxy.toml_dynamic_config_file = "/opt/tljh/state/rules.toml" +c.JupyterHub.proxy_class = TraefikTomlProxy c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, 'bin')] c.SystemdSpawner.default_shell = '/bin/bash' diff --git a/tljh/systemd-units/configurable-http-proxy.service b/tljh/systemd-units/configurable-http-proxy.service deleted file mode 100644 index d782380..0000000 --- a/tljh/systemd-units/configurable-http-proxy.service +++ /dev/null @@ -1,27 +0,0 @@ -# Template file for Configurable HTTP Proxy systemd service -# Uses simple string.format() for 'templating' -[Unit] -# Wait for network stack to be fully up before starting CHP -After=network.target - -[Service] -User=nobody -Restart=always -# chp process should have no write access anywhere on disk -ProtectHome=tmpfs -ProtectSystem=strict -PrivateTmp=yes -PrivateDevices=yes -ProtectKernelTunables=yes -ProtectKernelModules=yes -EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret -ExecStart={install_prefix}/hub/node_modules/.bin/configurable-http-proxy \ - --ip 127.0.0.1 \ - --port 15003 \ - --api-ip 127.0.0.1 \ - --api-port 15002 \ - --error-target http://127.0.0.1:15001/hub/error - -[Install] -# Start service when system boots -WantedBy=multi-user.target diff --git a/tljh/systemd-units/jupyterhub.service b/tljh/systemd-units/jupyterhub.service index dd94868..676b3e6 100644 --- a/tljh/systemd-units/jupyterhub.service +++ b/tljh/systemd-units/jupyterhub.service @@ -1,9 +1,9 @@ # Template file for JupyterHub systemd service # Uses simple string.format() for 'templating' [Unit] -# CHP must have successfully started *before* we launch JupyterHub -Requires=configurable-http-proxy.service -After=configurable-http-proxy.service +# Traefik must have successfully started *before* we launch JupyterHub +Requires=traefik.service +After=traefik.service [Service] User=root @@ -17,7 +17,6 @@ PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes # Source CONFIGPROXY_AUTH_TOKEN from here! -EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret Environment=TLJH_INSTALL_PREFIX={install_prefix} ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path} diff --git a/tljh/systemd-units/traefik.service b/tljh/systemd-units/traefik.service index 01da967..04f37ee 100644 --- a/tljh/systemd-units/traefik.service +++ b/tljh/systemd-units/traefik.service @@ -7,13 +7,13 @@ After=network.target [Service] User=root Restart=always -# process only needs to write state/acme.json file, no other files ProtectHome=tmpfs ProtectSystem=strict PrivateTmp=yes PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes +ReadWritePaths={install_prefix}/state/rules.toml ReadWritePaths={install_prefix}/state/acme.json WorkingDirectory={install_prefix}/state ExecStart={install_prefix}/hub/bin/traefik \ diff --git a/tljh/systemd.py b/tljh/systemd.py index 2d11934..44b10d0 100644 --- a/tljh/systemd.py +++ b/tljh/systemd.py @@ -70,3 +70,48 @@ def enable_service(name): 'enable', name ], check=True) + + +def check_service_active(name): + """ + Check if a service is currently active (running) + """ + try: + subprocess.run([ + 'systemctl', + 'is-active', + name + ], check=True) + return True + except subprocess.CalledProcessError: + return False + + +def check_hub_ready(): + """ + Check if the hub is ready + """ + + try: + last_restart = subprocess.check_output([ + 'systemctl', + 'show', + 'jupyterhub', + '-p', + 'ActiveEnterTimestamp' + ]).decode().strip() + + last_restart = " ".join(last_restart.split(" ")[-3:-1]) + + out = subprocess.check_output([ + 'journalctl', + '-u', + 'jupyterhub', + '--since', + last_restart + ]) + + if "JupyterHub is now running at" in out.decode(): + return True + except subprocess.CalledProcessError: + return False diff --git a/tljh/traefik.py b/tljh/traefik.py index 6fee96f..1230b47 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -9,11 +9,11 @@ from tljh.configurer import load_config # FIXME: support more than one platform here plat = "linux-amd64" -traefik_version = "1.6.5" +traefik_version = "1.7.5" # record sha256 hashes for supported platforms here checksums = { - "linux-amd64": "9e77c7664e316953e3f5463c323dffeeecbb35d0b1db7fb49f52e1d9464ca193" + "linux-amd64": "4417a9d83753e1ad6bdd64bbbeaeb4b279bcc71542e779b7bcb3b027c6e3356e" } @@ -78,6 +78,9 @@ def ensure_traefik_config(state_dir): os.fchmod(f.fileno(), 0o744) f.write(new_toml) + with open(os.path.join(state_dir, "rules.toml"), "w") as f: + os.fchmod(f.fileno(), 0o744) + # ensure acme.json exists and is private with open(os.path.join(state_dir, "acme.json"), "a") as f: os.fchmod(f.fileno(), 0o600) diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 40f71b4..9ed4625 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -33,7 +33,6 @@ idleTimeout = "10m0s" {% if https['enabled'] %} [entryPoints.https] address = ":{{https['port']}}" - backend = "jupyterhub" [entryPoints.https.tls] {% if https['tls']['cert'] %} [[entryPoints.https.tls.certificates]] @@ -41,6 +40,17 @@ idleTimeout = "10m0s" keyFile = "{{https['tls']['key']}}" {% endif %} {% endif %} + [entryPoints.auth_api] + address = ":8099" + [entryPoints.auth_api.auth.basic] + users = ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"] + +[wss] +protocol = "http" + +[api] +dashboard = true +entrypoint = "auth_api" {% if https['enabled'] and https['letsencrypt']['email'] %} [acme] @@ -57,13 +67,5 @@ entryPoint = "https" {% endif %} [file] - -[frontends] - [frontends.jupyterhub] - backend = "jupyterhub" - passHostHeader = true -[backends] - [backends.jupyterhub] - [backends.jupyterhub.servers.chp] - url = "http://127.0.0.1:15003" - +filename = "/opt/tljh/state/rules.toml" +watch = true From 3ee387cd3bdf40cf1ed42e9477a3d86c18537796 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Mon, 11 Feb 2019 09:24:16 +0200 Subject: [PATCH 02/13] Fixed some issues --- integration-tests/requirements.txt | 1 + integration-tests/test_proxy.py | 9 +++++++-- tests/test_config.py | 7 +++++-- tests/test_configurer.py | 24 ++++++++++++++++++++++ tljh/config.py | 18 ++++++++++++++--- tljh/configurer.py | 27 ++++++++++++++++++++++++- tljh/jupyterhub_config.py | 6 +++--- tljh/systemd.py | 32 +----------------------------- tljh/traefik.py | 4 ++-- tljh/traefik.toml.tpl | 8 +++++--- 10 files changed, 89 insertions(+), 47 deletions(-) diff --git a/integration-tests/requirements.txt b/integration-tests/requirements.txt index 271c563..91f56ca 100644 --- a/integration-tests/requirements.txt +++ b/integration-tests/requirements.txt @@ -1,3 +1,4 @@ pytest pytest-asyncio +passlib git+https://github.com/yuvipanda/hubtraf.git \ No newline at end of file diff --git a/integration-tests/test_proxy.py b/integration-tests/test_proxy.py index a8e114d..1ee236f 100644 --- a/integration-tests/test_proxy.py +++ b/integration-tests/test_proxy.py @@ -53,8 +53,13 @@ def test_manual_https(preserve_config): # verify that our certificate was loaded by traefik assert server_cert == file_cert - # verify that we can still connect to the hub - r = requests.get("https://127.0.0.1/hub/api", verify=False) + for i in range(5): + time.sleep(i) + # verify that we can still connect to the hub + r = requests.get("https://127.0.0.1/hub/api", verify=False) + if r.status_code == 200: + break; + r.raise_for_status() # cleanup diff --git a/tests/test_config.py b/tests/test_config.py index 7b3dda8..811e010 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -119,16 +119,19 @@ def test_remove_from_config_error(): def test_reload_hub(): with mock.patch('tljh.systemd.restart_service') as restart_service, mock.patch( 'tljh.systemd.check_service_active' - ) as check_active, mock.patch('tljh.systemd.check_hub_ready') as check_ready: + ) as check_active, mock.patch('tljh.config.check_hub_ready') as check_ready: config.reload_component('hub') assert restart_service.called_with('jupyterhub') assert check_active.called_with('jupyterhub') def test_reload_proxy(tljh_dir): - with mock.patch('tljh.systemd.restart_service') as restart_service: + with mock.patch('tljh.systemd.restart_service') as restart_service, mock.patch( + 'tljh.systemd.check_service_active' + ) as check_active: config.reload_component('proxy') assert restart_service.called_with('traefik') + assert check_active.called_with('traefik') assert os.path.exists(os.path.join(config.STATE_DIR, 'traefik.toml')) diff --git a/tests/test_configurer.py b/tests/test_configurer.py index 59dd6ec..14ce953 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -159,3 +159,27 @@ def test_auth_github(): assert c.JupyterHub.authenticator_class == 'oauthenticator.github.GitHubOAuthenticator' assert c.GitHubOAuthenticator.client_id == 'something' assert c.GitHubOAuthenticator.client_secret == 'something-else' + + +def test_auth_api_default(): + """ + Test default traefik api authentication settings with no overrides + """ + c = apply_mock_config({}) + + assert c.TraefikTomlProxy.traefik_api_username == 'api_admin' + assert c.TraefikTomlProxy.traefik_api_password == 'admin' + + +def test_set_auth_api(): + """ + Test setting per traefik api credentials + """ + c = apply_mock_config({ + 'auth_api': { + 'username': 'some_user', + 'password': '1234' + } + }) + assert c.TraefikTomlProxy.traefik_api_username == 'some_user' + assert c.TraefikTomlProxy.traefik_api_password == '1234' \ No newline at end of file diff --git a/tljh/config.py b/tljh/config.py index c322421..bdc879b 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -173,6 +173,14 @@ def remove_config_value(config_path, key_path, value): with open(config_path, 'w') as f: yaml.dump(config, f) +def check_hub_ready(): + import requests + + try: + r = requests.get('http://127.0.0.1:80') + return r.status_code == 200 + except: + return False def reload_component(component): """ @@ -182,17 +190,21 @@ def reload_component(component): """ # import here to avoid circular imports from tljh import systemd, traefik + import time + if component == 'hub': systemd.restart_service('jupyterhub') # Ensure hub is back up while not systemd.check_service_active('jupyterhub'): - asyncio.sleep(1) - while not systemd.check_hub_ready(): - asyncio.sleep(1) + time.sleep(1) + while not check_hub_ready(): + time.sleep(1) print('Hub reload with new configuration complete') elif component == 'proxy': traefik.ensure_traefik_config(STATE_DIR) systemd.restart_service('traefik') + while not systemd.check_service_active('traefik'): + time.sleep(1) print('Proxy reload with new configuration complete') diff --git a/tljh/configurer.py b/tljh/configurer.py index e8526d9..a99e88a 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -46,12 +46,18 @@ default = { 'domains': [], }, }, + 'auth_api': { + 'ip': "127.0.0.1", + 'port': 8099, + 'username': 'api_admin', + 'password': 'admin', + 'basic_auth': '' + }, 'user_environment': { 'default_app': 'classic', }, } - def load_config(config_file=CONFIG_FILE): """Load the current config as a dictionary @@ -62,6 +68,8 @@ def load_config(config_file=CONFIG_FILE): config_overrides = yaml.load(f) else: config_overrides = {} + + generate_traefik_api_credentials() return _merge_dictionaries(dict(default), config_overrides) @@ -76,6 +84,7 @@ def apply_config(config_overrides, c): update_limits(c, tljh_config) update_user_environment(c, tljh_config) update_user_account_config(c, tljh_config) + update_auth_api(c, tljh_config) def set_if_not_none(parent, key, value): @@ -85,6 +94,14 @@ def set_if_not_none(parent, key, value): if value is not None: setattr(parent, key, value) +def generate_traefik_api_credentials(): + from passlib.apache import HtpasswdFile + + ht = HtpasswdFile() + ht.set_password(default['auth_api']['username'], default['auth_api']['password']) + traefik_api_hashed_password = str(ht.to_string()).split(":")[1][:-3] + default['auth_api']['basic_auth'] = default['auth_api']['username'] + ":" + traefik_api_hashed_password + def update_auth(c, config): """ @@ -149,6 +166,14 @@ def update_user_account_config(c, config): c.SystemdSpawner.username_template = 'jupyter-{USERNAME}' +def update_auth_api(c, config): + """ + Set traefik api endpoint credentials + """ + c.TraefikTomlProxy.traefik_api_username = config['auth_api']['username'] + c.TraefikTomlProxy.traefik_api_password = config['auth_api']['password'] + + def _merge_dictionaries(a, b, path=None, update=True): """ Merge two dictionaries recursively. diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index da1999d..61be82e 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -44,9 +44,9 @@ c.JupyterHub.cleanup_servers = False c.JupyterHub.hub_port = 15001 c.TraefikTomlProxy.should_start = False -c.TraefikTomlProxy.traefik_api_password = "admin" -c.TraefikTomlProxy.traefik_api_username = "api_admin" -c.TraefikTomlProxy.toml_dynamic_config_file = "/opt/tljh/state/rules.toml" + +dynamic_conf_file_path = os.path.join(INSTALL_PREFIX, 'state', 'rules.toml') +c.TraefikTomlProxy.toml_dynamic_config_file = dynamic_conf_file_path c.JupyterHub.proxy_class = TraefikTomlProxy c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, 'bin')] diff --git a/tljh/systemd.py b/tljh/systemd.py index 44b10d0..53fa965 100644 --- a/tljh/systemd.py +++ b/tljh/systemd.py @@ -84,34 +84,4 @@ def check_service_active(name): ], check=True) return True except subprocess.CalledProcessError: - return False - - -def check_hub_ready(): - """ - Check if the hub is ready - """ - - try: - last_restart = subprocess.check_output([ - 'systemctl', - 'show', - 'jupyterhub', - '-p', - 'ActiveEnterTimestamp' - ]).decode().strip() - - last_restart = " ".join(last_restart.split(" ")[-3:-1]) - - out = subprocess.check_output([ - 'journalctl', - '-u', - 'jupyterhub', - '--since', - last_restart - ]) - - if "JupyterHub is now running at" in out.decode(): - return True - except subprocess.CalledProcessError: - return False + return False \ No newline at end of file diff --git a/tljh/traefik.py b/tljh/traefik.py index 1230b47..5758d09 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -75,11 +75,11 @@ def ensure_traefik_config(state_dir): ): raise ValueError("Both email and domains must be set for letsencrypt") with open(os.path.join(state_dir, "traefik.toml"), "w") as f: - os.fchmod(f.fileno(), 0o744) + os.fchmod(f.fileno(), 0o600) f.write(new_toml) with open(os.path.join(state_dir, "rules.toml"), "w") as f: - os.fchmod(f.fileno(), 0o744) + os.fchmod(f.fileno(), 0o600) # ensure acme.json exists and is private with open(os.path.join(state_dir, "acme.json"), "a") as f: diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 9ed4625..3e53326 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -41,9 +41,11 @@ idleTimeout = "10m0s" {% endif %} {% endif %} [entryPoints.auth_api] - address = ":8099" + address = ":{{auth_api['port']}}" + [entryPoints.auth_api.whiteList] + sourceRange = ['{{auth_api['ip']}}'] [entryPoints.auth_api.auth.basic] - users = ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"] + users = ['{{auth_api['basic_auth']}}'] [wss] protocol = "http" @@ -67,5 +69,5 @@ entryPoint = "https" {% endif %} [file] -filename = "/opt/tljh/state/rules.toml" +filename = "rules.toml" watch = true From 03b4c6218b71297f13240301b17eb0dabdcd33cd Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Mon, 11 Feb 2019 11:13:22 +0200 Subject: [PATCH 03/13] Fixed cfg runtime entry in test --- dev-requirements.txt | 1 + tests/test_traefik.py | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index fd93238..292131f 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,3 +2,4 @@ pytest pytest-cov codecov pytoml +passlib diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 302b9e6..3480c0e 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -27,13 +27,19 @@ def test_default_config(tmpdir, tljh_dir): print(toml_cfg) cfg = toml.loads(toml_cfg) assert cfg["defaultEntryPoints"] == ["http"] + assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 + assert cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"][0].startswith("api_admin") + # runtime generated entry, value not testable + cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + assert cfg["entryPoints"] == { "http": {"address": ":80"}, "auth_api": { "address": ":8099", "auth": { - "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + "basic": {"users": [""]} }, + "whiteList": {"sourceRange": ["127.0.0.1"]} }, } @@ -57,14 +63,20 @@ def test_letsencrypt_config(tljh_dir): cfg = toml.loads(toml_cfg) assert cfg["defaultEntryPoints"] == ["http", "https"] assert "acme" in cfg + assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 + assert cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"][0].startswith("api_admin") + # runtime generated entry, value not testable + cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + assert cfg["entryPoints"] == { "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, "https": {"address": ":443", "tls": {}}, "auth_api": { "address": ":8099", "auth": { - "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + "basic": {"users": [""]} }, + "whiteList": {"sourceRange": ["127.0.0.1"]} }, } assert cfg["acme"] == { @@ -91,6 +103,10 @@ def test_manual_ssl_config(tljh_dir): cfg = toml.loads(toml_cfg) assert cfg["defaultEntryPoints"] == ["http", "https"] assert "acme" not in cfg + assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 + assert cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"][0].startswith("api_admin") + # runtime generated entry, value not testable + cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] assert cfg["entryPoints"] == { "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, "https": { @@ -104,7 +120,8 @@ def test_manual_ssl_config(tljh_dir): "auth_api": { "address": ":8099", "auth": { - "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + "basic": {"users": [""]} }, + "whiteList": {"sourceRange": ["127.0.0.1"]} }, } From 956c95e5271e4eb101300d0d9197166bb2063e23 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 12 Feb 2019 14:35:59 +0200 Subject: [PATCH 04/13] Fixed minor issues --- bootstrap/bootstrap.py | 2 +- tests/test_configurer.py | 2 +- tljh/systemd.py | 2 +- tljh/traefik.toml.tpl | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index e448438..60411cb 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -86,7 +86,7 @@ def main(): 'git+https://github.com/jupyterhub/the-littlest-jupyterhub.git' ) - traefik_proxy_repo_path = 'git+https://github.com/jupyterhub/traefik-proxy.git' + traefik_proxy_repo_path = 'jupyterhub-traefik-proxy==0.1.0a1' subprocess.check_output([ os.path.join(hub_prefix, 'bin', 'pip'), diff --git a/tests/test_configurer.py b/tests/test_configurer.py index 14ce953..96d2fe0 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -182,4 +182,4 @@ def test_set_auth_api(): } }) assert c.TraefikTomlProxy.traefik_api_username == 'some_user' - assert c.TraefikTomlProxy.traefik_api_password == '1234' \ No newline at end of file + assert c.TraefikTomlProxy.traefik_api_password == '1234' diff --git a/tljh/systemd.py b/tljh/systemd.py index 53fa965..ae1af19 100644 --- a/tljh/systemd.py +++ b/tljh/systemd.py @@ -84,4 +84,4 @@ def check_service_active(name): ], check=True) return True except subprocess.CalledProcessError: - return False \ No newline at end of file + return False diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 3e53326..20a3487 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -41,7 +41,7 @@ idleTimeout = "10m0s" {% endif %} {% endif %} [entryPoints.auth_api] - address = ":{{auth_api['port']}}" + address = "127.0.0.1:{{auth_api['port']}}" [entryPoints.auth_api.whiteList] sourceRange = ['{{auth_api['ip']}}'] [entryPoints.auth_api.auth.basic] From f7f686f540bb8d2e52b4efccbf3f9fbd5c965abd Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 12 Feb 2019 14:55:01 +0200 Subject: [PATCH 05/13] Fixed unit test --- tests/test_traefik.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 3480c0e..bcb0248 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -35,7 +35,7 @@ def test_default_config(tmpdir, tljh_dir): assert cfg["entryPoints"] == { "http": {"address": ":80"}, "auth_api": { - "address": ":8099", + "address": "127.0.0.1:8099", "auth": { "basic": {"users": [""]} }, @@ -72,7 +72,7 @@ def test_letsencrypt_config(tljh_dir): "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, "https": {"address": ":443", "tls": {}}, "auth_api": { - "address": ":8099", + "address": "127.0.0.1:8099", "auth": { "basic": {"users": [""]} }, @@ -118,7 +118,7 @@ def test_manual_ssl_config(tljh_dir): }, }, "auth_api": { - "address": ":8099", + "address": "127.0.0.1:8099", "auth": { "basic": {"users": [""]} }, From e8b303d01b26560905835b8bf6bea60fe7049014 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Wed, 13 Feb 2019 14:10:28 +0200 Subject: [PATCH 06/13] Generate random traefik api password --- bootstrap/bootstrap.py | 8 -------- dev-requirements.txt | 3 +-- integration-tests/requirements.txt | 1 - setup.py | 4 +++- tests/test_configurer.py | 2 +- tljh/config.py | 10 +++++----- tljh/configurer.py | 12 +++++++++--- tljh/installer.py | 12 +++++++++++- tljh/systemd-units/jupyterhub.service | 1 - 9 files changed, 30 insertions(+), 23 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 60411cb..3a42fb2 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -86,20 +86,12 @@ def main(): 'git+https://github.com/jupyterhub/the-littlest-jupyterhub.git' ) - traefik_proxy_repo_path = 'jupyterhub-traefik-proxy==0.1.0a1' - subprocess.check_output([ os.path.join(hub_prefix, 'bin', 'pip'), 'install' ] + pip_flags + [tljh_repo_path], stderr=subprocess.STDOUT) logger.info('Setup tljh package') - subprocess.check_output([ - os.path.join(hub_prefix, 'bin', 'pip'), - 'install' - ] + [traefik_proxy_repo_path], stderr=subprocess.STDOUT) - logger.info('Setup traefik-proxy package') - logger.info('Starting TLJH installer...') os.execv( os.path.join(hub_prefix, 'bin', 'python3'), diff --git a/dev-requirements.txt b/dev-requirements.txt index 292131f..166dd49 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,4 @@ pytest pytest-cov codecov -pytoml -passlib +pytoml \ No newline at end of file diff --git a/integration-tests/requirements.txt b/integration-tests/requirements.txt index 91f56ca..271c563 100644 --- a/integration-tests/requirements.txt +++ b/integration-tests/requirements.txt @@ -1,4 +1,3 @@ pytest pytest-asyncio -passlib git+https://github.com/yuvipanda/hubtraf.git \ No newline at end of file diff --git a/setup.py b/setup.py index 7dd7d81..896c8db 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,9 @@ setup( install_requires=[ 'ruamel.yaml==0.15.*', 'jinja2', - 'pluggy>0.7<1.0' + 'pluggy>0.7<1.0', + 'passlib', + 'jupyterhub-traefik-proxy==0.1.0a1' ], entry_points={ 'console_scripts': [ diff --git a/tests/test_configurer.py b/tests/test_configurer.py index 96d2fe0..c36324e 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -168,7 +168,7 @@ def test_auth_api_default(): c = apply_mock_config({}) assert c.TraefikTomlProxy.traefik_api_username == 'api_admin' - assert c.TraefikTomlProxy.traefik_api_password == 'admin' + assert len(c.TraefikTomlProxy.traefik_api_password) == 0 def test_set_auth_api(): diff --git a/tljh/config.py b/tljh/config.py index bdc879b..39edbd3 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -13,12 +13,15 @@ tljh-config show firstlevel.second_level """ import argparse +import asyncio from collections import Sequence, Mapping from copy import deepcopy import os import re import sys -import asyncio +import time + +import requests from .yaml import yaml @@ -174,10 +177,8 @@ def remove_config_value(config_path, key_path, value): yaml.dump(config, f) def check_hub_ready(): - import requests - try: - r = requests.get('http://127.0.0.1:80') + r = requests.get('http://127.0.0.1:80', verify=False) return r.status_code == 200 except: return False @@ -190,7 +191,6 @@ def reload_component(component): """ # import here to avoid circular imports from tljh import systemd, traefik - import time if component == 'hub': systemd.restart_service('jupyterhub') diff --git a/tljh/configurer.py b/tljh/configurer.py index a99e88a..e019ae8 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -10,7 +10,9 @@ FIXME: A strong feeling that JSON Schema should be involved somehow. import os -from .config import CONFIG_FILE +from passlib.apache import HtpasswdFile + +from .config import CONFIG_FILE, STATE_DIR from .yaml import yaml # Default configuration for tljh @@ -50,7 +52,7 @@ default = { 'ip': "127.0.0.1", 'port': 8099, 'username': 'api_admin', - 'password': 'admin', + 'password': '', 'basic_auth': '' }, 'user_environment': { @@ -95,9 +97,13 @@ def set_if_not_none(parent, key, value): setattr(parent, key, value) def generate_traefik_api_credentials(): - from passlib.apache import HtpasswdFile + proxy_secret_path = os.path.join(STATE_DIR, 'traefik-api.secret') + with open(proxy_secret_path,'r') as f: + password = f.read() + default['auth_api']['password'] = password ht = HtpasswdFile() + # generate htpassword ht.set_password(default['auth_api']['username'], default['auth_api']['password']) traefik_api_hashed_password = str(ht.to_string()).split(":")[1][:-3] default['auth_api']['basic_auth'] = default['auth_api']['username'] + ":" + traefik_api_hashed_password diff --git a/tljh/installer.py b/tljh/installer.py index 2b35a56..8fbc663 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -120,9 +120,18 @@ def ensure_jupyterhub_service(prefix): with open(os.path.join(HERE, 'systemd-units', 'jupyterhub.service')) as f: hub_unit_template = f.read() + # with open(os.path.join(HERE, 'systemd-units', 'configurable-http-proxy.service')) as f: + # chp_unit_template = f.read() + with open(os.path.join(HERE, 'systemd-units', 'traefik.service')) as f: traefik_unit_template = f.read() + #Set up proxy / hub secret token if it is not already setup + proxy_secret_path = os.path.join(STATE_DIR, 'traefik-api.secret') + if not os.path.exists(proxy_secret_path): + with open(proxy_secret_path, 'w') as f: + f.write(secrets.token_hex(32)) + traefik.ensure_traefik_config(STATE_DIR) unit_params = dict( @@ -132,13 +141,14 @@ def ensure_jupyterhub_service(prefix): ) systemd.install_unit('jupyterhub.service', hub_unit_template.format(**unit_params)) systemd.install_unit('traefik.service', traefik_unit_template.format(**unit_params)) + # systemd.install_unit('configurable-http-proxy.service', chp_unit_template.format(**unit_params)) systemd.reload_daemon() # If JupyterHub is running, we want to restart it. systemd.restart_service('jupyterhub') systemd.restart_service('traefik') - # Mark JupyterHub & CHP to start at boot time + # Mark JupyterHub & traefik to start at boot time systemd.enable_service('jupyterhub') systemd.enable_service('traefik') diff --git a/tljh/systemd-units/jupyterhub.service b/tljh/systemd-units/jupyterhub.service index 676b3e6..e766e20 100644 --- a/tljh/systemd-units/jupyterhub.service +++ b/tljh/systemd-units/jupyterhub.service @@ -16,7 +16,6 @@ PrivateTmp=yes PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes -# Source CONFIGPROXY_AUTH_TOKEN from here! Environment=TLJH_INSTALL_PREFIX={install_prefix} ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path} From ffa635dda3278809e5fb7cfb883a1b03dbd24010 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Wed, 13 Feb 2019 14:59:57 +0200 Subject: [PATCH 07/13] Fixed tests --- tests/test_config.py | 33 +++++++++++++++++++++++++-------- tests/test_traefik.py | 19 +++++++++++++------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 811e010..9b1dc1d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -126,9 +126,11 @@ def test_reload_hub(): def test_reload_proxy(tljh_dir): - with mock.patch('tljh.systemd.restart_service') as restart_service, mock.patch( - 'tljh.systemd.check_service_active' - ) as check_active: + with mock.patch("tljh.systemd.restart_service") as restart_service, mock.patch( + "tljh.systemd.check_service_active" + ) as check_active, mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: config.reload_component('proxy') assert restart_service.called_with('traefik') assert check_active.called_with('traefik') @@ -151,29 +153,44 @@ def test_cli_no_command(capsys): ) def test_cli_set_bool(tljh_dir, arg, value): config.main(["set", "https.enabled", arg]) - cfg = configurer.load_config() + with mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: + cfg = configurer.load_config() assert cfg['https']['enabled'] == value def test_cli_set_int(tljh_dir): config.main(["set", "https.port", "123"]) - cfg = configurer.load_config() + with mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: + cfg = configurer.load_config() assert cfg['https']['port'] == 123 def test_cli_add_float(tljh_dir): config.main(["add-item", "foo.bar", "1.25"]) - cfg = configurer.load_config() + with mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: + cfg = configurer.load_config() assert cfg['foo']['bar'] == [1.25] def test_cli_remove_int(tljh_dir): config.main(["add-item", "foo.bar", "1"]) config.main(["add-item", "foo.bar", "2"]) - cfg = configurer.load_config() + with mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: + cfg = configurer.load_config() assert cfg['foo']['bar'] == [1, 2] config.main(["remove-item", "foo.bar", "1"]) - cfg = configurer.load_config() + with mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: + cfg = configurer.load_config() assert cfg['foo']['bar'] == [2] diff --git a/tests/test_traefik.py b/tests/test_traefik.py index bcb0248..38f4b62 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -1,5 +1,6 @@ """Test traefik configuration""" import os +import mock import pytoml as toml @@ -17,7 +18,10 @@ def test_download_traefik(tmpdir): def test_default_config(tmpdir, tljh_dir): state_dir = tmpdir.mkdir("state") - traefik.ensure_traefik_config(str(state_dir)) + with mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: + traefik.ensure_traefik_config(str(state_dir)) assert state_dir.join("traefik.toml").exists() traefik_toml = os.path.join(state_dir, "traefik.toml") with open(traefik_toml) as f: @@ -28,7 +32,6 @@ def test_default_config(tmpdir, tljh_dir): cfg = toml.loads(toml_cfg) assert cfg["defaultEntryPoints"] == ["http"] assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - assert cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"][0].startswith("api_admin") # runtime generated entry, value not testable cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] @@ -53,7 +56,10 @@ def test_letsencrypt_config(tljh_dir): config.set_config_value( config.CONFIG_FILE, "https.letsencrypt.domains", ["testing.jovyan.org"] ) - traefik.ensure_traefik_config(str(state_dir)) + with mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: + traefik.ensure_traefik_config(str(state_dir)) traefik_toml = os.path.join(state_dir, "traefik.toml") with open(traefik_toml) as f: toml_cfg = f.read() @@ -64,7 +70,6 @@ def test_letsencrypt_config(tljh_dir): assert cfg["defaultEntryPoints"] == ["http", "https"] assert "acme" in cfg assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - assert cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"][0].startswith("api_admin") # runtime generated entry, value not testable cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] @@ -93,7 +98,10 @@ def test_manual_ssl_config(tljh_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") - traefik.ensure_traefik_config(str(state_dir)) + with mock.patch( + "tljh.configurer.generate_traefik_api_credentials" + ) as generate_credentials: + traefik.ensure_traefik_config(str(state_dir)) traefik_toml = os.path.join(state_dir, "traefik.toml") with open(traefik_toml) as f: toml_cfg = f.read() @@ -104,7 +112,6 @@ def test_manual_ssl_config(tljh_dir): assert cfg["defaultEntryPoints"] == ["http", "https"] assert "acme" not in cfg assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 - assert cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"][0].startswith("api_admin") # runtime generated entry, value not testable cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] assert cfg["entryPoints"] == { From 84d8000114d6bf3673194c95d41c044c622a876b Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Mon, 18 Feb 2019 15:08:53 +0200 Subject: [PATCH 08/13] Removed chp service --- tests/test_traefik.py | 2 +- tljh/installer.py | 30 ++++++++++++++++++++---------- tljh/systemd.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 38f4b62..633127f 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -1,6 +1,6 @@ """Test traefik configuration""" import os -import mock +from unittest import mock import pytoml as toml diff --git a/tljh/installer.py b/tljh/installer.py index 8fbc663..af90af8 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -99,15 +99,25 @@ sckuXINIU3DFWzZGr0QrqkuE/jyr7FXeUJj9B7cLo+s/TXo+RaVfi3kOc9BoxIvy apt.add_source('nodesource', 'https://deb.nodesource.com/node_10.x', 'main') apt.install_packages(['nodejs']) - -def ensure_chp_package(prefix): +def remove_chp(): """ - Ensure CHP is installed + Ensure CHP is not running """ - if not os.path.exists(os.path.join(prefix, 'node_modules', '.bin', 'configurable-http-proxy')): - subprocess.check_output([ - 'npm', 'install', 'configurable-http-proxy@3.1.0' - ], cwd=prefix, stderr=subprocess.STDOUT) + if os.path.exists(os.path.join(HERE, 'systemd-units', 'configurable-http-proxy.service')): + if systemd.check_service_active('configurable-http-proxy.service'): + try: + systemd.stop_service('configurable-http-proxy.service') + except subprocess.CalledProcessError: + logger.info("Cannot stop configurable-http-proxy...") + if systemd.check_service_enabled('configurable-http-proxy.service'): + try: + systemd.disable_service('configurable-http-proxy.service') + except subprocess.CalledProcessError: + logger.info("Cannot disable configurable-http-proxy...") + try: + systemd.uninstall_unit('configurable-http-proxy.service') + except subprocess.CalledProcessError: + logger.info("Cannot uninstall configurable-http-proxy...") def ensure_jupyterhub_service(prefix): @@ -117,11 +127,12 @@ def ensure_jupyterhub_service(prefix): os.makedirs(STATE_DIR, mode=0o700, exist_ok=True) + remove_chp() + systemd.reload_daemon() + with open(os.path.join(HERE, 'systemd-units', 'jupyterhub.service')) as f: hub_unit_template = f.read() - # with open(os.path.join(HERE, 'systemd-units', 'configurable-http-proxy.service')) as f: - # chp_unit_template = f.read() with open(os.path.join(HERE, 'systemd-units', 'traefik.service')) as f: traefik_unit_template = f.read() @@ -141,7 +152,6 @@ def ensure_jupyterhub_service(prefix): ) systemd.install_unit('jupyterhub.service', hub_unit_template.format(**unit_params)) systemd.install_unit('traefik.service', traefik_unit_template.format(**unit_params)) - # systemd.install_unit('configurable-http-proxy.service', chp_unit_template.format(**unit_params)) systemd.reload_daemon() # If JupyterHub is running, we want to restart it. diff --git a/tljh/systemd.py b/tljh/systemd.py index ae1af19..fe85321 100644 --- a/tljh/systemd.py +++ b/tljh/systemd.py @@ -48,6 +48,17 @@ def start_service(name): ], check=True) +def stop_service(name): + """ + Start service with given name. + """ + subprocess.run([ + 'systemctl', + 'stop', + name + ], check=True) + + def restart_service(name): """ Restart service with given name. @@ -72,6 +83,19 @@ def enable_service(name): ], check=True) +def disable_service(name): + """ + Enable a service with given name. + + This most likely makes the service start on bootup + """ + subprocess.run([ + 'systemctl', + 'disable', + name + ], check=True) + + def check_service_active(name): """ Check if a service is currently active (running) @@ -85,3 +109,17 @@ def check_service_active(name): return True except subprocess.CalledProcessError: return False + +def check_service_enabled(name): + """ + Check if a service is enabled + """ + try: + subprocess.run([ + 'systemctl', + 'is-enabled', + name + ], check=True) + return True + except subprocess.CalledProcessError: + return False From 436610f83e7674ef6dd93bac5b81ddc44936d2d6 Mon Sep 17 00:00:00 2001 From: GeorgianaElena Date: Tue, 19 Feb 2019 17:28:43 +0200 Subject: [PATCH 09/13] Use the actual installation path of chp service, changed traefik-proxy version --- setup.py | 2 +- tljh/installer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 896c8db..61f8ead 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( 'jinja2', 'pluggy>0.7<1.0', 'passlib', - 'jupyterhub-traefik-proxy==0.1.0a1' + 'jupyterhub-traefik-proxy==0.1.*' ], entry_points={ 'console_scripts': [ diff --git a/tljh/installer.py b/tljh/installer.py index af90af8..56f5ae5 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -103,7 +103,7 @@ def remove_chp(): """ Ensure CHP is not running """ - if os.path.exists(os.path.join(HERE, 'systemd-units', 'configurable-http-proxy.service')): + if os.path.exists("/etc/systemd/system/configurable-http-proxy.service"): if systemd.check_service_active('configurable-http-proxy.service'): try: systemd.stop_service('configurable-http-proxy.service') From 23ea539b280c335d1843a5676c71aa6969578b80 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 22 Feb 2019 10:53:36 +0100 Subject: [PATCH 10/13] move generating traefik basic auth to traefik.py compute this when we write the template, not when we load config --- tljh/configurer.py | 20 ++++++-------------- tljh/traefik.py | 15 +++++++++++++++ tljh/traefik.toml.tpl | 6 +++--- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/tljh/configurer.py b/tljh/configurer.py index e019ae8..2a68e88 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -10,8 +10,6 @@ FIXME: A strong feeling that JSON Schema should be involved somehow. import os -from passlib.apache import HtpasswdFile - from .config import CONFIG_FILE, STATE_DIR from .yaml import yaml @@ -48,12 +46,11 @@ default = { 'domains': [], }, }, - 'auth_api': { + 'traefik_api': { 'ip': "127.0.0.1", 'port': 8099, 'username': 'api_admin', 'password': '', - 'basic_auth': '' }, 'user_environment': { 'default_app': 'classic', @@ -86,7 +83,7 @@ def apply_config(config_overrides, c): update_limits(c, tljh_config) update_user_environment(c, tljh_config) update_user_account_config(c, tljh_config) - update_auth_api(c, tljh_config) + update_traefik_api(c, tljh_config) def set_if_not_none(parent, key, value): @@ -101,12 +98,7 @@ def generate_traefik_api_credentials(): with open(proxy_secret_path,'r') as f: password = f.read() - default['auth_api']['password'] = password - ht = HtpasswdFile() - # generate htpassword - ht.set_password(default['auth_api']['username'], default['auth_api']['password']) - traefik_api_hashed_password = str(ht.to_string()).split(":")[1][:-3] - default['auth_api']['basic_auth'] = default['auth_api']['username'] + ":" + traefik_api_hashed_password + default['traefik_api']['password'] = password def update_auth(c, config): @@ -172,12 +164,12 @@ def update_user_account_config(c, config): c.SystemdSpawner.username_template = 'jupyter-{USERNAME}' -def update_auth_api(c, config): +def update_traefik_api(c, config): """ Set traefik api endpoint credentials """ - c.TraefikTomlProxy.traefik_api_username = config['auth_api']['username'] - c.TraefikTomlProxy.traefik_api_password = config['auth_api']['password'] + c.TraefikTomlProxy.traefik_api_username = config['traefik_api']['username'] + c.TraefikTomlProxy.traefik_api_password = config['traefik_api']['password'] def _merge_dictionaries(a, b, path=None, update=True): diff --git a/tljh/traefik.py b/tljh/traefik.py index 5758d09..4581953 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -4,6 +4,7 @@ import os from urllib.request import urlretrieve from jinja2 import Template +from passlib.apache import HtpasswdFile from tljh.configurer import load_config @@ -55,9 +56,23 @@ def ensure_traefik_binary(prefix): raise IOError(f"Checksum failed {traefik_bin}: {checksum} != {checksums[plat]}") +def compute_basic_auth(username, password): + """Generate hashed HTTP basic auth from traefik_api username+password""" + ht = HtpasswdFile() + # generate htpassword + ht.set_password(username, password) + hashed_password = str(ht.to_string()).split(":")[1][:-3] + return username + ":" + hashed_password + + def ensure_traefik_config(state_dir): """Render the traefik.toml config file""" config = load_config() + config['traefik_api']['basic_auth'] = compute_basic_auth( + config['traefik_api']['username'], + config['traefik_api']['password'], + ) + with open(os.path.join(os.path.dirname(__file__), "traefik.toml.tpl")) as f: template = Template(f.read()) new_toml = template.render(config) diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 20a3487..2b88b77 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -41,11 +41,11 @@ idleTimeout = "10m0s" {% endif %} {% endif %} [entryPoints.auth_api] - address = "127.0.0.1:{{auth_api['port']}}" + address = "127.0.0.1:{{traefik_api['port']}}" [entryPoints.auth_api.whiteList] - sourceRange = ['{{auth_api['ip']}}'] + sourceRange = ['{{traefik_api['ip']}}'] [entryPoints.auth_api.auth.basic] - users = ['{{auth_api['basic_auth']}}'] + users = ['{{ traefik_api['basic_auth'] }}'] [wss] protocol = "http" From fd114e9030a606e07a21c60b8643e4961995e896 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 22 Feb 2019 11:17:59 +0100 Subject: [PATCH 11/13] use load_config to load config in jupyterhub_config.py rather than re-implementing it --- tljh/jupyterhub_config.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index 61be82e..ff1a34a 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -54,13 +54,8 @@ c.SystemdSpawner.default_shell = '/bin/bash' # Drop the '-singleuser' suffix present in the default template c.SystemdSpawner.unit_name_template = 'jupyter-{USERNAME}' -config_overrides_path = os.path.join(CONFIG_DIR, 'config.yaml') -if os.path.exists(config_overrides_path): - with open(config_overrides_path) as f: - config_overrides = yaml.load(f) -else: - config_overrides = {} -configurer.apply_config(config_overrides, c) +tljh_config = configurer.load_config() +configurer.apply_config(tljh_config, c) # Load arbitrary .py config files if they exist. # This is our escape hatch From af36ee73e46d9a3e873f29c70c7ab52b2c1f6764 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 22 Feb 2019 11:39:40 +0100 Subject: [PATCH 12/13] no longer need to mock out generate_traefik_api_credentials --- tests/test_config.py | 29 ++++++----------------------- tests/test_traefik.py | 15 +++------------ 2 files changed, 9 insertions(+), 35 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 9b1dc1d..ce61330 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -128,9 +128,7 @@ def test_reload_hub(): def test_reload_proxy(tljh_dir): with mock.patch("tljh.systemd.restart_service") as restart_service, mock.patch( "tljh.systemd.check_service_active" - ) as check_active, mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: + ) as check_active: config.reload_component('proxy') assert restart_service.called_with('traefik') assert check_active.called_with('traefik') @@ -153,44 +151,29 @@ def test_cli_no_command(capsys): ) def test_cli_set_bool(tljh_dir, arg, value): config.main(["set", "https.enabled", arg]) - with mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: - cfg = configurer.load_config() + cfg = configurer.load_config() assert cfg['https']['enabled'] == value def test_cli_set_int(tljh_dir): config.main(["set", "https.port", "123"]) - with mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: - cfg = configurer.load_config() + cfg = configurer.load_config() assert cfg['https']['port'] == 123 def test_cli_add_float(tljh_dir): config.main(["add-item", "foo.bar", "1.25"]) - with mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: - cfg = configurer.load_config() + cfg = configurer.load_config() assert cfg['foo']['bar'] == [1.25] def test_cli_remove_int(tljh_dir): config.main(["add-item", "foo.bar", "1"]) config.main(["add-item", "foo.bar", "2"]) - with mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: - cfg = configurer.load_config() + cfg = configurer.load_config() assert cfg['foo']['bar'] == [1, 2] config.main(["remove-item", "foo.bar", "1"]) - with mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: - cfg = configurer.load_config() + cfg = configurer.load_config() assert cfg['foo']['bar'] == [2] diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 633127f..4ef9065 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -18,10 +18,7 @@ def test_download_traefik(tmpdir): def test_default_config(tmpdir, tljh_dir): state_dir = tmpdir.mkdir("state") - with mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: - traefik.ensure_traefik_config(str(state_dir)) + traefik.ensure_traefik_config(str(state_dir)) assert state_dir.join("traefik.toml").exists() traefik_toml = os.path.join(state_dir, "traefik.toml") with open(traefik_toml) as f: @@ -56,10 +53,7 @@ def test_letsencrypt_config(tljh_dir): config.set_config_value( config.CONFIG_FILE, "https.letsencrypt.domains", ["testing.jovyan.org"] ) - with mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: - traefik.ensure_traefik_config(str(state_dir)) + traefik.ensure_traefik_config(str(state_dir)) traefik_toml = os.path.join(state_dir, "traefik.toml") with open(traefik_toml) as f: toml_cfg = f.read() @@ -98,10 +92,7 @@ def test_manual_ssl_config(tljh_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") - with mock.patch( - "tljh.configurer.generate_traefik_api_credentials" - ) as generate_credentials: - traefik.ensure_traefik_config(str(state_dir)) + traefik.ensure_traefik_config(str(state_dir)) traefik_toml = os.path.join(state_dir, "traefik.toml") with open(traefik_toml) as f: toml_cfg = f.read() From 7c9bea377f09a8ff117f1eb49eb47cf8cf2d072e Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 22 Feb 2019 11:41:50 +0100 Subject: [PATCH 13/13] add load_secrets as an explicit stage during load_config rather than applying directly to defaults, which should be left static --- tests/test_configurer.py | 29 ++++++++++++++++++++++------- tljh/configurer.py | 27 +++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/tests/test_configurer.py b/tests/test_configurer.py index c36324e..f58e40f 100644 --- a/tests/test_configurer.py +++ b/tests/test_configurer.py @@ -1,7 +1,9 @@ """ -Test +Test configurer """ +import os + from tljh import configurer @@ -161,7 +163,7 @@ def test_auth_github(): assert c.GitHubOAuthenticator.client_secret == 'something-else' -def test_auth_api_default(): +def test_traefik_api_default(): """ Test default traefik api authentication settings with no overrides """ @@ -171,15 +173,28 @@ def test_auth_api_default(): assert len(c.TraefikTomlProxy.traefik_api_password) == 0 -def test_set_auth_api(): +def test_set_traefik_api(): """ Test setting per traefik api credentials """ c = apply_mock_config({ - 'auth_api': { - 'username': 'some_user', - 'password': '1234' - } + 'traefik_api': { + 'username': 'some_user', + 'password': '1234' + } }) assert c.TraefikTomlProxy.traefik_api_username == 'some_user' assert c.TraefikTomlProxy.traefik_api_password == '1234' + + +def test_load_secrets(tljh_dir): + """ + Test loading secret files + """ + with open(os.path.join(tljh_dir, 'state', 'traefik-api.secret'), 'w') as f: + f.write("traefik-password") + + tljh_config = configurer.load_config() + assert tljh_config['traefik_api']['password'] == "traefik-password" + c = apply_mock_config(tljh_config) + assert c.TraefikTomlProxy.traefik_api_password == "traefik-password" diff --git a/tljh/configurer.py b/tljh/configurer.py index 2a68e88..491a5f8 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -68,8 +68,10 @@ def load_config(config_file=CONFIG_FILE): else: config_overrides = {} - generate_traefik_api_credentials() - return _merge_dictionaries(dict(default), config_overrides) + secrets = load_secrets() + config = _merge_dictionaries(dict(default), secrets) + config = _merge_dictionaries(config, config_overrides) + return config def apply_config(config_overrides, c): @@ -93,12 +95,29 @@ def set_if_not_none(parent, key, value): if value is not None: setattr(parent, key, value) -def generate_traefik_api_credentials(): + +def load_traefik_api_credentials(): + """Load traefik api secret from a file""" proxy_secret_path = os.path.join(STATE_DIR, 'traefik-api.secret') + if not os.path.exists(proxy_secret_path): + return {} with open(proxy_secret_path,'r') as f: password = f.read() + return { + 'traefik_api': { + 'password': password, + } + } - default['traefik_api']['password'] = password + +def load_secrets(): + """Load any secret values stored on disk + + Returns dict to be merged into config during load + """ + config = {} + config = _merge_dictionaries(config, load_traefik_api_credentials()) + return config def update_auth(c, config):