Merge ~amzn-cmiller/cloud-init:ssh_authorizedkeys into cloud-init:master

Proposed by Chad
Status: Work in progress
Proposed branch: ~amzn-cmiller/cloud-init:ssh_authorizedkeys
Merge into: cloud-init:master
Diff against target: 137 lines (+91/-1)
2 files modified
cloudinit/ssh_util.py (+27/-0)
tests/unittests/test_sshutil.py (+64/-1)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Needs Fixing
Scott Moser Needs Information
Andrew Jorgensen Pending
Review via email: mp+365058@code.launchpad.net

Commit message

Mimic list-behavior of sshd AuthorizedKeyFile setting

Quoth sshd_config(5), "Multiple files may be listed, separated by whitespace". Use the first location specified as where to write
provided keys.

LP: #1404060

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:a8a3b5489fb29be2b2e7b462569f61698e9f9c62
https://jenkins.ubuntu.com/server/job/cloud-init-ci/649/
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/649/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

If you have evidence that shlex.split() is needed, then just provide it and I approve.

review: Needs Information
be685b5... by Chad

Split AuthorizedKeysFile as sshd does

Revision history for this message
Chad (amzn-cmiller) wrote :

We don't need `shlex.split()` per se, because it doesn't behave like sshd. Neither does "".split() .

Config listing `AuthorizedKeysFile /etc/ssh/"weird-file-name" %h/.ssh/authorized_keys`
looks for `/etc/ssh/weird-file-name`

Config listing `AuthorizedKeysFile "/etc/ssh/authkey /foo" %h/.ssh/authorized_keys`
looks for (as anticipated) `/etc/ssh/authkey /foo`

Config listing `AuthorizedKeysFile /etc/ssh/authkey\ /foo %h/.ssh/authorized_keys`
looks for `/etc/ssh/authkey\` !

shlex does both too much and too little. That ssh parsing is an irregular language, so a Regular Expression can't match or split it.

Your case of having quotes in the weird name would never have worked in ssh, and I don't see a way to include literal quotes (though I have not examined source code there).

But, backslash-space would not count as a literal space, but would in shlex.split() . I tested the directive in the comments in this patch to verify it behaves the same.

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

I've pointed CI at this updated branch.

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

FAILED: Continuous integration, rev:be685b51802f80dce57909d80771720d0606616c
https://jenkins.ubuntu.com/server/job/cloud-init-ci/672/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    FAILED: Ubuntu LTS: Integration

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

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

I've marked this branch Work-in-Progress. Once you've fixed the CI issue, please push changes and then mark the state back to Needs Review.

Unmerged commits

be685b5... by Chad

Split AuthorizedKeysFile as sshd does

a8a3b54... by Chad

Prove that space are allowed in files in authkeysfile directive

2476a40... by Matthew Yeazel

Add patch from smoser on LP bug

7e72941... by Matthew Yeazel

Fix flake error with import order

e77b7d5... by Matthew Yeazel

Fix more errors with flake8 and imports

8d11bc9... by Matthew Yeazel

Add test for one entry

e53d23b... by Matthew Yeazel

Fix issue with multiple AuthorizedKeysFile entries

If a user puts two entries in the AuthorizedKeysFile, cloud-init
will manage the keys in the first file.

https://bugs.launchpad.net/cloud-init/+bug/1404060

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/ssh_util.py b/cloudinit/ssh_util.py
2index 3f99b58..5f4a8f8 100644
3--- a/cloudinit/ssh_util.py
4+++ b/cloudinit/ssh_util.py
5@@ -8,6 +8,8 @@
6
7 import os
8 import pwd
9+import re
10+import shlex
11
12 from cloudinit import log as logging
13 from cloudinit import util
14@@ -176,6 +178,26 @@ def parse_authorized_keys(fname):
15 return contents
16
17
18+def file_list_split(file_list_string): # type: (Text) -> List(Text)
19+ r"""Split filename list like sshd does. The difference from shlex
20+ is insensitivity to backslash. So, escape all backslashes so shlex will
21+ leave them intact. We need shlex.split for its parser.
22+
23+ >>> file_list_split(r'a "b c" d\ e f\g h\\i')
24+ ['a', 'b c', 'd\\', 'e', 'f\\g', 'h\\\\i']
25+
26+ AuthorizedKeysFile a "b c" d\ e f\g h\\i
27+
28+ Openssh (7.2) searches for these names (in user home because
29+ nonabsolute names), 'a' 'b c' 'd\' 'e' 'f\g' 'h\\i'
30+ which show up in log files with one layer of backslash-escaping.
31+
32+ @param file_list_string: filenames separated by space, opt quoted
33+ @return: each filename as element of list
34+ """
35+ return shlex.split(re.sub(r"\\", r"\\\\", file_list_string))
36+
37+
38 def update_authorized_keys(old_entries, keys):
39 to_add = list([k for k in keys if k.valid()])
40 for i in range(0, len(old_entries)):
41@@ -223,6 +245,11 @@ def extract_authorized_keys(username):
42 # authenticated and %u is replaced by the username of that user.
43 ssh_cfg = parse_ssh_config_map(DEF_SSHD_CFG)
44 auth_key_fn = ssh_cfg.get("authorizedkeysfile", '').strip()
45+ # AuthorizedKeysFile can have multiple entries separated with
46+ # whitespace, this will set auth_key_fn to the first entry, we
47+ # will manage keys in the first file listed, but not break
48+ # configuration for more than one file.
49+ auth_key_fn = file_list_split(auth_key_fn)[0]
50 if not auth_key_fn:
51 auth_key_fn = "%h/.ssh/authorized_keys"
52 auth_key_fn = auth_key_fn.replace("%h", pw_ent.pw_dir)
53diff --git a/tests/unittests/test_sshutil.py b/tests/unittests/test_sshutil.py
54index 73ae897..9756f9b 100644
55--- a/tests/unittests/test_sshutil.py
56+++ b/tests/unittests/test_sshutil.py
57@@ -1,11 +1,15 @@
58 # This file is part of cloud-init. See LICENSE file for license information.
59
60-from mock import patch
61+from cloudinit.tests.helpers import mock
62+
63+from collections import namedtuple
64
65 from cloudinit import ssh_util
66 from cloudinit.tests import helpers as test_helpers
67 from cloudinit import util
68
69+from mock import patch
70+
71
72 VALID_CONTENT = {
73 'dsa': (
74@@ -326,4 +330,63 @@ class TestUpdateSshConfig(test_helpers.CiTestCase):
75 m_write_file.assert_not_called()
76
77
78+def _dummy_users_ssh_info(username):
79+ # Faking a passwd entry for mocking
80+ kwargs = {"pw_name": username, "pw_passwd": "***",
81+ "pw_uid": 101, "pw_gid": 101, "pw_gecos": username,
82+ "pw_dir": "/home/{}".format(username), "pw_shell": "/bin/bash"}
83+ Passwd = namedtuple("Passwd", kwargs.keys())
84+ return '/home/{}/.ssh'.format(username), Passwd(**kwargs)
85+
86+
87+class TestExtractAuthorizedKeys(test_helpers.TestCase):
88+
89+ @mock.patch("cloudinit.ssh_util.users_ssh_info")
90+ @mock.patch("cloudinit.ssh_util.parse_ssh_config_map")
91+ def test_one_entry(self, m_parse_ssh_config_map, m_users_ssh_info):
92+ one_entry = '%h/.ssh/authorized_keys'
93+ m_users_ssh_info.side_effect = _dummy_users_ssh_info
94+ m_parse_ssh_config_map.return_value = {
95+ "authorizedkeysfile": one_entry
96+ }
97+
98+ ret = ssh_util.extract_authorized_keys('testuser')
99+ self.assertEqual('/home/testuser/.ssh/authorized_keys', ret[0])
100+
101+ @mock.patch("cloudinit.ssh_util.users_ssh_info")
102+ @mock.patch("cloudinit.ssh_util.parse_ssh_config_map")
103+ def test_two_entries(self, m_parse_ssh_config_map, m_users_ssh_info):
104+ both_entries = '%h/.ssh/authorized_keys /etc/ssh/authorized_keys/%u'
105+ m_users_ssh_info.side_effect = _dummy_users_ssh_info
106+ m_parse_ssh_config_map.return_value = {
107+ "authorizedkeysfile": both_entries
108+ }
109+
110+ ret = ssh_util.extract_authorized_keys('testuser')
111+ self.assertEqual('/home/testuser/.ssh/authorized_keys', ret[0])
112+
113+ @mock.patch("cloudinit.ssh_util.users_ssh_info")
114+ @mock.patch("cloudinit.ssh_util.parse_ssh_config_map")
115+ def test_quoted_name(self, m_parse_ssh_config_map, m_users_ssh_info):
116+ both_entries = '"/wi"t"h spa"ces/ak "second"'
117+ m_users_ssh_info.side_effect = _dummy_users_ssh_info
118+ m_parse_ssh_config_map.return_value = {
119+ "authorizedkeysfile": both_entries
120+ }
121+
122+ ret = ssh_util.extract_authorized_keys('testuser')
123+ self.assertEqual('/with spaces/ak', ret[0])
124+
125+ @mock.patch("cloudinit.ssh_util.users_ssh_info")
126+ @mock.patch("cloudinit.ssh_util.parse_ssh_config_map")
127+ def test_backslash_ignore(self, m_parse_ssh_config_map, m_users_ssh_info):
128+ both_entries = r'/first\ second'
129+ m_users_ssh_info.side_effect = _dummy_users_ssh_info
130+ m_parse_ssh_config_map.return_value = {
131+ "authorizedkeysfile": both_entries
132+ }
133+
134+ ret = ssh_util.extract_authorized_keys('testuser')
135+ self.assertEqual('/first\\', ret[0])
136+
137 # vi: ts=4 expandtab

Subscribers

People subscribed via source and target branches