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/dev-requirements.txt b/dev-requirements.txt index fd93238..166dd49 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,4 @@ pytest pytest-cov codecov -pytoml +pytoml \ No newline at end of file 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/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/setup.py b/setup.py index 7dd7d81..61f8ead 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.*' ], entry_points={ 'console_scripts': [ diff --git a/tests/test_config.py b/tests/test_config.py index f84582a..ce61330 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,16 +117,21 @@ 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.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('configurable-http-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')) @@ -140,8 +146,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_configurer.py b/tests/test_configurer.py index a471098..98dd106 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,6 +163,43 @@ def test_auth_github(): assert c.GitHubOAuthenticator.client_secret == 'something-else' +def test_traefik_api_default(): + """ + Test default traefik api authentication settings with no overrides + """ + c = apply_mock_config({}) + + assert c.TraefikTomlProxy.traefik_api_username == 'api_admin' + assert len(c.TraefikTomlProxy.traefik_api_password) == 0 + + +def test_set_traefik_api(): + """ + Test setting per traefik api credentials + """ + c = apply_mock_config({ + '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" + + def test_auth_native(): """ Test setting Native Authenticator @@ -175,3 +214,4 @@ def test_auth_native(): }) assert c.JupyterHub.authenticator_class == 'nativeauthenticator.NativeAuthenticator' assert c.NativeAuthenticator.open_signup == True + diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 0d811ec..4ef9065 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -1,5 +1,6 @@ """Test traefik configuration""" import os +from unittest import mock import pytoml as toml @@ -27,12 +28,19 @@ 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 len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1 + # runtime generated entry, value not testable + cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""] + + assert cfg["entryPoints"] == { + "http": {"address": ":80"}, + "auth_api": { + "address": "127.0.0.1:8099", + "auth": { + "basic": {"users": [""]} + }, + "whiteList": {"sourceRange": ["127.0.0.1"]} + }, } @@ -55,9 +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 + # 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", "backend": "jupyterhub", "tls": {}}, + "https": {"address": ":443", "tls": {}}, + "auth_api": { + "address": "127.0.0.1:8099", + "auth": { + "basic": {"users": [""]} + }, + "whiteList": {"sourceRange": ["127.0.0.1"]} + }, } assert cfg["acme"] == { "email": "fake@jupyter.org", @@ -83,15 +102,24 @@ 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 + # 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", - "backend": "jupyterhub", "tls": { "certificates": [ {"certFile": "/path/to/ssl.cert", "keyFile": "/path/to/ssl.key"} ] }, }, + "auth_api": { + "address": "127.0.0.1:8099", + "auth": { + "basic": {"users": [""]} + }, + "whiteList": {"sourceRange": ["127.0.0.1"]} + }, } diff --git a/tljh/config.py b/tljh/config.py index 9c862d5..39edbd3 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -13,11 +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 time + +import requests from .yaml import yaml @@ -85,7 +89,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('.') @@ -172,6 +176,12 @@ def remove_config_value(config_path, key_path, value): with open(config_path, 'w') as f: yaml.dump(config, f) +def check_hub_ready(): + try: + r = requests.get('http://127.0.0.1:80', verify=False) + return r.status_code == 200 + except: + return False def reload_component(component): """ @@ -181,14 +191,20 @@ def reload_component(component): """ # import here to avoid circular imports 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'): + 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('configurable-http-proxy') 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..491a5f8 100644 --- a/tljh/configurer.py +++ b/tljh/configurer.py @@ -10,7 +10,7 @@ FIXME: A strong feeling that JSON Schema should be involved somehow. import os -from .config import CONFIG_FILE +from .config import CONFIG_FILE, STATE_DIR from .yaml import yaml # Default configuration for tljh @@ -46,12 +46,17 @@ default = { 'domains': [], }, }, + 'traefik_api': { + 'ip': "127.0.0.1", + 'port': 8099, + 'username': 'api_admin', + 'password': '', + }, 'user_environment': { 'default_app': 'classic', }, } - def load_config(config_file=CONFIG_FILE): """Load the current config as a dictionary @@ -62,7 +67,11 @@ def load_config(config_file=CONFIG_FILE): config_overrides = yaml.load(f) else: config_overrides = {} - 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): @@ -76,6 +85,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_traefik_api(c, tljh_config) def set_if_not_none(parent, key, value): @@ -86,6 +96,30 @@ def set_if_not_none(parent, key, value): setattr(parent, key, value) +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, + } + } + + +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): """ Set auth related configuration from YAML config file @@ -149,6 +183,14 @@ def update_user_account_config(c, config): c.SystemdSpawner.username_template = 'jupyter-{USERNAME}' +def update_traefik_api(c, config): + """ + Set traefik api endpoint credentials + """ + 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): """ Merge two dictionaries recursively. diff --git a/tljh/installer.py b/tljh/installer.py index af6adb8..3c8f222 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("/etc/systemd/system/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,15 +127,22 @@ 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: - proxy_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( @@ -133,28 +150,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 + # Mark JupyterHub & traefik to start at boot time systemd.enable_service('jupyterhub') - systemd.enable_service('configurable-http-proxy') systemd.enable_service('traefik') @@ -276,7 +281,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 @@ -433,7 +438,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..ff1a34a 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,21 +43,19 @@ 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 + +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')] 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 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..e766e20 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 @@ -16,8 +16,6 @@ PrivateTmp=yes 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..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. @@ -70,3 +81,45 @@ def enable_service(name): 'enable', 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) + """ + try: + subprocess.run([ + 'systemctl', + 'is-active', + name + ], check=True) + 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 diff --git a/tljh/traefik.py b/tljh/traefik.py index 6fee96f..4581953 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -4,16 +4,17 @@ import os from urllib.request import urlretrieve from jinja2 import Template +from passlib.apache import HtpasswdFile 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" } @@ -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) @@ -75,9 +90,12 @@ 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(), 0o600) + # 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..2b88b77 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,19 @@ idleTimeout = "10m0s" keyFile = "{{https['tls']['key']}}" {% endif %} {% endif %} + [entryPoints.auth_api] + address = "127.0.0.1:{{traefik_api['port']}}" + [entryPoints.auth_api.whiteList] + sourceRange = ['{{traefik_api['ip']}}'] + [entryPoints.auth_api.auth.basic] + users = ['{{ traefik_api['basic_auth'] }}'] + +[wss] +protocol = "http" + +[api] +dashboard = true +entrypoint = "auth_api" {% if https['enabled'] and https['letsencrypt']['email'] %} [acme] @@ -57,13 +69,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 = "rules.toml" +watch = true