From 24b535d524618ad4b9f5780433d1e1161a9c9037 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 17 Jul 2018 08:36:06 -0700 Subject: [PATCH 1/4] move proxy secret to state dir --- tljh/installer.py | 8 +++++--- tljh/systemd-units/configurable-http-proxy.service | 2 +- tljh/systemd-units/jupyterhub.service | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index 324cbb9..a68652c 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -14,6 +14,8 @@ INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh') HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub') USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user') +STATE_DIR = os.path.join(HUB_ENV_PREFIX, 'state') + HERE = os.path.abspath(os.path.dirname(__file__)) rt_yaml = YAML() @@ -38,16 +40,16 @@ def ensure_jupyterhub_service(prefix): systemd.install_unit('jupyterhub.service', hub_unit_template.format(**unit_params)) systemd.reload_daemon() + os.makedirs(STATE_DIR, mode=0o700, exist_ok=True) + # Set up proxy / hub secret oken if it is not already setup - # FIXME: Check umask here properly - proxy_secret_path = os.path.join(INSTALL_PREFIX, 'configurable-http-proxy.secret') + 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') - os.makedirs(os.path.join(INSTALL_PREFIX, 'hub', 'state'), mode=0o700, exist_ok=True) # Start CHP if it has already not been started systemd.start_service('configurable-http-proxy') # If JupyterHub is running, we want to restart it. diff --git a/tljh/systemd-units/configurable-http-proxy.service b/tljh/systemd-units/configurable-http-proxy.service index 808bb8b..483028a 100644 --- a/tljh/systemd-units/configurable-http-proxy.service +++ b/tljh/systemd-units/configurable-http-proxy.service @@ -14,7 +14,7 @@ PrivateTmp=yes PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes -EnvironmentFile={install_prefix}/configurable-http-proxy.secret +EnvironmentFile={install_prefix}/hub/state/configurable-http-proxy.secret # Set PATH so env can find correct node Environment=PATH=$PATH:{install_prefix}/hub/bin ExecStart={install_prefix}/hub/bin/configurable-http-proxy \ diff --git a/tljh/systemd-units/jupyterhub.service b/tljh/systemd-units/jupyterhub.service index b7fbcb2..a653592 100644 --- a/tljh/systemd-units/jupyterhub.service +++ b/tljh/systemd-units/jupyterhub.service @@ -17,7 +17,7 @@ PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes # Source CONFIGPROXY_AUTH_TOKEN from here! -EnvironmentFile={install_prefix}/configurable-http-proxy.secret +EnvironmentFile={install_prefix}/hub/state/configurable-http-proxy.secret Environment=TLJH_INSTALL_PREFIX={install_prefix} ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path} From 00797b404c990e4e46a67aca06dd37e0b6c57643 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Jul 2018 23:15:36 -0700 Subject: [PATCH 2/4] move state outside envs so it can be managed separately more easily --- tljh/installer.py | 3 +-- tljh/systemd-units/configurable-http-proxy.service | 2 +- tljh/systemd-units/jupyterhub.service | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tljh/installer.py b/tljh/installer.py index a68652c..15ab952 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -13,8 +13,7 @@ from ruamel.yaml import YAML INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh') HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub') USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user') - -STATE_DIR = os.path.join(HUB_ENV_PREFIX, 'state') +STATE_DIR = os.path.join(INSTALL_PREFIX, 'state') HERE = os.path.abspath(os.path.dirname(__file__)) diff --git a/tljh/systemd-units/configurable-http-proxy.service b/tljh/systemd-units/configurable-http-proxy.service index 483028a..2b983e6 100644 --- a/tljh/systemd-units/configurable-http-proxy.service +++ b/tljh/systemd-units/configurable-http-proxy.service @@ -14,7 +14,7 @@ PrivateTmp=yes PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes -EnvironmentFile={install_prefix}/hub/state/configurable-http-proxy.secret +EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret # Set PATH so env can find correct node Environment=PATH=$PATH:{install_prefix}/hub/bin ExecStart={install_prefix}/hub/bin/configurable-http-proxy \ diff --git a/tljh/systemd-units/jupyterhub.service b/tljh/systemd-units/jupyterhub.service index a653592..dd94868 100644 --- a/tljh/systemd-units/jupyterhub.service +++ b/tljh/systemd-units/jupyterhub.service @@ -10,14 +10,14 @@ User=root Restart=always # jupyterhub process should have no access to home directories ProtectHome=tmpfs -WorkingDirectory={install_prefix}/hub/state +WorkingDirectory={install_prefix}/state # Protect bits that are normally shared across the system PrivateTmp=yes PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes # Source CONFIGPROXY_AUTH_TOKEN from here! -EnvironmentFile={install_prefix}/hub/state/configurable-http-proxy.secret +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} From 680206813ff0929f488ffd397c49e03e1694cabe Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Jul 2018 23:56:00 -0700 Subject: [PATCH 3/4] test permissions imported from acl-based tests test for admin-install permissions are included but skipped as xfail --- integration-tests/test_install.py | 206 ++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 integration-tests/test_install.py diff --git a/integration-tests/test_install.py b/integration-tests/test_install.py new file mode 100644 index 0000000..975955e --- /dev/null +++ b/integration-tests/test_install.py @@ -0,0 +1,206 @@ +from contextlib import contextmanager +from concurrent.futures import ProcessPoolExecutor +from functools import partial +import grp +import os +import pwd +import subprocess +import sys + +import pytest + + +ADMIN_GROUP = "jupyterhub-admins" +USER_GROUP = "jupyterhub-users" +INSTALL_PREFIX = os.environ.get("TLJH_INSTALL_PREFIX", "/opt/tljh") +HUB_PREFIX = os.path.join(INSTALL_PREFIX, "hub") +USER_PREFIX = os.path.join(INSTALL_PREFIX, "user") +STATE_DIR = os.path.join(INSTALL_PREFIX, "state") + + +@contextmanager +def noop(): + """no-op context manager + + for parametrized tests + """ + yield + + +def setgroup(group): + """Become the user nobody:group + + Only call in a subprocess because there's no turning back + """ + gid = grp.getgrnam(group).gr_gid + uid = pwd.getpwnam("nobody").pw_uid + os.setgid(gid) + os.setuid(uid) + os.environ["HOME"] = "/tmp/test-home-%i-%i" % (uid, gid) + + +@pytest.mark.parametrize("group", [ADMIN_GROUP, USER_GROUP]) +def test_groups_exist(group): + """Verify that groups exist""" + grp.getgrnam(group) + + +def permissions_test(group, path, *, readable=None, writable=None, dirs_only=False): + """Run a permissions test on all files in a path path""" + # start a subprocess and become nobody:group in the process + pool = ProcessPoolExecutor(1) + pool.submit(setgroup, group) + + def access(path, flag): + """Run access test in subproccess as nobody:group""" + return pool.submit(os.access, path, flag).result() + + total_tested = 0 + + failures = [] + + # walk the directory and check permissions + for root, dirs, files in os.walk(path): + to_test = dirs + if not dirs_only: + to_test += files + total_tested += len(to_test) + for name in to_test: + path = os.path.join(root, name) + if os.path.islink(path): + # skip links + continue + st = os.lstat(path) + try: + user = pwd.getpwuid(st.st_uid).pw_name + except KeyError: + # uid may not exist + user = st.st_uid + try: + groupname = grp.getgrgid(st.st_gid).gr_name + except KeyError: + # gid may not exist + groupname = st.st_gid + stat_str = "{perm:04o} {user} {group}".format( + perm=st.st_mode, user=user, group=groupname + ) + + # check if the path should be writable + if writable is not None: + if access(path, os.W_OK) != writable: + failures.append( + "{} {} should {}be writable by {}".format( + stat_str, path, "" if writable else "not ", group + ) + ) + + # check if the path should be readable + if readable is not None: + if access(path, os.R_OK) != readable: + failures.append( + "{} {} should {}be readable by {}".format( + stat_str, path, "" if readable else "not ", group + ) + ) + # verify that we actually tested some files + # (path typos) + assert total_tested > 0, "No files to test in %r" % path + # raise a nice summary of the failures: + if failures: + if len(failures) > 50: + failures = failures[:32] + ["...%i total" % len(failures)] + assert False, "\n".join(failures) + + +@pytest.mark.xfail(reason="admin-write permissions is not implemented") +def test_admin_writable(): + permissions_test(ADMIN_GROUP, sys.prefix, writable=True, dirs_only=True) + + +@pytest.mark.parametrize("group", [ADMIN_GROUP, USER_GROUP]) +def test_user_env_readable(group): + # every file in user env should be readable by everyone + permissions_test(group, USER_PREFIX, readable=True) + + +def test_nothing_user_writable(): + # nothing in the install directory should be writable by users + permissions_test(USER_GROUP, INSTALL_PREFIX, writable=False) + + +@pytest.mark.parametrize( + "group, readwrite", [(ADMIN_GROUP, False), (USER_GROUP, False)] +) +def test_state_permissions(group, readwrite): + state_dir = os.path.abspath(os.path.join(sys.prefix, os.pardir, "state")) + permissions_test(group, state_dir, writable=readwrite, readable=readwrite) + + +# FIXME: admin-group should have install permissions +@pytest.mark.parametrize( + "group, allowed", + [ + (USER_GROUP, False), + pytest.param( + ADMIN_GROUP, + True, + marks=pytest.mark.xfail(reason="admin-permissions not implemented"), + ), + ], +) +def test_pip_install(group, allowed): + if allowed: + context = noop() + else: + context = pytest.raises(subprocess.CalledProcessError) + + python = os.path.join(USER_PREFIX, "bin", "python") + + with context: + subprocess.check_call( + [python, "-m", "pip", "install", "--ignore-installed", "--no-deps", "flit"], + preexec_fn=partial(setgroup, group), + ) + if allowed: + subprocess.check_call( + [python, "-m", "pip", "uninstall", "-y", "flit"], + preexec_fn=partial(setgroup, group), + ) + + +@pytest.mark.parametrize( + "group, allowed", + [ + (USER_GROUP, False), + pytest.param( + ADMIN_GROUP, + True, + marks=pytest.mark.xfail(reason="admin-permissions not implemented"), + ), + ], +) +def test_pip_upgrade(group, allowed): + if allowed: + context = noop() + pytest.skip("admin-install permissions is not implemented") + else: + context = pytest.raises(subprocess.CalledProcessError) + python = os.path.join(USER_PREFIX, "bin", "python") + with context: + subprocess.check_call( + [ + python, + "-m", + "pip", + "install", + "--ignore-installed", + "--no-deps", + "testpath==0.3.0", + ], + preexec_fn=partial(setgroup, group), + ) + if allowed: + subprocess.check_call( + [python, "-m", "pip", "install", "--upgrade", "testpath"], + preexec_fn=partial(setgroup, group), + ) From bcf31a979c8759a2bc4ed8023fb66a14cd2ea64c Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 19 Jul 2018 00:02:15 -0700 Subject: [PATCH 4/4] fix permissions on initial install running installer as root allows some file ownership from the archive to be set as a user other than root --- bootstrap/bootstrap.py | 7 +++++++ tljh/installer.py | 18 +++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 7334fec..41bb009 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -76,6 +76,13 @@ def install_miniconda(installer_path, prefix): '-u', '-b', '-p', prefix ], stderr=subprocess.STDOUT) + # fix permissions on initial install + # a few files have the wrong ownership and permissions initially + # when the installer is run as root + subprocess.check_call( + ["chown", "-R", "{}:{}".format(os.getuid(), os.getgid()), prefix] + ) + subprocess.check_call(["chmod", "-R", "o-w", prefix]) def pip_install(prefix, packages, editable=False): diff --git a/tljh/installer.py b/tljh/installer.py index 15ab952..1618908 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -1,15 +1,16 @@ -import sys +import argparse import os -import tljh.systemd as systemd -import tljh.conda as conda +import secrets +import subprocess +import sys +import time from urllib.error import HTTPError from urllib.request import urlopen, URLError -from tljh import user -import secrets -import argparse -import time + from ruamel.yaml import YAML +from tljh import conda, systemd, user + INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh') HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub') USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user') @@ -193,6 +194,9 @@ def main(): ensure_usergroups() ensure_user_environment(args.user_requirements_txt_url) + # Weird setuptools issue creates a few world-writable metadata files. + # Fix it: + subprocess.check_call(["chmod", "-R", "o-w", os.path.join(HUB_ENV_PREFIX, "pkgs")]) print("Setting up JupyterHub...") ensure_jupyterhub_package(HUB_ENV_PREFIX)