diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 0000000..965ce53 --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,108 @@ +""" +Test wrappers in tljw.user module +""" +from tljh import user +import os +import uuid +import pwd +import grp +import pytest + + +def test_ensure_user(): + """ + Test user creation & removal + """ + # Use a prefix to make sure we never start with a number + username = 'u' + str(uuid.uuid4())[:8] + # Validate that no user exists + with pytest.raises(KeyError): + pwd.getpwnam(username) + + try: + # Create user! + user.ensure_user(username) + # This raises exception if user doesn't exist + ent = pwd.getpwnam(username) + # Home directory must also exist + assert os.path.exists(ent.pw_shell) + # Run ensure_user again, should be a noop + user.ensure_user(username) + # User still exists, after our second ensure_user call + pwd.getpwnam(username) + finally: + # We clean up and remove user! + user.remove_user(username) + with pytest.raises(KeyError): + pwd.getpwnam(username) + + +def test_ensure_group(): + """ + Test group creation & removal + """ + # Use a prefix to make sure we never start with a number + groupname = 'g' + str(uuid.uuid4())[:8] + + # Validate that no group exists + with pytest.raises(KeyError): + grp.getgrnam(groupname) + + try: + # Create group + user.ensure_group(groupname) + # This raises if group doesn't exist + grp.getgrnam(groupname) + + # Do it again, this should be a noop + user.ensure_group(groupname) + grp.getgrnam(groupname) + finally: + # Remove the group + user.remove_group(groupname) + with pytest.raises(KeyError): + grp.getgrnam(groupname) + + +def test_group_membership(): + """ + Test group memberships can be added / removed + """ + username = 'u' + str(uuid.uuid4())[:8] + groupname = 'g' + str(uuid.uuid4())[:8] + + # Validate that no group exists + with pytest.raises(KeyError): + grp.getgrnam(groupname) + with pytest.raises(KeyError): + pwd.getpwnam(username) + + try: + user.ensure_group(groupname) + user.ensure_user(username) + + user.ensure_user_group(username, groupname) + + assert username in grp.getgrnam(groupname).gr_mem + + # Do it again, this should be a noop + user.ensure_user_group(username, groupname) + + assert username in grp.getgrnam(groupname).gr_mem + + # Remove it + user.remove_user_group(username, groupname) + assert username not in grp.getgrnam(groupname).gr_mem + + # Do it again, this should be a noop + user.remove_user_group(username, groupname) + assert username not in grp.getgrnam(groupname).gr_mem + finally: + # Remove the group + user.remove_user(username) + user.remove_group(groupname) + + with pytest.raises(KeyError): + grp.getgrnam(groupname) + with pytest.raises(KeyError): + pwd.getpwnam(username) diff --git a/tljh/installer.py b/tljh/installer.py index 070b7b8..4029ba1 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -2,6 +2,7 @@ import sys import os import tljh.systemd as systemd import tljh.conda as conda +from tljh import user INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh') HUB_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'hub') @@ -17,7 +18,7 @@ def ensure_jupyterhub_service(prefix): unit = unit_template.format( python_interpreter_path=sys.executable, jupyterhub_config_path=os.path.join(HERE, 'jupyterhub_config.py'), - prefix=prefix + install_prefix=INSTALL_PREFIX ) systemd.install_unit('jupyterhub.service', unit) @@ -33,13 +34,20 @@ def ensure_jupyterhub_package(prefix): conda.ensure_conda_packages(prefix, ['jupyterhub==0.9.0']) conda.ensure_pip_packages(prefix, [ 'jupyterhub-dummyauthenticator==0.3.1', - 'jupyterhub-systemdspawner==0.9.12' + 'jupyterhub-systemdspawner==0.9.12', + 'escapism' ]) ensure_jupyterhub_package(HUB_ENV_PREFIX) ensure_jupyterhub_service(HUB_ENV_PREFIX) +user.ensure_group('jupyterhub-admins') +user.ensure_group('jupyterhub-users') + +with open('/etc/sudoers.d/jupyterhub-admins', 'w') as f: + f.write('%jupyterhub-admins ALL = (ALL) NOPASSWD: ALL') + conda.ensure_conda_env(USER_ENV_PREFIX) conda.ensure_conda_packages(USER_ENV_PREFIX, [ 'jupyterhub==0.9.0', diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index a9ca75b..ff08f43 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -1,26 +1,29 @@ """ JupyterHub config for the littlest jupyterhub. - -This is run on startup & restarts. This file has the following -responsibilities: - -1. Set up & maintain user conda environment -2. Configure JupyterHub from YAML file - -This code will run as an unprivileged user, but with unlimited -sudo access. Code here can block, since it all runs before JupyterHub -starts. """ -from tljh import conda +from escapism import escape import os +from systemdspawner import SystemdSpawner +from tljh import user -INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX', '/opt/tljh') +INSTALL_PREFIX = os.environ.get('TLJH_INSTALL_PREFIX') USER_ENV_PREFIX = os.path.join(INSTALL_PREFIX, 'user') -c.JupyterHub.spawner_class = 'systemdspawner.SystemdSpawner' +class CustomSpawner(SystemdSpawner): + def start(self): + """ + Perform system user activities before starting server + """ + # FIXME: Move this elsewhere? Into the Authenticator? + user.ensure_user(self.user.name) + user.ensure_user_group(self.user.name, 'jupyterhub-users') + if self.user.admin: + user.ensure_user_group(self.user.name, 'jupyterhub-admins') + return super().start() + + +c.JupyterHub.spawner_class = CustomSpawner c.JupyterHub.authenticator_class = 'dummyauthenticator.DummyAuthenticator' c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, 'bin')] c.SystemdSpawner.use_sudo = True - -c.SystemdSpawner.dynamic_users = True diff --git a/tljh/systemd-units/jupyterhub.service b/tljh/systemd-units/jupyterhub.service index 232fc1e..f4f5f47 100644 --- a/tljh/systemd-units/jupyterhub.service +++ b/tljh/systemd-units/jupyterhub.service @@ -7,6 +7,7 @@ Wants=network-online.target [Service] User=root Restart=always +Environment=TLJH_INSTALL_PREFIX={install_prefix} StateDirectory=jupyterhub WorkingDirectory=/var/lib/jupyterhub ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path} diff --git a/tljh/user.py b/tljh/user.py new file mode 100644 index 0000000..857be2f --- /dev/null +++ b/tljh/user.py @@ -0,0 +1,123 @@ +""" +User management for tljh. + +Supports user creation, deletion & sudo +""" +import pwd +import grp +import subprocess + + +def ensure_user(username): + """ + Make sure a given user exists + """ + # Check if user exists + try: + pwd.getpwnam(username) + # User exists, nothing to do! + return + except KeyError: + # User doesn't exist, time to create! + pass + + subprocess.check_call([ + 'sudo', + 'adduser', + '--disabled-password', + '--force-badname', + '--quiet', + username + ]) + + +def remove_user(username): + """ + Remove user from system if exists + """ + try: + pwd.getpwnam(username) + except KeyError: + # User doesn't exist, nothing to do + return + + subprocess.check_call([ + 'sudo', + 'deluser', + '--quiet', + username + ]) + + +def ensure_group(groupname): + """ + Ensure given group exists + """ + try: + grp.getgrnam(groupname) + # Group exists, nothing to do! + return + except KeyError: + pass + + subprocess.check_call([ + 'sudo', + 'addgroup', + '--quiet', + groupname + ]) + + +def remove_group(groupname): + """ + Remove user from system if exists + """ + try: + grp.getgrnam(groupname) + except KeyError: + # Group doesn't exist, nothing to do + return + + subprocess.check_call([ + 'sudo', + 'delgroup', + '--quiet', + groupname + ]) + + +def ensure_user_group(username, groupname): + """ + Ensure given user is member of given group + + Group and User must already exist. + """ + group = grp.getgrnam(groupname) + if username in group.gr_mem: + return + + subprocess.check_call([ + 'sudo', + 'usermod', + '--append', + '--groups', + groupname, + username + ]) + + +def remove_user_group(username, groupname): + """ + Ensure given user is *not* a member of given group + """ + group = grp.getgrnam(groupname) + if username not in group.gr_mem: + return + + subprocess.check_call([ + 'sudo', + 'deluser', + '--quiet', + username, + groupname + ])