Repository: jupyterhub/jupyterhub

Issue:
Default shell to bash
Right now, if users are created by jupyterhub with useradd on a stock Ubuntu, there is no default shell. This leads to `/bin/sh` being used rather than bash. When the web terminal is opened all readline related actions (tab, up, down) don't work. I know I can configure this in `/etc/default/useradd`, but we jut added an option to set the home directory base path. Should we default the shell to bash in the useradd CLI call? Maybe add an config option that defaults to `/bin/bash`. This behavior means that a pretty standard config on Ubuntu will have a (slightly) broken terminal.

I could have sworn that we did something on this previously, when I was debugging things with my Active Directory setup where `SHELL` wasn't being set. But I don't remember what we ended up doing (I may have just set it myself).

Thoughts?



Discussion:
I think the previous bug had to do with how the user's shell was looked up, not how it is set initially, so that would have been slightly different, but I'm not 100% sure.

I think the problem may be that we are using `useradd` instead of `adduser`, which has different defaults. When I create a user with `adduser`, the default shell is bash.

For reference, the non-interactive `adduser` call is:

```
adduser -q --gecos "" --disabled-password new2
```

(and totally different on BSD, of course)

We could also add a `useradd_args`, so that all args are exposed. I would prefer not to add a configurable for every option, so a passthrough to useradd (or adduser) would be the easiest way to make sure you can set whatever you need.

Min, thanks for the thoughtful response.

I think the problem may be that we are using useradd instead of adduser,

> which has different defaults. When I create a user with adduser, the
> default shell is bash.
> 
> For reference, the non-interactive adduser call is:
> 
> adduser -q --gecos "" --disabled-password new2
> 
> I had a look at the config for adduser and it does look a bit more sane
> that useradd. It is also equally easy to configure using the config file.
> 
>  (and totally different on BSD, of course)
> 
> We could also add a useradd_args, so that all args are exposed. I would
> prefer not to add a configurable for every option, so a passthrough to
> useradd (or adduser) would be the easiest way to make sure you can set
> whatever you need.
> 
> What about using adduser so the defaults are a bit more sane and then
> adding a adduser_args that is string valued to provide extra flags?

Or, another option that would address the BSD difference would be to have a
single command for user creation that is templated by the username, with a
default like:

'adduser -q --gecos "" --disabled-password {username}'

That would be the most flexible and have pretty sane default behavior. I
almost like that best as it makes it easy to get working for other setups
as well.

> —
> Reply to this email directly or view it on GitHub
> https://github.com/jupyter/jupyterhub/issues/348#issuecomment-163048387.

## 

Brian E. Granger
Associate Professor of Physics and Data Science
Cal Poly State University, San Luis Obispo
@ellisonbg on Twitter and GitHub
bgranger@calpoly.edu and ellisonbg@gmail.com

> What about using adduser so the defaults are a bit more sane and then adding a adduser_args that is string valued to provide extra flags?

I think that's the most sensible. Possibly with an escape hatch for producing the complete command.


Resolving patch:
diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py
--- a/jupyterhub/auth.py
+++ b/jupyterhub/auth.py
@@ -4,8 +4,11 @@
 # Distributed under the terms of the Modified BSD License.
 
 from grp import getgrnam
+import pipes
 import pwd
-from subprocess import check_call, check_output, CalledProcessError
+from shutil import which
+import sys
+from subprocess import check_call
 
 from tornado import gen
 import pamela
@@ -15,6 +18,7 @@
 
 from .handlers.login import LoginHandler
 from .utils import url_path_join
+from .traitlets import Command
 
 class Authenticator(LoggingConfigurable):
     """A class for authentication.
@@ -123,17 +127,36 @@ class LocalAuthenticator(Authenticator):
         should I try to create the system user?
         """
     )
-    system_user_home = Unicode('', config=True,
-        help="""Specify root home directory for users if different from system default.
+    add_user_cmd = Command(config=True,
+        help="""The command to use for creating users as a list of strings.
         
-        Passed to `useradd -b`.
-        If specified, when system users are created their home directories will be created in
+        For each element in the list, the string USERNAME will be replaced with
+        the user's username. The username will also be appended as the final argument.
         
-            system_user_home/username
+        For Linux, the default value is:
         
-        Only has an effect when users are created with `create_system_users=True`.
+            ['adduser', '-q', '--gecos', '""', '--disabled-password']
+            
+        To specify a custom home directory, set this to:
+        
+            ['adduser', '-q', '--gecos', '""', '--home', '/customhome/USERNAME', '--disabled-password']
+
+        This will run the command:
+
+        adduser -q --gecos "" --home /customhome/river --disabled-password river
+        
+        when the user 'river' is created.
         """
     )
+    def _add_user_cmd_default(self):
+        if sys.platform == 'darwin':
+            raise ValueError("I don't know how to create users on OS X")
+        elif which('pw'):
+            # Probably BSD
+            return ['pw', 'useradd', '-m']
+        else:
+            # This appears to be the Linux non-interactive adduser command:
+            return ['adduser', '-q', '--gecos', '""', '--disabled-password']
 
     group_whitelist = Set(
         config=True,
@@ -196,23 +219,9 @@ def system_user_exists(user):
     def add_system_user(self, user):
         """Create a new *ix user on the system. Works on FreeBSD and Linux, at least."""
         name = user.name
-        for useradd in (
-            ['pw', 'useradd', '-m'],
-            ['useradd', '-m'],
-        ):
-            try:
-                check_output(['which', useradd[0]])
-            except CalledProcessError:
-                continue
-            else:
-                break
-        else:
-            raise RuntimeError("I don't know how to add users on this system.")
-        
-        cmd = useradd
-        if self.system_user_home:
-            cmd = cmd + ['-b', self.system_user_home]
-        check_call(cmd + [name])
+        cmd = [ arg.replace('USERNAME', name) for arg in self.add_user_cmd ] + [name]
+        self.log.info("Creating user: %s", ' '.join(map(pipes.quote, cmd)))
+        check_call(cmd)
 
 
 class PAMAuthenticator(LocalAuthenticator):


Test patch:
diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py
--- a/jupyterhub/tests/test_auth.py
+++ b/jupyterhub/tests/test_auth.py
@@ -93,13 +93,14 @@ def test_wont_add_system_user(io_loop):
 def test_cant_add_system_user(io_loop):
     user = orm.User(name='lioness4321')
     authenticator = auth.PAMAuthenticator(whitelist={'mal'})
+    authenticator.add_user_cmd = ['jupyterhub-fake-command']
     authenticator.create_system_users = True
     
-    def check_output(cmd, *a, **kw):
+    def check_call(cmd, *a, **kw):
         raise CalledProcessError(1, cmd)
     
-    with mock.patch.object(auth, 'check_output', check_output):
-        with pytest.raises(RuntimeError):
+    with mock.patch.object(auth, 'check_call', check_call):
+        with pytest.raises(CalledProcessError):
             io_loop.run_sync(lambda : authenticator.add_user(user))
 
 
@@ -107,19 +108,15 @@ def test_add_system_user(io_loop):
     user = orm.User(name='lioness4321')
     authenticator = auth.PAMAuthenticator(whitelist={'mal'})
     authenticator.create_system_users = True
-    
-    def check_output(*a, **kw):
-        return
+    authenticator.add_user_cmd = ['echo', '/home/USERNAME']
     
     record = {}
     def check_call(cmd, *a, **kw):
         record['cmd'] = cmd
     
-    with mock.patch.object(auth, 'check_output', check_output), \
-             mock.patch.object(auth, 'check_call', check_call):
+    with mock.patch.object(auth, 'check_call', check_call):
         io_loop.run_sync(lambda : authenticator.add_user(user))
-    
-    assert user.name in record['cmd']
+    assert record['cmd'] == ['echo', '/home/lioness4321', 'lioness4321']
 
 
 def test_delete_user(io_loop):