sailesh1993 / rpms / cloud-init

Forked from rpms/cloud-init 10 months ago
Clone
faf1e5
From 60991b1241a5efb585df889d4343007e501fd70c Mon Sep 17 00:00:00 2001
faf1e5
From: Eduardo Otubo <otubo@redhat.com>
faf1e5
Date: Tue, 5 May 2020 08:08:15 +0200
faf1e5
Subject: [PATCH 2/5] Add support for publishing host keys to GCE guest
faf1e5
 attributes
faf1e5
faf1e5
RH-Author: Eduardo Otubo <otubo@redhat.com>
faf1e5
Message-id: <20200504085238.25884-3-otubo@redhat.com>
faf1e5
Patchwork-id: 96243
faf1e5
O-Subject: [RHEL-7.8.z cloud-init PATCH 2/5] Add support for publishing host keys to GCE guest attributes
faf1e5
Bugzilla: 1827207
faf1e5
RH-Acked-by: Cathy Avery <cavery@redhat.com>
faf1e5
RH-Acked-by: Mohammed Gamal <mgamal@redhat.com>
faf1e5
RH-Acked-by: Vitaly Kuznetsov <vkuznets@redhat.com>
faf1e5
faf1e5
commit 155847209e6a3ed5face91a133d8488a703f3f93
faf1e5
Author: Rick Wright <rickw@google.com>
faf1e5
Date:   Fri Aug 9 17:11:05 2019 +0000
faf1e5
faf1e5
    Add support for publishing host keys to GCE guest attributes
faf1e5
faf1e5
    This adds an empty publish_host_keys() method to the default datasource
faf1e5
    that is called by cc_ssh.py. This feature can be controlled by the
faf1e5
    'ssh_publish_hostkeys' config option. It is enabled by default but can
faf1e5
    be disabled by setting 'enabled' to false. Also, a blacklist of key
faf1e5
    types is supported.
faf1e5
faf1e5
    In addition, this change implements ssh_publish_hostkeys() for the GCE
faf1e5
    datasource, attempting to write the hostkeys to the instance's guest
faf1e5
    attributes. Using these hostkeys for ssh connections is currently
faf1e5
    supported by the alpha version of Google's 'gcloud' command-line tool.
faf1e5
faf1e5
    (On Google Compute Engine, this feature will be enabled by setting the
faf1e5
    'enable-guest-attributes' metadata key to 'true' for the
faf1e5
    project/instance that you would like to use this feature for. When
faf1e5
    connecting to the instance for the first time using 'gcloud compute ssh'
faf1e5
    the hostkeys will be read from the guest attributes for the instance and
faf1e5
    written to the user's local known_hosts file for Google Compute Engine
faf1e5
    instances.)
faf1e5
faf1e5
Signed-off-by: Eduardo Otubo <otubo@redhat.com>
faf1e5
Signed-off-by: Miroslav Rezanina <mrezanin@redhat.com>
faf1e5
---
faf1e5
 cloudinit/config/cc_ssh.py                  |  55 +++++++++
faf1e5
 cloudinit/config/tests/test_ssh.py          | 166 ++++++++++++++++++++++++++++
faf1e5
 cloudinit/sources/DataSourceGCE.py          |  22 +++-
faf1e5
 cloudinit/sources/__init__.py               |  10 ++
faf1e5
 cloudinit/url_helper.py                     |   9 +-
faf1e5
 tests/unittests/test_datasource/test_gce.py |  18 +++
faf1e5
 6 files changed, 274 insertions(+), 6 deletions(-)
faf1e5
faf1e5
diff --git a/cloudinit/config/cc_ssh.py b/cloudinit/config/cc_ssh.py
faf1e5
index f8f7cb3..53f6939 100755
faf1e5
--- a/cloudinit/config/cc_ssh.py
faf1e5
+++ b/cloudinit/config/cc_ssh.py
faf1e5
@@ -91,6 +91,9 @@ public keys.
faf1e5
     ssh_authorized_keys:
faf1e5
         - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEA3FSyQwBI6Z+nCSjUU ...
faf1e5
         - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZ ...
faf1e5
+    ssh_publish_hostkeys:
faf1e5
+        enabled: <true/false> (Defaults to true)
faf1e5
+        blacklist: <list of key types> (Defaults to [dsa])
faf1e5
 """
faf1e5
 
faf1e5
 import glob
faf1e5
@@ -104,6 +107,10 @@ from cloudinit import util
faf1e5
 
faf1e5
 GENERATE_KEY_NAMES = ['rsa', 'dsa', 'ecdsa', 'ed25519']
faf1e5
 KEY_FILE_TPL = '/etc/ssh/ssh_host_%s_key'
faf1e5
+PUBLISH_HOST_KEYS = True
faf1e5
+# Don't publish the dsa hostkey by default since OpenSSH recommends not using
faf1e5
+# it.
faf1e5
+HOST_KEY_PUBLISH_BLACKLIST = ['dsa']
faf1e5
 
faf1e5
 CONFIG_KEY_TO_FILE = {}
faf1e5
 PRIV_TO_PUB = {}
faf1e5
@@ -176,6 +183,23 @@ def handle(_name, cfg, cloud, log, _args):
faf1e5
                         util.logexc(log, "Failed generating key type %s to "
faf1e5
                                     "file %s", keytype, keyfile)
faf1e5
 
faf1e5
+    if "ssh_publish_hostkeys" in cfg:
faf1e5
+        host_key_blacklist = util.get_cfg_option_list(
faf1e5
+            cfg["ssh_publish_hostkeys"], "blacklist",
faf1e5
+            HOST_KEY_PUBLISH_BLACKLIST)
faf1e5
+        publish_hostkeys = util.get_cfg_option_bool(
faf1e5
+            cfg["ssh_publish_hostkeys"], "enabled", PUBLISH_HOST_KEYS)
faf1e5
+    else:
faf1e5
+        host_key_blacklist = HOST_KEY_PUBLISH_BLACKLIST
faf1e5
+        publish_hostkeys = PUBLISH_HOST_KEYS
faf1e5
+
faf1e5
+    if publish_hostkeys:
faf1e5
+        hostkeys = get_public_host_keys(blacklist=host_key_blacklist)
faf1e5
+        try:
faf1e5
+            cloud.datasource.publish_host_keys(hostkeys)
faf1e5
+        except Exception as e:
faf1e5
+            util.logexc(log, "Publishing host keys failed!")
faf1e5
+
faf1e5
     try:
faf1e5
         (users, _groups) = ug_util.normalize_users_groups(cfg, cloud.distro)
faf1e5
         (user, _user_config) = ug_util.extract_default(users)
faf1e5
@@ -209,4 +233,35 @@ def apply_credentials(keys, user, disable_root, disable_root_opts):
faf1e5
 
faf1e5
     ssh_util.setup_user_keys(keys, 'root', options=key_prefix)
faf1e5
 
faf1e5
+
faf1e5
+def get_public_host_keys(blacklist=None):
faf1e5
+    """Read host keys from /etc/ssh/*.pub files and return them as a list.
faf1e5
+
faf1e5
+    @param blacklist: List of key types to ignore. e.g. ['dsa', 'rsa']
faf1e5
+    @returns: List of keys, each formatted as a two-element tuple.
faf1e5
+        e.g. [('ssh-rsa', 'AAAAB3Nz...'), ('ssh-ed25519', 'AAAAC3Nx...')]
faf1e5
+    """
faf1e5
+    public_key_file_tmpl = '%s.pub' % (KEY_FILE_TPL,)
faf1e5
+    key_list = []
faf1e5
+    blacklist_files = []
faf1e5
+    if blacklist:
faf1e5
+        # Convert blacklist to filenames:
faf1e5
+        # 'dsa' -> '/etc/ssh/ssh_host_dsa_key.pub'
faf1e5
+        blacklist_files = [public_key_file_tmpl % (key_type,)
faf1e5
+                           for key_type in blacklist]
faf1e5
+    # Get list of public key files and filter out blacklisted files.
faf1e5
+    file_list = [hostfile for hostfile
faf1e5
+                 in glob.glob(public_key_file_tmpl % ('*',))
faf1e5
+                 if hostfile not in blacklist_files]
faf1e5
+
faf1e5
+    # Read host key files, retrieve first two fields as a tuple and
faf1e5
+    # append that tuple to key_list.
faf1e5
+    for file_name in file_list:
faf1e5
+        file_contents = util.load_file(file_name)
faf1e5
+        key_data = file_contents.split()
faf1e5
+        if key_data and len(key_data) > 1:
faf1e5
+            key_list.append(tuple(key_data[:2]))
faf1e5
+    return key_list
faf1e5
+
faf1e5
+
faf1e5
 # vi: ts=4 expandtab
faf1e5
diff --git a/cloudinit/config/tests/test_ssh.py b/cloudinit/config/tests/test_ssh.py
faf1e5
index c8a4271..e778984 100644
faf1e5
--- a/cloudinit/config/tests/test_ssh.py
faf1e5
+++ b/cloudinit/config/tests/test_ssh.py
faf1e5
@@ -1,5 +1,6 @@
faf1e5
 # This file is part of cloud-init. See LICENSE file for license information.
faf1e5
 
faf1e5
+import os.path
faf1e5
 
faf1e5
 from cloudinit.config import cc_ssh
faf1e5
 from cloudinit import ssh_util
faf1e5
@@ -12,6 +13,25 @@ MODPATH = "cloudinit.config.cc_ssh."
faf1e5
 class TestHandleSsh(CiTestCase):
faf1e5
     """Test cc_ssh handling of ssh config."""
faf1e5
 
faf1e5
+    def _publish_hostkey_test_setup(self):
faf1e5
+        self.test_hostkeys = {
faf1e5
+            'dsa': ('ssh-dss', 'AAAAB3NzaC1kc3MAAACB'),
faf1e5
+            'ecdsa': ('ecdsa-sha2-nistp256', 'AAAAE2VjZ'),
faf1e5
+            'ed25519': ('ssh-ed25519', 'AAAAC3NzaC1lZDI'),
faf1e5
+            'rsa': ('ssh-rsa', 'AAAAB3NzaC1yc2EAAA'),
faf1e5
+        }
faf1e5
+        self.test_hostkey_files = []
faf1e5
+        hostkey_tmpdir = self.tmp_dir()
faf1e5
+        for key_type in ['dsa', 'ecdsa', 'ed25519', 'rsa']:
faf1e5
+            key_data = self.test_hostkeys[key_type]
faf1e5
+            filename = 'ssh_host_%s_key.pub' % key_type
faf1e5
+            filepath = os.path.join(hostkey_tmpdir, filename)
faf1e5
+            self.test_hostkey_files.append(filepath)
faf1e5
+            with open(filepath, 'w') as f:
faf1e5
+                f.write(' '.join(key_data))
faf1e5
+
faf1e5
+        cc_ssh.KEY_FILE_TPL = os.path.join(hostkey_tmpdir, 'ssh_host_%s_key')
faf1e5
+
faf1e5
     def test_apply_credentials_with_user(self, m_setup_keys):
faf1e5
         """Apply keys for the given user and root."""
faf1e5
         keys = ["key1"]
faf1e5
@@ -64,6 +84,7 @@ class TestHandleSsh(CiTestCase):
faf1e5
         # Mock os.path.exits to True to short-circuit the key writing logic
faf1e5
         m_path_exists.return_value = True
faf1e5
         m_nug.return_value = ([], {})
faf1e5
+        cc_ssh.PUBLISH_HOST_KEYS = False
faf1e5
         cloud = self.tmp_cloud(
faf1e5
             distro='ubuntu', metadata={'public-keys': keys})
faf1e5
         cc_ssh.handle("name", cfg, cloud, None, None)
faf1e5
@@ -149,3 +170,148 @@ class TestHandleSsh(CiTestCase):
faf1e5
         self.assertEqual([mock.call(set(keys), user),
faf1e5
                           mock.call(set(keys), "root", options="")],
faf1e5
                          m_setup_keys.call_args_list)
faf1e5
+
faf1e5
+    @mock.patch(MODPATH + "glob.glob")
faf1e5
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
faf1e5
+    @mock.patch(MODPATH + "os.path.exists")
faf1e5
+    def test_handle_publish_hostkeys_default(
faf1e5
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
faf1e5
+        """Test handle with various configs for ssh_publish_hostkeys."""
faf1e5
+        self._publish_hostkey_test_setup()
faf1e5
+        cc_ssh.PUBLISH_HOST_KEYS = True
faf1e5
+        keys = ["key1"]
faf1e5
+        user = "clouduser"
faf1e5
+        # Return no matching keys for first glob, test keys for second.
faf1e5
+        m_glob.side_effect = iter([
faf1e5
+                                  [],
faf1e5
+                                  self.test_hostkey_files,
faf1e5
+                                  ])
faf1e5
+        # Mock os.path.exits to True to short-circuit the key writing logic
faf1e5
+        m_path_exists.return_value = True
faf1e5
+        m_nug.return_value = ({user: {"default": user}}, {})
faf1e5
+        cloud = self.tmp_cloud(
faf1e5
+            distro='ubuntu', metadata={'public-keys': keys})
faf1e5
+        cloud.datasource.publish_host_keys = mock.Mock()
faf1e5
+
faf1e5
+        cfg = {}
faf1e5
+        expected_call = [self.test_hostkeys[key_type] for key_type
faf1e5
+                         in ['ecdsa', 'ed25519', 'rsa']]
faf1e5
+        cc_ssh.handle("name", cfg, cloud, None, None)
faf1e5
+        self.assertEqual([mock.call(expected_call)],
faf1e5
+                         cloud.datasource.publish_host_keys.call_args_list)
faf1e5
+
faf1e5
+    @mock.patch(MODPATH + "glob.glob")
faf1e5
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
faf1e5
+    @mock.patch(MODPATH + "os.path.exists")
faf1e5
+    def test_handle_publish_hostkeys_config_enable(
faf1e5
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
faf1e5
+        """Test handle with various configs for ssh_publish_hostkeys."""
faf1e5
+        self._publish_hostkey_test_setup()
faf1e5
+        cc_ssh.PUBLISH_HOST_KEYS = False
faf1e5
+        keys = ["key1"]
faf1e5
+        user = "clouduser"
faf1e5
+        # Return no matching keys for first glob, test keys for second.
faf1e5
+        m_glob.side_effect = iter([
faf1e5
+                                  [],
faf1e5
+                                  self.test_hostkey_files,
faf1e5
+                                  ])
faf1e5
+        # Mock os.path.exits to True to short-circuit the key writing logic
faf1e5
+        m_path_exists.return_value = True
faf1e5
+        m_nug.return_value = ({user: {"default": user}}, {})
faf1e5
+        cloud = self.tmp_cloud(
faf1e5
+            distro='ubuntu', metadata={'public-keys': keys})
faf1e5
+        cloud.datasource.publish_host_keys = mock.Mock()
faf1e5
+
faf1e5
+        cfg = {'ssh_publish_hostkeys': {'enabled': True}}
faf1e5
+        expected_call = [self.test_hostkeys[key_type] for key_type
faf1e5
+                         in ['ecdsa', 'ed25519', 'rsa']]
faf1e5
+        cc_ssh.handle("name", cfg, cloud, None, None)
faf1e5
+        self.assertEqual([mock.call(expected_call)],
faf1e5
+                         cloud.datasource.publish_host_keys.call_args_list)
faf1e5
+
faf1e5
+    @mock.patch(MODPATH + "glob.glob")
faf1e5
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
faf1e5
+    @mock.patch(MODPATH + "os.path.exists")
faf1e5
+    def test_handle_publish_hostkeys_config_disable(
faf1e5
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
faf1e5
+        """Test handle with various configs for ssh_publish_hostkeys."""
faf1e5
+        self._publish_hostkey_test_setup()
faf1e5
+        cc_ssh.PUBLISH_HOST_KEYS = True
faf1e5
+        keys = ["key1"]
faf1e5
+        user = "clouduser"
faf1e5
+        # Return no matching keys for first glob, test keys for second.
faf1e5
+        m_glob.side_effect = iter([
faf1e5
+                                  [],
faf1e5
+                                  self.test_hostkey_files,
faf1e5
+                                  ])
faf1e5
+        # Mock os.path.exits to True to short-circuit the key writing logic
faf1e5
+        m_path_exists.return_value = True
faf1e5
+        m_nug.return_value = ({user: {"default": user}}, {})
faf1e5
+        cloud = self.tmp_cloud(
faf1e5
+            distro='ubuntu', metadata={'public-keys': keys})
faf1e5
+        cloud.datasource.publish_host_keys = mock.Mock()
faf1e5
+
faf1e5
+        cfg = {'ssh_publish_hostkeys': {'enabled': False}}
faf1e5
+        cc_ssh.handle("name", cfg, cloud, None, None)
faf1e5
+        self.assertFalse(cloud.datasource.publish_host_keys.call_args_list)
faf1e5
+        cloud.datasource.publish_host_keys.assert_not_called()
faf1e5
+
faf1e5
+    @mock.patch(MODPATH + "glob.glob")
faf1e5
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
faf1e5
+    @mock.patch(MODPATH + "os.path.exists")
faf1e5
+    def test_handle_publish_hostkeys_config_blacklist(
faf1e5
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
faf1e5
+        """Test handle with various configs for ssh_publish_hostkeys."""
faf1e5
+        self._publish_hostkey_test_setup()
faf1e5
+        cc_ssh.PUBLISH_HOST_KEYS = True
faf1e5
+        keys = ["key1"]
faf1e5
+        user = "clouduser"
faf1e5
+        # Return no matching keys for first glob, test keys for second.
faf1e5
+        m_glob.side_effect = iter([
faf1e5
+                                  [],
faf1e5
+                                  self.test_hostkey_files,
faf1e5
+                                  ])
faf1e5
+        # Mock os.path.exits to True to short-circuit the key writing logic
faf1e5
+        m_path_exists.return_value = True
faf1e5
+        m_nug.return_value = ({user: {"default": user}}, {})
faf1e5
+        cloud = self.tmp_cloud(
faf1e5
+            distro='ubuntu', metadata={'public-keys': keys})
faf1e5
+        cloud.datasource.publish_host_keys = mock.Mock()
faf1e5
+
faf1e5
+        cfg = {'ssh_publish_hostkeys': {'enabled': True,
faf1e5
+                                        'blacklist': ['dsa', 'rsa']}}
faf1e5
+        expected_call = [self.test_hostkeys[key_type] for key_type
faf1e5
+                         in ['ecdsa', 'ed25519']]
faf1e5
+        cc_ssh.handle("name", cfg, cloud, None, None)
faf1e5
+        self.assertEqual([mock.call(expected_call)],
faf1e5
+                         cloud.datasource.publish_host_keys.call_args_list)
faf1e5
+
faf1e5
+    @mock.patch(MODPATH + "glob.glob")
faf1e5
+    @mock.patch(MODPATH + "ug_util.normalize_users_groups")
faf1e5
+    @mock.patch(MODPATH + "os.path.exists")
faf1e5
+    def test_handle_publish_hostkeys_empty_blacklist(
faf1e5
+            self, m_path_exists, m_nug, m_glob, m_setup_keys):
faf1e5
+        """Test handle with various configs for ssh_publish_hostkeys."""
faf1e5
+        self._publish_hostkey_test_setup()
faf1e5
+        cc_ssh.PUBLISH_HOST_KEYS = True
faf1e5
+        keys = ["key1"]
faf1e5
+        user = "clouduser"
faf1e5
+        # Return no matching keys for first glob, test keys for second.
faf1e5
+        m_glob.side_effect = iter([
faf1e5
+                                  [],
faf1e5
+                                  self.test_hostkey_files,
faf1e5
+                                  ])
faf1e5
+        # Mock os.path.exits to True to short-circuit the key writing logic
faf1e5
+        m_path_exists.return_value = True
faf1e5
+        m_nug.return_value = ({user: {"default": user}}, {})
faf1e5
+        cloud = self.tmp_cloud(
faf1e5
+            distro='ubuntu', metadata={'public-keys': keys})
faf1e5
+        cloud.datasource.publish_host_keys = mock.Mock()
faf1e5
+
faf1e5
+        cfg = {'ssh_publish_hostkeys': {'enabled': True,
faf1e5
+                                        'blacklist': []}}
faf1e5
+        expected_call = [self.test_hostkeys[key_type] for key_type
faf1e5
+                         in ['dsa', 'ecdsa', 'ed25519', 'rsa']]
faf1e5
+        cc_ssh.handle("name", cfg, cloud, None, None)
faf1e5
+        self.assertEqual([mock.call(expected_call)],
faf1e5
+                         cloud.datasource.publish_host_keys.call_args_list)
faf1e5
diff --git a/cloudinit/sources/DataSourceGCE.py b/cloudinit/sources/DataSourceGCE.py
faf1e5
index d816262..6cbfbba 100644
faf1e5
--- a/cloudinit/sources/DataSourceGCE.py
faf1e5
+++ b/cloudinit/sources/DataSourceGCE.py
faf1e5
@@ -18,10 +18,13 @@ LOG = logging.getLogger(__name__)
faf1e5
 MD_V1_URL = 'http://metadata.google.internal/computeMetadata/v1/'
faf1e5
 BUILTIN_DS_CONFIG = {'metadata_url': MD_V1_URL}
faf1e5
 REQUIRED_FIELDS = ('instance-id', 'availability-zone', 'local-hostname')
faf1e5
+GUEST_ATTRIBUTES_URL = ('http://metadata.google.internal/computeMetadata/'
faf1e5
+                        'v1/instance/guest-attributes')
faf1e5
+HOSTKEY_NAMESPACE = 'hostkeys'
faf1e5
+HEADERS = {'Metadata-Flavor': 'Google'}
faf1e5
 
faf1e5
 
faf1e5
 class GoogleMetadataFetcher(object):
faf1e5
-    headers = {'Metadata-Flavor': 'Google'}
faf1e5
 
faf1e5
     def __init__(self, metadata_address):
faf1e5
         self.metadata_address = metadata_address
faf1e5
@@ -32,7 +35,7 @@ class GoogleMetadataFetcher(object):
faf1e5
             url = self.metadata_address + path
faf1e5
             if is_recursive:
faf1e5
                 url += '/?recursive=True'
faf1e5
-            resp = url_helper.readurl(url=url, headers=self.headers)
faf1e5
+            resp = url_helper.readurl(url=url, headers=HEADERS)
faf1e5
         except url_helper.UrlError as exc:
faf1e5
             msg = "url %s raised exception %s"
faf1e5
             LOG.debug(msg, path, exc)
faf1e5
@@ -90,6 +93,10 @@ class DataSourceGCE(sources.DataSource):
faf1e5
         public_keys_data = self.metadata['public-keys-data']
faf1e5
         return _parse_public_keys(public_keys_data, self.default_user)
faf1e5
 
faf1e5
+    def publish_host_keys(self, hostkeys):
faf1e5
+        for key in hostkeys:
faf1e5
+            _write_host_key_to_guest_attributes(*key)
faf1e5
+
faf1e5
     def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
faf1e5
         # GCE has long FDQN's and has asked for short hostnames.
faf1e5
         return self.metadata['local-hostname'].split('.')[0]
faf1e5
@@ -103,6 +110,17 @@ class DataSourceGCE(sources.DataSource):
faf1e5
         return self.availability_zone.rsplit('-', 1)[0]
faf1e5
 
faf1e5
 
faf1e5
+def _write_host_key_to_guest_attributes(key_type, key_value):
faf1e5
+    url = '%s/%s/%s' % (GUEST_ATTRIBUTES_URL, HOSTKEY_NAMESPACE, key_type)
faf1e5
+    key_value = key_value.encode('utf-8')
faf1e5
+    resp = url_helper.readurl(url=url, data=key_value, headers=HEADERS,
faf1e5
+                              request_method='PUT', check_status=False)
faf1e5
+    if resp.ok():
faf1e5
+        LOG.debug('Wrote %s host key to guest attributes.',  key_type)
faf1e5
+    else:
faf1e5
+        LOG.debug('Unable to write %s host key to guest attributes.', key_type)
faf1e5
+
faf1e5
+
faf1e5
 def _has_expired(public_key):
faf1e5
     # Check whether an SSH key is expired. Public key input is a single SSH
faf1e5
     # public key in the GCE specific key format documented here:
faf1e5
diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
faf1e5
index e6966b3..425e703 100644
faf1e5
--- a/cloudinit/sources/__init__.py
faf1e5
+++ b/cloudinit/sources/__init__.py
faf1e5
@@ -474,6 +474,16 @@ class DataSource(object):
faf1e5
     def get_public_ssh_keys(self):
faf1e5
         return normalize_pubkey_data(self.metadata.get('public-keys'))
faf1e5
 
faf1e5
+    def publish_host_keys(self, hostkeys):
faf1e5
+        """Publish the public SSH host keys (found in /etc/ssh/*.pub).
faf1e5
+
faf1e5
+        @param hostkeys: List of host key tuples (key_type, key_value),
faf1e5
+            where key_type is the first field in the public key file
faf1e5
+            (e.g. 'ssh-rsa') and key_value is the key itself
faf1e5
+            (e.g. 'AAAAB3NzaC1y...').
faf1e5
+        """
faf1e5
+        pass
faf1e5
+
faf1e5
     def _remap_device(self, short_name):
faf1e5
         # LP: #611137
faf1e5
         # the metadata service may believe that devices are named 'sda'
faf1e5
diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py
faf1e5
index 396d69a..1b0721b 100644
faf1e5
--- a/cloudinit/url_helper.py
faf1e5
+++ b/cloudinit/url_helper.py
faf1e5
@@ -199,18 +199,19 @@ def _get_ssl_args(url, ssl_details):
faf1e5
 def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
faf1e5
             headers=None, headers_cb=None, ssl_details=None,
faf1e5
             check_status=True, allow_redirects=True, exception_cb=None,
faf1e5
-            session=None, infinite=False, log_req_resp=True):
faf1e5
+            session=None, infinite=False, log_req_resp=True,
faf1e5
+            request_method=None):
faf1e5
     url = _cleanurl(url)
faf1e5
     req_args = {
faf1e5
         'url': url,
faf1e5
     }
faf1e5
     req_args.update(_get_ssl_args(url, ssl_details))
faf1e5
     req_args['allow_redirects'] = allow_redirects
faf1e5
-    req_args['method'] = 'GET'
faf1e5
+    if not request_method:
faf1e5
+        request_method = 'POST' if data else 'GET'
faf1e5
+    req_args['method'] = request_method
faf1e5
     if timeout is not None:
faf1e5
         req_args['timeout'] = max(float(timeout), 0)
faf1e5
-    if data:
faf1e5
-        req_args['method'] = 'POST'
faf1e5
     # It doesn't seem like config
faf1e5
     # was added in older library versions (or newer ones either), thus we
faf1e5
     # need to manually do the retries if it wasn't...
faf1e5
diff --git a/tests/unittests/test_datasource/test_gce.py b/tests/unittests/test_datasource/test_gce.py
faf1e5
index 41176c6..67744d3 100644
faf1e5
--- a/tests/unittests/test_datasource/test_gce.py
faf1e5
+++ b/tests/unittests/test_datasource/test_gce.py
faf1e5
@@ -55,6 +55,8 @@ GCE_USER_DATA_TEXT = {
faf1e5
 HEADERS = {'Metadata-Flavor': 'Google'}
faf1e5
 MD_URL_RE = re.compile(
faf1e5
     r'http://metadata.google.internal/computeMetadata/v1/.*')
faf1e5
+GUEST_ATTRIBUTES_URL = ('http://metadata.google.internal/computeMetadata/'
faf1e5
+                        'v1/instance/guest-attributes/hostkeys/')
faf1e5
 
faf1e5
 
faf1e5
 def _set_mock_metadata(gce_meta=None):
faf1e5
@@ -341,4 +343,20 @@ class TestDataSourceGCE(test_helpers.HttprettyTestCase):
faf1e5
             public_key_data, default_user='default')
faf1e5
         self.assertEqual(sorted(found), sorted(expected))
faf1e5
 
faf1e5
+    @mock.patch("cloudinit.url_helper.readurl")
faf1e5
+    def test_publish_host_keys(self, m_readurl):
faf1e5
+        hostkeys = [('ssh-rsa', 'asdfasdf'),
faf1e5
+                    ('ssh-ed25519', 'qwerqwer')]
faf1e5
+        readurl_expected_calls = [
faf1e5
+            mock.call(check_status=False, data=b'asdfasdf', headers=HEADERS,
faf1e5
+                      request_method='PUT',
faf1e5
+                      url='%s%s' % (GUEST_ATTRIBUTES_URL, 'ssh-rsa')),
faf1e5
+            mock.call(check_status=False, data=b'qwerqwer', headers=HEADERS,
faf1e5
+                      request_method='PUT',
faf1e5
+                      url='%s%s' % (GUEST_ATTRIBUTES_URL, 'ssh-ed25519')),
faf1e5
+        ]
faf1e5
+        self.ds.publish_host_keys(hostkeys)
faf1e5
+        m_readurl.assert_has_calls(readurl_expected_calls, any_order=True)
faf1e5
+
faf1e5
+
faf1e5
 # vi: ts=4 expandtab
faf1e5
-- 
faf1e5
1.8.3.1
faf1e5