Merge ~pzakha/cloud-init:userdata into cloud-init:master

Proposed by Pavel Zakharov
Status: Merged
Approved by: Ryan Harper
Approved revision: 67f7991583a71105e07c7555a4d2e06d51d6bb73
Merge reported by: Server Team CI bot
Merged at revision: not available
Proposed branch: ~pzakha/cloud-init:userdata
Merge into: cloud-init:master
Diff against target: 254 lines (+103/-12)
5 files modified
cloudinit/config/cc_ssh.py (+13/-2)
cloudinit/config/tests/test_ssh.py (+37/-9)
cloudinit/stages.py (+5/-1)
doc/rtd/topics/format.rst (+8/-0)
tests/unittests/test_data.py (+40/-0)
Reviewer Review Type Date Requested Status
Ryan Harper Approve
Server Team CI bot continuous-integration Approve
Review via email: mp+367721@code.launchpad.net

Description of the change

Add config for ssh-key import and consuming user-data

This patch enables control over SSH public-key import and
discarding supplied user-data (both disabled by default).

  allow-userdata: false
  ssh:
    allow_public_ssh_keys: false

This feature enables closed appliances to prevent customers
from unintentionally breaking the appliance which were
not designed for user interaction.

The downstream change for this is here:
  https://github.com/delphix/cloud-init/pull/4

To post a comment you must log in.
Revision history for this message
Dan Watkins (oddbloke) wrote :

Hi Pavel, thanks for the contribution to cloud-init! I'm flitting between meetings, so I'm not able to assess whether these changes are broadly appropriate. That said, I do have an inline comment on how the changes could be expressed a little more concisely. I'll revisit this later today for a more full review.

Revision history for this message
Scott Moser (smoser) wrote :

I'm not opposed to this in principal. But I'd like to warn that this is not "drm". The end user can probably find a way of getting root on your image if they really want. You'll just be making it more difficult to do so.

That said, I do understand the desire to not have a user "break" your supported functionality unintentionally and then asking for support.

Revision history for this message
Pavel Zakharov (pzakha) wrote :

Thanks for the reviews.

Yes I understand that if the user really wants to access the system, they will find a way to do so. The intent of this is to make it non-trivial. Your reasoning is spot on.

Revision history for this message
Ryan Harper (raharper) :
Revision history for this message
Pavel Zakharov (pzakha) wrote :

I think I've addressed the review feedback, let me know what are the next steps.

Revision history for this message
Ryan Harper (raharper) wrote :

Thanks for updating the branch. I didn't see a unittest for the change. Let me know if you need help there.

I definitely would like to see us document this behavior. At a min, this branch can update:

 cloudinit/config/cc_ssh.py

The large block comment at the top is in RTD format, please add the allow_public_keys field, default value and describe what it does.

And I think the best location to mention disabling user-data is in:

 doc/rtd/topics/boot.rst

under the Network section. It mentions that this stage consumes all user-data.
Here you could add a

**Note**: Cloud-init system config (/etc/cloud/cloud.cfg.d/*) may disable consumption of
user-data via the following key: allow_userdata , etc

And document the default value and what it does.

review: Needs Fixing
Revision history for this message
Pavel Zakharov (pzakha) wrote :

Alright, I'm a bit busy right now, but I should be able to post an update in a few weeks.

Revision history for this message
Ryan Harper (raharper) wrote :

I've marked this branch work-in-progress. Once you've updated this branch please move the state back to NeedsReview. Thanks!

Revision history for this message
Pavel Zakharov (pzakha) wrote :

I've updated the code, looking for another round of reviews!

Revision history for this message
Pavel Zakharov (pzakha) wrote :

Could someone take another look at this?

Revision history for this message
Ryan Harper (raharper) wrote :

Thanks for the update.

I'm wondering if these two config changes should be in separate commits.

I've some in-line comments, suggestions.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:8913c393d5878fd4d169565f7c2dacb7596ecdbe
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1199/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1199//rebuild

review: Needs Fixing (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

If you can rebase this to master, that'll fix up the current CI issue unrelated to your changes.

~pzakha/cloud-init:userdata updated
33f9d36... by Pavel Zakharov <email address hidden>

Review feedback, fix tests

Revision history for this message
Pavel Zakharov (pzakha) wrote :

I've rebased the code on master, and fixed the failing unit tests.

@raharper, I think I fixed most of the phrasing, but I didn't quite understand your feedback regarding the "Disabling User-Data" section.

Revision history for this message
Ryan Harper (raharper) wrote :

Thanks for rebasing. I'll look at it again and comment. I think the important part was to not say

"cloud-init will silently ignore"

when your code *logs* that we're ignoring user-data as requested, which is what I want; we want to leave a breadcrumb in the log file for folks to see why their user-data was not processed.

~pzakha/cloud-init:userdata updated
67f7991... by Pavel Zakharov <email address hidden>

Reword the Disable User-Data section

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:67f7991583a71105e07c7555a4d2e06d51d6bb73
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1239/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/1239//rebuild

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

Thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/cloudinit/config/cc_ssh.py b/cloudinit/config/cc_ssh.py
index fdd8f4d..050285a 100755
--- a/cloudinit/config/cc_ssh.py
+++ b/cloudinit/config/cc_ssh.py
@@ -56,9 +56,13 @@ root login is disabled, and root login opts are set to::
56 no-port-forwarding,no-agent-forwarding,no-X11-forwarding56 no-port-forwarding,no-agent-forwarding,no-X11-forwarding
5757
58Authorized keys for the default user/first user defined in ``users`` can be58Authorized keys for the default user/first user defined in ``users`` can be
59specified using `ssh_authorized_keys``. Keys should be specified as a list of59specified using ``ssh_authorized_keys``. Keys should be specified as a list of
60public keys.60public keys.
6161
62Importing ssh public keys for the default user (defined in ``users``)) is
63enabled by default. This feature may be disabled by setting
64``allow_publish_ssh_keys: false``.
65
62.. note::66.. note::
63 see the ``cc_set_passwords`` module documentation to enable/disable ssh67 see the ``cc_set_passwords`` module documentation to enable/disable ssh
64 password authentication68 password authentication
@@ -91,6 +95,7 @@ public keys.
91 ssh_authorized_keys:95 ssh_authorized_keys:
92 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEA3FSyQwBI6Z+nCSjUU ...96 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEA3FSyQwBI6Z+nCSjUU ...
93 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZ ...97 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZ ...
98 allow_public_ssh_keys: <true/false>
94 ssh_publish_hostkeys:99 ssh_publish_hostkeys:
95 enabled: <true/false> (Defaults to true)100 enabled: <true/false> (Defaults to true)
96 blacklist: <list of key types> (Defaults to [dsa])101 blacklist: <list of key types> (Defaults to [dsa])
@@ -207,7 +212,13 @@ def handle(_name, cfg, cloud, log, _args):
207 disable_root_opts = util.get_cfg_option_str(cfg, "disable_root_opts",212 disable_root_opts = util.get_cfg_option_str(cfg, "disable_root_opts",
208 ssh_util.DISABLE_USER_OPTS)213 ssh_util.DISABLE_USER_OPTS)
209214
210 keys = cloud.get_public_ssh_keys() or []215 keys = []
216 if util.get_cfg_option_bool(cfg, 'allow_public_ssh_keys', True):
217 keys = cloud.get_public_ssh_keys() or []
218 else:
219 log.debug('Skipping import of publish ssh keys per '
220 'config setting: allow_public_ssh_keys=False')
221
211 if "ssh_authorized_keys" in cfg:222 if "ssh_authorized_keys" in cfg:
212 cfgkeys = cfg["ssh_authorized_keys"]223 cfgkeys = cfg["ssh_authorized_keys"]
213 keys.extend(cfgkeys)224 keys.extend(cfgkeys)
diff --git a/cloudinit/config/tests/test_ssh.py b/cloudinit/config/tests/test_ssh.py
index e778984..0c55441 100644
--- a/cloudinit/config/tests/test_ssh.py
+++ b/cloudinit/config/tests/test_ssh.py
@@ -5,6 +5,9 @@ import os.path
5from cloudinit.config import cc_ssh5from cloudinit.config import cc_ssh
6from cloudinit import ssh_util6from cloudinit import ssh_util
7from cloudinit.tests.helpers import CiTestCase, mock7from cloudinit.tests.helpers import CiTestCase, mock
8import logging
9
10LOG = logging.getLogger(__name__)
811
9MODPATH = "cloudinit.config.cc_ssh."12MODPATH = "cloudinit.config.cc_ssh."
1013
@@ -87,7 +90,7 @@ class TestHandleSsh(CiTestCase):
87 cc_ssh.PUBLISH_HOST_KEYS = False90 cc_ssh.PUBLISH_HOST_KEYS = False
88 cloud = self.tmp_cloud(91 cloud = self.tmp_cloud(
89 distro='ubuntu', metadata={'public-keys': keys})92 distro='ubuntu', metadata={'public-keys': keys})
90 cc_ssh.handle("name", cfg, cloud, None, None)93 cc_ssh.handle("name", cfg, cloud, LOG, None)
91 options = ssh_util.DISABLE_USER_OPTS.replace("$USER", "NONE")94 options = ssh_util.DISABLE_USER_OPTS.replace("$USER", "NONE")
92 options = options.replace("$DISABLE_USER", "root")95 options = options.replace("$DISABLE_USER", "root")
93 m_glob.assert_called_once_with('/etc/ssh/ssh_host_*key*')96 m_glob.assert_called_once_with('/etc/ssh/ssh_host_*key*')
@@ -103,6 +106,31 @@ class TestHandleSsh(CiTestCase):
103 @mock.patch(MODPATH + "glob.glob")106 @mock.patch(MODPATH + "glob.glob")
104 @mock.patch(MODPATH + "ug_util.normalize_users_groups")107 @mock.patch(MODPATH + "ug_util.normalize_users_groups")
105 @mock.patch(MODPATH + "os.path.exists")108 @mock.patch(MODPATH + "os.path.exists")
109 def test_dont_allow_public_ssh_keys(self, m_path_exists, m_nug,
110 m_glob, m_setup_keys):
111 """Test allow_public_ssh_keys=False ignores ssh public keys from
112 platform.
113 """
114 cfg = {"allow_public_ssh_keys": False}
115 keys = ["key1"]
116 user = "clouduser"
117 m_glob.return_value = [] # Return no matching keys to prevent removal
118 # Mock os.path.exits to True to short-circuit the key writing logic
119 m_path_exists.return_value = True
120 m_nug.return_value = ({user: {"default": user}}, {})
121 cloud = self.tmp_cloud(
122 distro='ubuntu', metadata={'public-keys': keys})
123 cc_ssh.handle("name", cfg, cloud, LOG, None)
124
125 options = ssh_util.DISABLE_USER_OPTS.replace("$USER", user)
126 options = options.replace("$DISABLE_USER", "root")
127 self.assertEqual([mock.call(set(), user),
128 mock.call(set(), "root", options=options)],
129 m_setup_keys.call_args_list)
130
131 @mock.patch(MODPATH + "glob.glob")
132 @mock.patch(MODPATH + "ug_util.normalize_users_groups")
133 @mock.patch(MODPATH + "os.path.exists")
106 def test_handle_no_cfg_and_default_root(self, m_path_exists, m_nug,134 def test_handle_no_cfg_and_default_root(self, m_path_exists, m_nug,
107 m_glob, m_setup_keys):135 m_glob, m_setup_keys):
108 """Test handle with no config and a default distro user."""136 """Test handle with no config and a default distro user."""
@@ -115,7 +143,7 @@ class TestHandleSsh(CiTestCase):
115 m_nug.return_value = ({user: {"default": user}}, {})143 m_nug.return_value = ({user: {"default": user}}, {})
116 cloud = self.tmp_cloud(144 cloud = self.tmp_cloud(
117 distro='ubuntu', metadata={'public-keys': keys})145 distro='ubuntu', metadata={'public-keys': keys})
118 cc_ssh.handle("name", cfg, cloud, None, None)146 cc_ssh.handle("name", cfg, cloud, LOG, None)
119147
120 options = ssh_util.DISABLE_USER_OPTS.replace("$USER", user)148 options = ssh_util.DISABLE_USER_OPTS.replace("$USER", user)
121 options = options.replace("$DISABLE_USER", "root")149 options = options.replace("$DISABLE_USER", "root")
@@ -140,7 +168,7 @@ class TestHandleSsh(CiTestCase):
140 m_nug.return_value = ({user: {"default": user}}, {})168 m_nug.return_value = ({user: {"default": user}}, {})
141 cloud = self.tmp_cloud(169 cloud = self.tmp_cloud(
142 distro='ubuntu', metadata={'public-keys': keys})170 distro='ubuntu', metadata={'public-keys': keys})
143 cc_ssh.handle("name", cfg, cloud, None, None)171 cc_ssh.handle("name", cfg, cloud, LOG, None)
144172
145 options = ssh_util.DISABLE_USER_OPTS.replace("$USER", user)173 options = ssh_util.DISABLE_USER_OPTS.replace("$USER", user)
146 options = options.replace("$DISABLE_USER", "root")174 options = options.replace("$DISABLE_USER", "root")
@@ -165,7 +193,7 @@ class TestHandleSsh(CiTestCase):
165 cloud = self.tmp_cloud(193 cloud = self.tmp_cloud(
166 distro='ubuntu', metadata={'public-keys': keys})194 distro='ubuntu', metadata={'public-keys': keys})
167 cloud.get_public_ssh_keys = mock.Mock(return_value=keys)195 cloud.get_public_ssh_keys = mock.Mock(return_value=keys)
168 cc_ssh.handle("name", cfg, cloud, None, None)196 cc_ssh.handle("name", cfg, cloud, LOG, None)
169197
170 self.assertEqual([mock.call(set(keys), user),198 self.assertEqual([mock.call(set(keys), user),
171 mock.call(set(keys), "root", options="")],199 mock.call(set(keys), "root", options="")],
@@ -196,7 +224,7 @@ class TestHandleSsh(CiTestCase):
196 cfg = {}224 cfg = {}
197 expected_call = [self.test_hostkeys[key_type] for key_type225 expected_call = [self.test_hostkeys[key_type] for key_type
198 in ['ecdsa', 'ed25519', 'rsa']]226 in ['ecdsa', 'ed25519', 'rsa']]
199 cc_ssh.handle("name", cfg, cloud, None, None)227 cc_ssh.handle("name", cfg, cloud, LOG, None)
200 self.assertEqual([mock.call(expected_call)],228 self.assertEqual([mock.call(expected_call)],
201 cloud.datasource.publish_host_keys.call_args_list)229 cloud.datasource.publish_host_keys.call_args_list)
202230
@@ -225,7 +253,7 @@ class TestHandleSsh(CiTestCase):
225 cfg = {'ssh_publish_hostkeys': {'enabled': True}}253 cfg = {'ssh_publish_hostkeys': {'enabled': True}}
226 expected_call = [self.test_hostkeys[key_type] for key_type254 expected_call = [self.test_hostkeys[key_type] for key_type
227 in ['ecdsa', 'ed25519', 'rsa']]255 in ['ecdsa', 'ed25519', 'rsa']]
228 cc_ssh.handle("name", cfg, cloud, None, None)256 cc_ssh.handle("name", cfg, cloud, LOG, None)
229 self.assertEqual([mock.call(expected_call)],257 self.assertEqual([mock.call(expected_call)],
230 cloud.datasource.publish_host_keys.call_args_list)258 cloud.datasource.publish_host_keys.call_args_list)
231259
@@ -252,7 +280,7 @@ class TestHandleSsh(CiTestCase):
252 cloud.datasource.publish_host_keys = mock.Mock()280 cloud.datasource.publish_host_keys = mock.Mock()
253281
254 cfg = {'ssh_publish_hostkeys': {'enabled': False}}282 cfg = {'ssh_publish_hostkeys': {'enabled': False}}
255 cc_ssh.handle("name", cfg, cloud, None, None)283 cc_ssh.handle("name", cfg, cloud, LOG, None)
256 self.assertFalse(cloud.datasource.publish_host_keys.call_args_list)284 self.assertFalse(cloud.datasource.publish_host_keys.call_args_list)
257 cloud.datasource.publish_host_keys.assert_not_called()285 cloud.datasource.publish_host_keys.assert_not_called()
258286
@@ -282,7 +310,7 @@ class TestHandleSsh(CiTestCase):
282 'blacklist': ['dsa', 'rsa']}}310 'blacklist': ['dsa', 'rsa']}}
283 expected_call = [self.test_hostkeys[key_type] for key_type311 expected_call = [self.test_hostkeys[key_type] for key_type
284 in ['ecdsa', 'ed25519']]312 in ['ecdsa', 'ed25519']]
285 cc_ssh.handle("name", cfg, cloud, None, None)313 cc_ssh.handle("name", cfg, cloud, LOG, None)
286 self.assertEqual([mock.call(expected_call)],314 self.assertEqual([mock.call(expected_call)],
287 cloud.datasource.publish_host_keys.call_args_list)315 cloud.datasource.publish_host_keys.call_args_list)
288316
@@ -312,6 +340,6 @@ class TestHandleSsh(CiTestCase):
312 'blacklist': []}}340 'blacklist': []}}
313 expected_call = [self.test_hostkeys[key_type] for key_type341 expected_call = [self.test_hostkeys[key_type] for key_type
314 in ['dsa', 'ecdsa', 'ed25519', 'rsa']]342 in ['dsa', 'ecdsa', 'ed25519', 'rsa']]
315 cc_ssh.handle("name", cfg, cloud, None, None)343 cc_ssh.handle("name", cfg, cloud, LOG, None)
316 self.assertEqual([mock.call(expected_call)],344 self.assertEqual([mock.call(expected_call)],
317 cloud.datasource.publish_host_keys.call_args_list)345 cloud.datasource.publish_host_keys.call_args_list)
diff --git a/cloudinit/stages.py b/cloudinit/stages.py
index 5012988..18ca3b9 100644
--- a/cloudinit/stages.py
+++ b/cloudinit/stages.py
@@ -549,7 +549,11 @@ class Init(object):
549 with events.ReportEventStack("consume-user-data",549 with events.ReportEventStack("consume-user-data",
550 "reading and applying user-data",550 "reading and applying user-data",
551 parent=self.reporter):551 parent=self.reporter):
552 self._consume_userdata(frequency)552 if util.get_cfg_option_bool(self.cfg, 'allow_userdata', True):
553 self._consume_userdata(frequency)
554 else:
555 LOG.debug('allow_userdata = False: discarding user-data')
556
553 with events.ReportEventStack("consume-vendor-data",557 with events.ReportEventStack("consume-vendor-data",
554 "reading and applying vendor-data",558 "reading and applying vendor-data",
555 parent=self.reporter):559 parent=self.reporter):
diff --git a/doc/rtd/topics/format.rst b/doc/rtd/topics/format.rst
index 7605040..f9f4ba6 100644
--- a/doc/rtd/topics/format.rst
+++ b/doc/rtd/topics/format.rst
@@ -196,6 +196,14 @@ Example
196196
197Also this `blog`_ post offers another example for more advanced usage.197Also this `blog`_ post offers another example for more advanced usage.
198198
199Disabling User-Data
200===================
201
202Cloud-init can be configured to ignore any user-data provided to instance.
203This allows custom images to prevent users from accidentally breaking closed
204appliances. Setting ``allow_userdata: false`` in the configuration will disable
205cloud-init from processing user-data.
206
199.. [#] See your cloud provider for applicable user-data size limitations...207.. [#] See your cloud provider for applicable user-data size limitations...
200.. _blog: http://foss-boss.blogspot.com/2011/01/advanced-cloud-init-custom-handlers.html208.. _blog: http://foss-boss.blogspot.com/2011/01/advanced-cloud-init-custom-handlers.html
201.. vi: textwidth=78209.. vi: textwidth=78
diff --git a/tests/unittests/test_data.py b/tests/unittests/test_data.py
index 3efe7ad..97d1dc2 100644
--- a/tests/unittests/test_data.py
+++ b/tests/unittests/test_data.py
@@ -524,6 +524,46 @@ c: 4
524 self.assertEqual(cfg.get('password'), 'gocubs')524 self.assertEqual(cfg.get('password'), 'gocubs')
525 self.assertEqual(cfg.get('locale'), 'chicago')525 self.assertEqual(cfg.get('locale'), 'chicago')
526526
527 @mock.patch('cloudinit.util.read_conf_with_confd')
528 def test_dont_allow_user_data(self, mock_cfg):
529 mock_cfg.return_value = {"allow_userdata": False}
530
531 # test that user-data is ignored but vendor-data is kept
532 user_blob = '''
533#cloud-config-jsonp
534[
535 { "op": "add", "path": "/baz", "value": "qux" },
536 { "op": "add", "path": "/bar", "value": "qux2" }
537]
538'''
539 vendor_blob = '''
540#cloud-config-jsonp
541[
542 { "op": "add", "path": "/baz", "value": "quxA" },
543 { "op": "add", "path": "/bar", "value": "quxB" },
544 { "op": "add", "path": "/foo", "value": "quxC" }
545]
546'''
547 self.reRoot()
548 initer = stages.Init()
549 initer.datasource = FakeDataSource(user_blob, vendordata=vendor_blob)
550 initer.read_cfg()
551 initer.initialize()
552 initer.fetch()
553 initer.instancify()
554 initer.update()
555 initer.cloudify().run('consume_data',
556 initer.consume_data,
557 args=[PER_INSTANCE],
558 freq=PER_INSTANCE)
559 mods = stages.Modules(initer)
560 (_which_ran, _failures) = mods.run_section('cloud_init_modules')
561 cfg = mods.cfg
562 self.assertIn('vendor_data', cfg)
563 self.assertEqual('quxA', cfg['baz'])
564 self.assertEqual('quxB', cfg['bar'])
565 self.assertEqual('quxC', cfg['foo'])
566
527567
528class TestConsumeUserDataHttp(TestConsumeUserData, helpers.HttprettyTestCase):568class TestConsumeUserDataHttp(TestConsumeUserData, helpers.HttprettyTestCase):
529569

Subscribers

People subscribed via source and target branches