diff options
author | Alberto Contreras <alberto.contreras@canonical.com> | 2023-02-16 15:39:32 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-02-16 08:39:32 -0600 |
commit | e4f56efce3e04e128c065ae15762b7ef5c02bcc9 (patch) | |
tree | 419c9a719a67030efbc86e5df5c52ef0a164a12a | |
parent | bf06b3e6f16f9c3bf3a662d1a9f440dffc989fce (diff) | |
download | cloud-init-git-e4f56efce3e04e128c065ae15762b7ef5c02bcc9.tar.gz |
cc_ssh: support multiple hostcertificates (#2018)
LP: #1999164
-rw-r--r-- | cloudinit/config/cc_ssh.py | 7 | ||||
-rw-r--r-- | cloudinit/ssh_util.py | 35 | ||||
-rw-r--r-- | tests/integration_tests/modules/test_ssh_keys_provided.py | 13 | ||||
-rw-r--r-- | tests/unittests/config/test_cc_ssh.py | 19 | ||||
-rw-r--r-- | tests/unittests/test_ssh_util.py | 38 |
5 files changed, 91 insertions, 21 deletions
diff --git a/cloudinit/config/cc_ssh.py b/cloudinit/config/cc_ssh.py index c01dd48c..1ec889f3 100644 --- a/cloudinit/config/cc_ssh.py +++ b/cloudinit/config/cc_ssh.py @@ -211,6 +211,7 @@ def handle( if "ssh_keys" in cfg: # if there are keys and/or certificates in cloud-config, use them + cert_config = [] for (key, val) in cfg["ssh_keys"].items(): if key not in CONFIG_KEY_TO_FILE: if pattern_unsupported_config_keys.match(key): @@ -224,8 +225,10 @@ def handle( util.write_file(tgt_fn, val, tgt_perms) # set server to present the most recently identified certificate if "_certificate" in key: - cert_config = {"HostCertificate": tgt_fn} - ssh_util.update_ssh_config(cert_config) + cert_config.append(("HostCertificate", str(tgt_fn))) + + if cert_config: + ssh_util.append_ssh_config(cert_config) for private_type, public_type in PRIV_TO_PUB.items(): if ( diff --git a/cloudinit/ssh_util.py b/cloudinit/ssh_util.py index a1a6964f..eb5c9f64 100644 --- a/cloudinit/ssh_util.py +++ b/cloudinit/ssh_util.py @@ -8,6 +8,7 @@ import os import pwd +from typing import List, Sequence, Tuple from cloudinit import log as logging from cloudinit import util @@ -499,18 +500,18 @@ class SshdConfigLine: return v -def parse_ssh_config(fname): +def parse_ssh_config(fname) -> List[SshdConfigLine]: if not os.path.isfile(fname): return [] return parse_ssh_config_lines(util.load_file(fname).splitlines()) -def parse_ssh_config_lines(lines): +def parse_ssh_config_lines(lines) -> List[SshdConfigLine]: # See: man sshd_config # The file contains keyword-argument pairs, one per line. # Lines starting with '#' and empty lines are interpreted as comments. # Note: key-words are case-insensitive and arguments are case-sensitive - ret = [] + ret: List[SshdConfigLine] = [] for line in lines: line = line.strip() if not line or line.startswith("#"): @@ -554,11 +555,7 @@ def _includes_dconf(fname: str) -> bool: return False -def update_ssh_config(updates, fname=DEF_SSHD_CFG): - """Read fname, and update if changes are necessary. - - @param updates: dictionary of desired values {Option: value} - @return: boolean indicating if an update was done.""" +def _ensure_cloud_init_ssh_config_file(fname): if _includes_dconf(fname): if not os.path.isdir(f"{fname}.d"): util.ensure_dir(f"{fname}.d", mode=0o755) @@ -566,6 +563,15 @@ def update_ssh_config(updates, fname=DEF_SSHD_CFG): if not os.path.isfile(fname): # Ensure root read-only: util.ensure_file(fname, 0o600) + return fname + + +def update_ssh_config(updates, fname=DEF_SSHD_CFG): + """Read fname, and update if changes are necessary. + + @param updates: dictionary of desired values {Option: value} + @return: boolean indicating if an update was done.""" + fname = _ensure_cloud_init_ssh_config_file(fname) lines = parse_ssh_config(fname) changed = update_ssh_config_lines(lines=lines, updates=updates) if changed: @@ -623,4 +629,17 @@ def update_ssh_config_lines(lines, updates): return changed +def append_ssh_config(lines: Sequence[Tuple[str, str]], fname=DEF_SSHD_CFG): + if not lines: + return + fname = _ensure_cloud_init_ssh_config_file(fname) + content = (f"{k} {v}" for k, v in lines) + util.write_file( + fname, + "\n".join(content) + "\n", + omode="ab", + preserve_mode=True, + ) + + # vi: ts=4 expandtab diff --git a/tests/integration_tests/modules/test_ssh_keys_provided.py b/tests/integration_tests/modules/test_ssh_keys_provided.py index 8e73267a..b6069376 100644 --- a/tests/integration_tests/modules/test_ssh_keys_provided.py +++ b/tests/integration_tests/modules/test_ssh_keys_provided.py @@ -70,6 +70,7 @@ ssh_keys: 1M6G15dqjQ2XkNVOEnb5AAAAD3Jvb3RAeGVuaWFsLWx4ZAECAwQFBg== -----END OPENSSH PRIVATE KEY----- ed25519_public: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINudAZSu4vjZpVWzId5pXmZg1M6G15dqjQ2XkNVOEnb5 root@xenial-lxd + ed25519_certificate: ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIAGbMtat76PmaoqQ7B2lDvhnzE47psvMvmnPhz6f423ZAAAAINudAZSu4vjZpVWzId5pXmZg1M6G15dqjQ2XkNVOEnb5AAAAAAAAAAAAAAACAAAAA2x4ZAAAAAAAAAAAY+0LHAAAAABlzO1rAAAAAAAAAAAAAAAAAAABFwAAAAdzc2gtcnNhAAAAAwEAAQAAAQEAtPx6PqN3iSEsnTtibyIEy52Tra8T5fn0ryXyg46Di2NBwdnjo8trNv9jenfV/UhmePl58lXjT43wV8OCMl6KsYXyBdegM35NNtono4I4mLLKFMR99TOtDn6iYcaNenVhF3ZCj9Z2nNOlTrdc0uchHqKMrxLjCRCUrL91Uf+xioTF901YRM+ZqC5lT92yAL76F4qPF+Lq1QtUfNfUIwwvOp5ccDZLPxij0YvyBzubYye9hJHuyjbJv78R4JHV+L2WhzSoX3W/6WrxVzeXqFGqH894ccOaC/7tnqSP6V8lIQ6fE2+cDurJcpM3CJRgkndGHjtU55Y71YkcdLksSMvezQAAARQAAAAMcnNhLXNoYTItNTEyAAABAC8VDdaBkdt9jRW2Wh7A54rtbWyoafEtA8rud9UHgq3fSLFvWMBBe19/MJZXs+xWkdvSuG49ZeaEWi7ZO3SQaUbmXp2L5CH6TNnok3yo5QL2h01gP6+ydn98cA8lktvZt/+ihSqXpeSAg6S755W0zqlaeT5iyopSmNt4/wLh8FvgXR+TrAEe2EEXcPcLEXrBrPkjoLZ8j/pzLFJHHmlme/JcHPGMB7ksGG9nKr6ZViB3VPshdxP4iqpORv4Ro+UBUaS1AoHe0mZsccr7gKg7Xe6lhqHT2Fwlkk9B1zsWWUTjWU4TeG9FrJCjSAGCHLdHUszhCOsQHOOf9aR2095mbI8= root@xenial-lxd ecdsa_private: | -----BEGIN EC PRIVATE KEY----- MHcCAQEEIDuK+QFc1wmyJY8uDqQVa1qHte30Rk/fdLxGIBkwJAyOoAoGCCqGSM49 @@ -137,13 +138,15 @@ class TestSshKeysProvided: out = class_client.read_from_file(config_path).strip() assert expected_out in out - @pytest.mark.parametrize( - "expected_out", ("HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub") - ) - def test_sshd_config(self, expected_out, class_client): + def test_sshd_config(self, class_client): + expected_certs = ( + "HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub", + "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub", + ) if ImageSpecification.from_os_image().release in {"bionic"}: sshd_config_path = "/etc/ssh/sshd_config" else: sshd_config_path = "/etc/ssh/sshd_config.d/50-cloud-init.conf" sshd_config = class_client.read_from_file(sshd_config_path).strip() - assert expected_out in sshd_config + for expected_cert in expected_certs: + assert expected_cert in sshd_config diff --git a/tests/unittests/config/test_cc_ssh.py b/tests/unittests/config/test_cc_ssh.py index cc4032de..66368d0f 100644 --- a/tests/unittests/config/test_cc_ssh.py +++ b/tests/unittests/config/test_cc_ssh.py @@ -311,6 +311,7 @@ class TestHandleSsh: cfg = {"ssh_keys": {}} expected_calls = [] + cert_content = "" for key_type in cc_ssh.GENERATE_KEY_NAMES: private_name = "{}_private".format(key_type) public_name = "{}_public".format(key_type) @@ -342,14 +343,20 @@ class TestHandleSsh: cert_value, 0o644, ), - mock.call( - sshd_conf_fname, - "HostCertificate /etc/ssh/ssh_host_{}_key-cert.pub" - "\n".format(key_type), - preserve_mode=True, - ), ] ) + cert_content += ( + f"HostCertificate /etc/ssh/ssh_host_{key_type}_key-cert.pub\n" + ) + + expected_calls.append( + mock.call( + sshd_conf_fname, + cert_content, + omode="ab", + preserve_mode=True, + ) + ) # Run the handler. m_nug.return_value = ([], {}) diff --git a/tests/unittests/test_ssh_util.py b/tests/unittests/test_ssh_util.py index d6a72dc1..ff50dd11 100644 --- a/tests/unittests/test_ssh_util.py +++ b/tests/unittests/test_ssh_util.py @@ -3,6 +3,7 @@ import os import stat from functools import partial +from textwrap import dedent from typing import NamedTuple from unittest import mock from unittest.mock import patch @@ -477,6 +478,18 @@ class TestParseSSHConfig: assert expected_key == ret[0].key assert expected_value == ret[0].value + def test_duplicated_keys(self, m_is_file, m_load_file): + file_content = [ + "HostCertificate /data/ssh/ssh_host_rsa_cert", + "HostCertificate /data/ssh/ssh_host_ed25519_cert", + ] + m_is_file.return_value = True + m_load_file.return_value = "\n".join(file_content) + ret = ssh_util.parse_ssh_config("some real file") + assert len(file_content) == len(ret) + for i in range(len(file_content)): + assert file_content[i] == ret[i].line + class TestUpdateSshConfigLines: """Test the update_ssh_config_lines method.""" @@ -622,6 +635,31 @@ class TestUpdateSshConfig: assert not os.path.isfile(f"other_{mycfg}.d/50-cloud-init.conf") +class TestAppendSshConfig: + cfgdata = "\n".join(["#Option val", "MyKey ORIG_VAL", ""]) + + @mock.patch(M_PATH + "_ensure_cloud_init_ssh_config_file") + def test_append_ssh_config(self, m_ensure_cloud_init_config_file, tmpdir): + mycfg = tmpdir.join("ssh_config") + util.write_file(mycfg, self.cfgdata) + m_ensure_cloud_init_config_file.return_value = str(mycfg) + ssh_util.append_ssh_config( + [("MyKey", "NEW_VAL"), ("MyKey", "NEW_VAL_2")], mycfg + ) + found = util.load_file(mycfg) + expected_cfg = dedent( + """\ + #Option val + MyKey ORIG_VAL + MyKey NEW_VAL + MyKey NEW_VAL_2 + """ + ) + assert expected_cfg == found + # assert there is a newline at end of file (LP: #1677205) + assert "\n" == found[-1] + + class TestBasicAuthorizedKeyParse: @pytest.mark.parametrize( "value, homedir, username, expected_rendered", |