cloud-init-dev team mailing list archive
-
cloud-init-dev team
-
Mailing list archive
-
Message #05443
[Merge] ~chad.smith/cloud-init:feature/ssh-redirect-user into cloud-init:master
Chad Smith has proposed merging ~chad.smith/cloud-init:feature/ssh-redirect-user into cloud-init:master.
Commit message:
config: disable ssh access to a configured user account
Vendor-data or user-data can now disable ssh access to a non-root user
which cloud-init creates.
The only difference is configuration now allows disabling a specific
non-root user.
When defining the 'users' list in cloud-configuration a boolean
'ssh_redirect_user: true' can be provided for any user will disable ssh
logins for that specific user. Any public-ssh-keys defined in cloud
meta-data will be added as authorized_keys which will be in a disabled
state preventing successful ssh login. Any attempts to ssh as this user
using acceptable ssh keys will be greeted with a message like the
following:
Please login as the user "ubuntu" rather than the user "cantlogin".
This behavior is equivalent to the existing disable_root config option for
non-root users.
Requested reviews:
cloud-init commiters (cloud-init-dev)
For more details, see:
https://code.launchpad.net/~chad.smith/cloud-init/+git/cloud-init/+merge/354496
--
Your team cloud-init commiters is requested to review the proposed merge of ~chad.smith/cloud-init:feature/ssh-redirect-user into cloud-init:master.
diff --git a/cloudinit/config/cc_ssh.py b/cloudinit/config/cc_ssh.py
index 45204a0..f8f7cb3 100755
--- a/cloudinit/config/cc_ssh.py
+++ b/cloudinit/config/cc_ssh.py
@@ -101,10 +101,6 @@ from cloudinit.distros import ug_util
from cloudinit import ssh_util
from cloudinit import util
-DISABLE_ROOT_OPTS = (
- "no-port-forwarding,no-agent-forwarding,"
- "no-X11-forwarding,command=\"echo \'Please login as the user \\\"$USER\\\""
- " rather than the user \\\"root\\\".\';echo;sleep 10\"")
GENERATE_KEY_NAMES = ['rsa', 'dsa', 'ecdsa', 'ed25519']
KEY_FILE_TPL = '/etc/ssh/ssh_host_%s_key'
@@ -185,7 +181,7 @@ def handle(_name, cfg, cloud, log, _args):
(user, _user_config) = ug_util.extract_default(users)
disable_root = util.get_cfg_option_bool(cfg, "disable_root", True)
disable_root_opts = util.get_cfg_option_str(cfg, "disable_root_opts",
- DISABLE_ROOT_OPTS)
+ ssh_util.DISABLE_USER_OPTS)
keys = cloud.get_public_ssh_keys() or []
if "ssh_authorized_keys" in cfg:
@@ -207,6 +203,7 @@ def apply_credentials(keys, user, disable_root, disable_root_opts):
if not user:
user = "NONE"
key_prefix = disable_root_opts.replace('$USER', user)
+ key_prefix = key_prefix.replace('$DISABLE_USER', 'root')
else:
key_prefix = ''
diff --git a/cloudinit/config/cc_users_groups.py b/cloudinit/config/cc_users_groups.py
index c95bdaa..9847e79 100644
--- a/cloudinit/config/cc_users_groups.py
+++ b/cloudinit/config/cc_users_groups.py
@@ -52,8 +52,15 @@ config keys for an entry in ``users`` are as follows:
associated with the address, username and SSH keys will be requested from
there. Default: none
- ``ssh_authorized_keys``: Optional. List of ssh keys to add to user's
- authkeys file. Default: none
- - ``ssh_import_id``: Optional. SSH id to import for user. Default: none
+ authkeys file. Default: none. These explicit keys are not affected by
+ ``ssh_redirect_user`` settings.
+ - ``ssh_import_id``: Optional. SSH id to import for user. Default: none.
+ These explicit keys are not afffected by ``ssh_redirect_user`` settings.
+ - ``ssh_redirect_user``: Optional. Boolean set to true to disable SSH
+ logins for this user. When specified, all cloud meta-data public ssh
+ keys will be set up in a disabled state for this username. Any ssh login
+ as this username will timeout and prompt with a message to login instead
+ as the configured <default_username> for this instance. Default: false.
- ``sudo``: Optional. Sudo rule to use, list of sudo rules to use or False.
Default: none. An absence of sudo key, or a value of none or false
will result in no sudo rules being written for the user.
@@ -101,6 +108,7 @@ config keys for an entry in ``users`` are as follows:
selinux_user: <selinux username>
shell: <shell path>
snapuser: <email>
+ ssh_redirect_user: <true/false>
ssh_authorized_keys:
- <key>
- <key>
@@ -114,17 +122,40 @@ config keys for an entry in ``users`` are as follows:
# since the module attribute 'distros'
# is a list of distros that are supported, not a sub-module
from cloudinit.distros import ug_util
+from cloudinit import log as logging
+from cloudinit.util import is_true
from cloudinit.settings import PER_INSTANCE
+LOG = logging.getLogger(__name__)
+
frequency = PER_INSTANCE
def handle(name, cfg, cloud, _log, _args):
(users, groups) = ug_util.normalize_users_groups(cfg, cloud.distro)
+ (default_user, _user_config) = ug_util.extract_default(users)
+ cloud_keys = cloud.get_public_ssh_keys() or []
for (name, members) in groups.items():
cloud.distro.create_group(name, members)
for (user, config) in users.items():
+ ssh_redirect_user = config.get("ssh_redirect_user", False)
+ if ssh_redirect_user:
+ config['cloud_public_ssh_keys'] = cloud_keys
+ if is_true(ssh_redirect_user, addons=['default']):
+ if default_user is not None:
+ config['ssh_redirect_user'] = default_user
+ else:
+ LOG.warning(
+ 'Ignoring ssh_redirect_user: %s setting for %s.'
+ ' No default_user defined in cloud configuration.'
+ ' Perhaps missing cloud configuration users: '
+ ' [default, ..].',
+ ssh_redirect_user, user)
+ config.pop('ssh_redirect_user')
+ else:
+ # Undocumented non-<default-user> username/string
+ config['ssh_redirect_user'] = ssh_redirect_user
cloud.distro.create_user(user, **config)
# vi: ts=4 expandtab
diff --git a/cloudinit/config/tests/test_ssh.py b/cloudinit/config/tests/test_ssh.py
index 7441d9e..c8a4271 100644
--- a/cloudinit/config/tests/test_ssh.py
+++ b/cloudinit/config/tests/test_ssh.py
@@ -2,6 +2,7 @@
from cloudinit.config import cc_ssh
+from cloudinit import ssh_util
from cloudinit.tests.helpers import CiTestCase, mock
MODPATH = "cloudinit.config.cc_ssh."
@@ -15,8 +16,7 @@ class TestHandleSsh(CiTestCase):
"""Apply keys for the given user and root."""
keys = ["key1"]
user = "clouduser"
- options = cc_ssh.DISABLE_ROOT_OPTS
- cc_ssh.apply_credentials(keys, user, False, options)
+ cc_ssh.apply_credentials(keys, user, False, ssh_util.DISABLE_USER_OPTS)
self.assertEqual([mock.call(set(keys), user),
mock.call(set(keys), "root", options="")],
m_setup_keys.call_args_list)
@@ -25,8 +25,7 @@ class TestHandleSsh(CiTestCase):
"""Apply keys for root only."""
keys = ["key1"]
user = None
- options = cc_ssh.DISABLE_ROOT_OPTS
- cc_ssh.apply_credentials(keys, user, False, options)
+ cc_ssh.apply_credentials(keys, user, False, ssh_util.DISABLE_USER_OPTS)
self.assertEqual([mock.call(set(keys), "root", options="")],
m_setup_keys.call_args_list)
@@ -34,9 +33,10 @@ class TestHandleSsh(CiTestCase):
"""Apply keys for the given user and disable root ssh."""
keys = ["key1"]
user = "clouduser"
- options = cc_ssh.DISABLE_ROOT_OPTS
+ options = ssh_util.DISABLE_USER_OPTS
cc_ssh.apply_credentials(keys, user, True, options)
options = options.replace("$USER", user)
+ options = options.replace("$DISABLE_USER", "root")
self.assertEqual([mock.call(set(keys), user),
mock.call(set(keys), "root", options=options)],
m_setup_keys.call_args_list)
@@ -45,9 +45,10 @@ class TestHandleSsh(CiTestCase):
"""Apply keys no user and disable root ssh."""
keys = ["key1"]
user = None
- options = cc_ssh.DISABLE_ROOT_OPTS
+ options = ssh_util.DISABLE_USER_OPTS
cc_ssh.apply_credentials(keys, user, True, options)
options = options.replace("$USER", "NONE")
+ options = options.replace("$DISABLE_USER", "root")
self.assertEqual([mock.call(set(keys), "root", options=options)],
m_setup_keys.call_args_list)
@@ -66,7 +67,8 @@ class TestHandleSsh(CiTestCase):
cloud = self.tmp_cloud(
distro='ubuntu', metadata={'public-keys': keys})
cc_ssh.handle("name", cfg, cloud, None, None)
- options = cc_ssh.DISABLE_ROOT_OPTS.replace("$USER", "NONE")
+ options = ssh_util.DISABLE_USER_OPTS.replace("$USER", "NONE")
+ options = options.replace("$DISABLE_USER", "root")
m_glob.assert_called_once_with('/etc/ssh/ssh_host_*key*')
self.assertIn(
[mock.call('/etc/ssh/ssh_host_rsa_key'),
@@ -94,7 +96,8 @@ class TestHandleSsh(CiTestCase):
distro='ubuntu', metadata={'public-keys': keys})
cc_ssh.handle("name", cfg, cloud, None, None)
- options = cc_ssh.DISABLE_ROOT_OPTS.replace("$USER", user)
+ options = ssh_util.DISABLE_USER_OPTS.replace("$USER", user)
+ options = options.replace("$DISABLE_USER", "root")
self.assertEqual([mock.call(set(keys), user),
mock.call(set(keys), "root", options=options)],
m_setup_keys.call_args_list)
@@ -118,7 +121,8 @@ class TestHandleSsh(CiTestCase):
distro='ubuntu', metadata={'public-keys': keys})
cc_ssh.handle("name", cfg, cloud, None, None)
- options = cc_ssh.DISABLE_ROOT_OPTS.replace("$USER", user)
+ options = ssh_util.DISABLE_USER_OPTS.replace("$USER", user)
+ options = options.replace("$DISABLE_USER", "root")
self.assertEqual([mock.call(set(keys), user),
mock.call(set(keys), "root", options=options)],
m_setup_keys.call_args_list)
diff --git a/cloudinit/config/tests/test_users_groups.py b/cloudinit/config/tests/test_users_groups.py
new file mode 100644
index 0000000..8d97a3a
--- /dev/null
+++ b/cloudinit/config/tests/test_users_groups.py
@@ -0,0 +1,144 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+
+from cloudinit.config import cc_users_groups
+from cloudinit.tests.helpers import CiTestCase, mock
+
+MODPATH = "cloudinit.config.cc_users_groups"
+
+
+@mock.patch('cloudinit.distros.ubuntu.Distro.create_group')
+@mock.patch('cloudinit.distros.ubuntu.Distro.create_user')
+class TestHandleUsersGroups(CiTestCase):
+ """Test cc_users_groups handling of config."""
+
+ with_logs = True
+
+ def test_handle_no_cfg_creates_no_users_or_groups(self, m_user, m_group):
+ """Test handle with no config will not create users or groups."""
+ cfg = {} # merged cloud-config
+ # System config defines a default user for the distro.
+ sys_cfg = {'default_user': {'name': 'ubuntu', 'lock_passwd': True,
+ 'groups': ['lxd', 'sudo'],
+ 'shell': '/bin/bash'}}
+ metadata = {}
+ cloud = self.tmp_cloud(
+ distro='ubuntu', sys_cfg=sys_cfg, metadata=metadata)
+ cc_users_groups.handle('modulename', cfg, cloud, None, None)
+ m_user.assert_not_called()
+ m_group.assert_not_called()
+
+ def test_handle_users_in_cfg_calls_create_users(self, m_user, m_group):
+ """When users in config, create users with distro.create_user."""
+ cfg = {'users': ['default', {'name': 'me2'}]} # merged cloud-config
+ # System config defines a default user for the distro.
+ sys_cfg = {'default_user': {'name': 'ubuntu', 'lock_passwd': True,
+ 'groups': ['lxd', 'sudo'],
+ 'shell': '/bin/bash'}}
+ metadata = {}
+ cloud = self.tmp_cloud(
+ distro='ubuntu', sys_cfg=sys_cfg, metadata=metadata)
+ cc_users_groups.handle('modulename', cfg, cloud, None, None)
+ m_user.assert_has_calls([
+ mock.call('ubuntu', groups='lxd,sudo', lock_passwd=True,
+ shell='/bin/bash'),
+ mock.call('me2', default=False)])
+ m_group.assert_not_called()
+
+ def test_users_with_ssh_redirect_user_passes_keys(self, m_user, m_group):
+ """When ssh_redirect_user is True pass default user and cloud keys."""
+ cfg = {
+ 'users': ['default', {'name': 'me2', 'ssh_redirect_user': True}]}
+ # System config defines a default user for the distro.
+ sys_cfg = {'default_user': {'name': 'ubuntu', 'lock_passwd': True,
+ 'groups': ['lxd', 'sudo'],
+ 'shell': '/bin/bash'}}
+ metadata = {'public-keys': ['key1']}
+ cloud = self.tmp_cloud(
+ distro='ubuntu', sys_cfg=sys_cfg, metadata=metadata)
+ cc_users_groups.handle('modulename', cfg, cloud, None, None)
+ m_user.assert_has_calls([
+ mock.call('ubuntu', groups='lxd,sudo', lock_passwd=True,
+ shell='/bin/bash'),
+ mock.call(
+ 'me2', cloud_public_ssh_keys=['key1'], default=False,
+ ssh_redirect_user='ubuntu')])
+ m_group.assert_not_called()
+
+ def test_users_with_ssh_redirect_user_default_str(self, m_user, m_group):
+ """When ssh_redirect_user is 'default' pass default username."""
+ cfg = {
+ 'users': ['default', {'name': 'me2',
+ 'ssh_redirect_user': 'default'}]}
+ # System config defines a default user for the distro.
+ sys_cfg = {'default_user': {'name': 'ubuntu', 'lock_passwd': True,
+ 'groups': ['lxd', 'sudo'],
+ 'shell': '/bin/bash'}}
+ metadata = {'public-keys': ['key1']}
+ cloud = self.tmp_cloud(
+ distro='ubuntu', sys_cfg=sys_cfg, metadata=metadata)
+ cc_users_groups.handle('modulename', cfg, cloud, None, None)
+ m_user.assert_has_calls([
+ mock.call('ubuntu', groups='lxd,sudo', lock_passwd=True,
+ shell='/bin/bash'),
+ mock.call(
+ 'me2', cloud_public_ssh_keys=['key1'], default=False,
+ ssh_redirect_user='ubuntu')])
+ m_group.assert_not_called()
+
+ def test_users_with_ssh_redirect_user_non_default(self, m_user, m_group):
+ """When ssh_redirect_user is not 'default' pass that username."""
+ cfg = {
+ 'users': ['default', {'name': 'me2',
+ 'ssh_redirect_user': 'snowflake'}]}
+ # System config defines a default user for the distro.
+ sys_cfg = {'default_user': {'name': 'ubuntu', 'lock_passwd': True,
+ 'groups': ['lxd', 'sudo'],
+ 'shell': '/bin/bash'}}
+ metadata = {'public-keys': ['key1']}
+ cloud = self.tmp_cloud(
+ distro='ubuntu', sys_cfg=sys_cfg, metadata=metadata)
+ cc_users_groups.handle('modulename', cfg, cloud, None, None)
+ m_user.assert_has_calls([
+ mock.call('ubuntu', groups='lxd,sudo', lock_passwd=True,
+ shell='/bin/bash'),
+ mock.call(
+ 'me2', cloud_public_ssh_keys=['key1'], default=False,
+ ssh_redirect_user='snowflake')])
+ m_group.assert_not_called()
+
+ def test_users_with_ssh_redirect_user_default_false(self, m_user, m_group):
+ """When unspecified ssh_redirect_user is false and not set up."""
+ cfg = {'users': ['default', {'name': 'me2'}]}
+ # System config defines a default user for the distro.
+ sys_cfg = {'default_user': {'name': 'ubuntu', 'lock_passwd': True,
+ 'groups': ['lxd', 'sudo'],
+ 'shell': '/bin/bash'}}
+ metadata = {'public-keys': ['key1']}
+ cloud = self.tmp_cloud(
+ distro='ubuntu', sys_cfg=sys_cfg, metadata=metadata)
+ cc_users_groups.handle('modulename', cfg, cloud, None, None)
+ m_user.assert_has_calls([
+ mock.call('ubuntu', groups='lxd,sudo', lock_passwd=True,
+ shell='/bin/bash'),
+ mock.call('me2', default=False)])
+ m_group.assert_not_called()
+
+ def test_users_ssh_redirect_user_and_no_default(self, m_user, m_group):
+ """Warn when ssh_redirect_user is True and no default user present."""
+ cfg = {
+ 'users': ['default', {'name': 'me2', 'ssh_redirect_user': True}]}
+ # System config defines *no* default user for the distro.
+ sys_cfg = {}
+ metadata = {} # no public-keys defined
+ cloud = self.tmp_cloud(
+ distro='ubuntu', sys_cfg=sys_cfg, metadata=metadata)
+ cc_users_groups.handle('modulename', cfg, cloud, None, None)
+ m_user.assert_called_once_with(
+ 'me2', cloud_public_ssh_keys=[], default=False)
+ m_group.assert_not_called()
+ self.assertEqual(
+ 'WARNING: Ignoring ssh_redirect_user: True setting for me2. No'
+ ' default_user defined in cloud configuration. Perhaps missing'
+ ' cloud configuration users: [default, ..].\n',
+ self.logs.getvalue())
diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
old mode 100755
new mode 100644
index d9101ce..d12cb9a
--- a/cloudinit/distros/__init__.py
+++ b/cloudinit/distros/__init__.py
@@ -381,6 +381,9 @@ class Distro(object):
"""
Add a user to the system using standard GNU tools
"""
+ # XXX need to make add_user idempotent somehow as we
+ # still want to add groups or modify ssh keys on pre-existing
+ # users in the image.
if util.is_user(name):
LOG.info("User %s already exists, skipping.", name)
return
@@ -549,8 +552,21 @@ class Distro(object):
" string, dict, or set.", type(keys))
else:
keys = set(keys) or []
- ssh_util.setup_user_keys(keys, name, options=None)
-
+ ssh_util.setup_user_keys(set(keys), name)
+ if 'ssh_redirect_user' in kwargs:
+ cloud_keys = kwargs.get('cloud_public_ssh_keys', [])
+ if not cloud_keys:
+ LOG.warning(
+ 'Unable to disable ssh logins for %s given'
+ 'ssh_redirect_user: %s. No cloud public-ssh-keys present.',
+ name, kwargs['ssh_redirect_user'])
+ else:
+ redirect_user = kwargs['ssh_redirect_user']
+ disable_option = ssh_util.DISABLE_USER_OPTS
+ disable_option = disable_option.replace('$USER', redirect_user)
+ disable_option = disable_option.replace('$DISABLE_USER', name)
+ ssh_util.setup_user_keys(
+ set(cloud_keys), name, options=disable_option)
return True
def lock_passwd(self, name):
diff --git a/cloudinit/ssh_util.py b/cloudinit/ssh_util.py
index 73c3177..3f99b58 100644
--- a/cloudinit/ssh_util.py
+++ b/cloudinit/ssh_util.py
@@ -41,6 +41,12 @@ VALID_KEY_TYPES = (
)
+DISABLE_USER_OPTS = (
+ "no-port-forwarding,no-agent-forwarding,"
+ "no-X11-forwarding,command=\"echo \'Please login as the user \\\"$USER\\\""
+ " rather than the user \\\"$DISABLE_USER\\\".\';echo;sleep 10\"")
+
+
class AuthKeyLine(object):
def __init__(self, source, keytype=None, base64=None,
comment=None, options=None):
diff --git a/doc/examples/cloud-config-user-groups.txt b/doc/examples/cloud-config-user-groups.txt
index 01ecad7..6064fa1 100644
--- a/doc/examples/cloud-config-user-groups.txt
+++ b/doc/examples/cloud-config-user-groups.txt
@@ -36,6 +36,8 @@ users:
- <ssh pub key 1>
- <ssh pub key 2>
- snapuser: joe@xxxxxxxxxx
+ - name: nosshlogins
+ ssh_redirect_user: true
# Valid Values:
# name: The user's login name
@@ -76,6 +78,11 @@ users:
# no_log_init: When set to true, do not initialize lastlog and faillog database.
# ssh_import_id: Optional. Import SSH ids
# ssh_authorized_keys: Optional. [list] Add keys to user's authorized keys file
+# ssh_redirect_user: Optional. [bool] Set true to block ssh logins for cloud
+# ssh public keys and emit a message redirecting logins to
+# use <default_username> instead. This option only disables cloud
+# provided public-keys and does not disable explicit ssh_import_id or
+# ssh_authorized_keys.
# sudo: Defaults to none. Accepts a sudo rule string, a list of sudo rule
# strings or False to explicitly deny sudo usage. Examples:
#
diff --git a/doc/examples/cloud-config.txt b/doc/examples/cloud-config.txt
index 774f66b..5978a9a 100644
--- a/doc/examples/cloud-config.txt
+++ b/doc/examples/cloud-config.txt
@@ -232,9 +232,20 @@ disable_root: false
# respective key in /root/.ssh/authorized_keys if disable_root is true
# see 'man authorized_keys' for more information on what you can do here
#
-# The string '$USER' will be replaced with the username of the default user
-#
-# disable_root_opts: no-port-forwarding,no-agent-forwarding,no-X11-forwarding,command="echo 'Please login as the user \"$USER\" rather than the user \"root\".';echo;sleep 10"
+# The string '$USER' will be replaced with the username of the default user.
+# The string '$DISABLE_USER' will be replaced with the username to disable.
+#
+# disable_root_opts: no-port-forwarding,no-agent-forwarding,no-X11-forwarding,command="echo 'Please login as the user \"$USER\" rather than the user \"$DISABLE_USER\".';echo;sleep 10"
+
+# disable ssh access to non-root-users
+# To disable ssh access for non-root users, ssh_redirect_user: true can be
+# provided for any use in the 'users' list. This will prompt any ssh login
+# attempts as that user with a message like that in disable_root_opts which
+# redirects the person to login as <default_username>
+users:
+ - default
+ - name: blockeduser
+ ssh_redirect_user: true
# set the locale to a given locale
Follow ups