summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlberto Contreras <alberto.contreras@canonical.com>2023-02-16 15:39:32 +0100
committerGitHub <noreply@github.com>2023-02-16 08:39:32 -0600
commite4f56efce3e04e128c065ae15762b7ef5c02bcc9 (patch)
tree419c9a719a67030efbc86e5df5c52ef0a164a12a
parentbf06b3e6f16f9c3bf3a662d1a9f440dffc989fce (diff)
downloadcloud-init-git-e4f56efce3e04e128c065ae15762b7ef5c02bcc9.tar.gz
cc_ssh: support multiple hostcertificates (#2018)
LP: #1999164
-rw-r--r--cloudinit/config/cc_ssh.py7
-rw-r--r--cloudinit/ssh_util.py35
-rw-r--r--tests/integration_tests/modules/test_ssh_keys_provided.py13
-rw-r--r--tests/unittests/config/test_cc_ssh.py19
-rw-r--r--tests/unittests/test_ssh_util.py38
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",