summaryrefslogtreecommitdiff
path: root/cloudinit/ssh_util.py
blob: ba252e7feafac702ce5276b69f727f5467803600 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#!/usr/bin/python
# vi: ts=4 expandtab
#
#    Copyright (C) 2012 Canonical Ltd.
#    Copyright (C) 2012 Hewlett-Packard Development Company, L.P.
#
#    Author: Scott Moser <scott.moser@canonical.com>
#    Author: Juerg Hafliger <juerg.haefliger@hp.com>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License version 3, as
#    published by the Free Software Foundation.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

from StringIO import StringIO

import csv
import os
import pwd

from cloudinit import log as logging
from cloudinit import util

LOG = logging.getLogger(__name__)
DEF_SSHD_CFG = "/etc/ssh/sshd_config"


class AuthKeyEntry(object):
    """
    AUTHORIZED_KEYS FILE FORMAT
     AuthorizedKeysFile specifies the file containing public keys for public
     key authentication; if none is specified, the default is
     ~/.ssh/authorized_keys.  Each line of the file contains one key (empty
     (because of the size of the public key encoding) up to a limit of 8 kilo-
     bytes, which permits DSA keys up to 8 kilobits and RSA keys up to 16
     kilobits.  You don't want to type them in; instead, copy the
     identity.pub, id_dsa.pub, or the id_rsa.pub file and edit it.

     sshd enforces a minimum RSA key modulus size for protocol 1 and protocol
     2 keys of 768 bits.

     The options (if present) consist of comma-separated option specifica-
     tions.  No spaces are permitted, except within double quotes.  The fol-
     lowing option specifications are supported (note that option keywords are
     case-insensitive):
    """

    def __init__(self, line, def_opt=None):
        self.line = str(line)
        (self.value, self.components) = self._parse(self.line, def_opt)

    def _extract_options(self, ent):
        """
        The options (if present) consist of comma-separated option specifica-
         tions.  No spaces are permitted, except within double quotes.
         Note that option keywords are case-insensitive.
        """
        quoted = False
        i = 0
        while (i < len(ent) and
               ((quoted) or (ent[i] not in (" ", "\t")))):
            curc = ent[i]
            if i + 1 >= len(ent):
                i = i + 1
                break
            nextc = ent[i + 1]
            if curc == "\\" and nextc == '"':
                i = i + 1
            elif curc == '"':
                quoted = not quoted
            i = i + 1
    
        options = ent[0:i]
        options_lst = []
        
        # Now use a csv parser to pull the options
        # out of the above string that we just found an endpoint for.
        #
        # No quoting so we don't mess up any of the quoting that
        # is already there.
        reader = csv.reader(StringIO(options), quoting=csv.QUOTE_NONE)
        for row in reader:
            for e in row:
                # Only keep non-empty csv options
                e = e.strip()
                if e:
                    options_lst.append(e)

        # Now take the rest of the items before the string
        # as long as there is room to do this...
        toks = []
        if i + 1 < len(ent):
            toks = ent[i + 1:].split(None, 3)
        return (options_lst, toks)

    def _form_components(self, toks):
        components = {}
        if len(toks) == 1:
            components['base64'] = toks[0]
        elif len(toks) == 2:
            components['base64'] = toks[0]
            components['comment'] = toks[1]
        elif len(toks) == 3:
            components['keytype'] = toks[0]
            components['base64'] = toks[1]
            components['comment'] = toks[2]
        return components

    def get(self, piece):
        return self.components.get(piece)

    def _parse(self, in_line, def_opt):
        line = in_line.rstrip("\r\n")
        if line.startswith("#") or line.strip() == '':
            return (False, {})
        else:
            ent = line.strip()
            toks = ent.split(None, 3)
            tmp_components = {}
            if def_opt:
                tmp_components['options'] = def_opt
            if len(toks) < 4:
                tmp_components.update(self._form_components(toks))
            else:
                (options, toks) = self._extract_options(ent)
                if options:
                    tmp_components['options'] = ",".join(options)
                tmp_components.update(self._form_components(toks))
            # We got some useful value!
            return (True, tmp_components)

    def __str__(self):
        if not self.value:
            return self.line
        else:
            toks = []
            if 'options' in self.components:
                toks.append(self.components['options'])
            if 'keytype' in self.components:
                toks.append(self.components['keytype'])
            if 'base64' in self.components:
                toks.append(self.components['base64'])
            if 'comment' in self.components:
                toks.append(self.components['comment'])
            if not toks:
                return ''
            return ' '.join(toks)


def update_authorized_keys(fname, keys):
    lines = []
    try:
        if os.path.isfile(fname):
            lines = util.load_file(fname).splitlines()
    except (IOError, OSError):
        util.logexc(LOG, "Error reading lines from %s", fname)
        lines = []

    to_add = list(keys)
    for i in range(0, len(lines)):
        ent = AuthKeyEntry(lines[i])
        if not ent.value:
            continue
        # Replace those with the same base64
        for k in keys:
            if not k.value:
                continue
            if k.get('base64') == ent.get('base64'):
                # Replace it with our better one
                ent = k
                # Don't add it later
                to_add.remove(k)
        lines[i] = str(ent)

    # Now append any entries we did not match above
    for key in to_add:
        lines.append(str(key))

    # Ensure it ends with a newline
    lines.append('')
    return '\n'.join(lines)


def setup_user_keys(keys, user, key_prefix, sshd_config_fn=None):
    if not sshd_config_fn:
        sshd_config_fn = DEF_SSHD_CFG

    pwent = pwd.getpwnam(user)
    ssh_dir = os.path.join(pwent.pw_dir, '.ssh')
    if not os.path.exists(ssh_dir):
        util.ensure_dir(ssh_dir, mode=0700)
        util.chownbyid(ssh_dir, pwent.pw_uid, pwent.pw_gid)

    key_entries = []
    for k in keys:
        key_entries.append(AuthKeyEntry(k, def_opt=key_prefix))

    with util.SeLinuxGuard(ssh_dir, recursive=True):
        try:
            # AuthorizedKeysFile may contain tokens
            # of the form %T which are substituted during connection set-up.
            # The following tokens are defined: %% is replaced by a literal
            # '%', %h is replaced by the home directory of the user being
            # authenticated and %u is replaced by the username of that user.
            ssh_cfg = parse_ssh_config(sshd_config_fn)
            akeys = ssh_cfg.get("authorizedkeysfile", '')
            akeys = akeys.strip()
            if not akeys:
                akeys = "%h/.ssh/authorized_keys"
            akeys = akeys.replace("%h", pwent.pw_dir)
            akeys = akeys.replace("%u", user)
            akeys = akeys.replace("%%", '%')
            if not akeys.startswith('/'):
                akeys = os.path.join(pwent.pw_dir, akeys)
            authorized_keys = akeys
        except (IOError, OSError):
            authorized_keys = os.path.join(ssh_dir, 'authorized_keys')
            util.logexc(LOG, ("Failed extracting 'AuthorizedKeysFile'"
                              " in ssh config"
                              " from %s, using 'AuthorizedKeysFile' file"
                              " %s instead"),
                        sshd_config_fn, authorized_keys)

        content = update_authorized_keys(authorized_keys, key_entries)
        util.ensure_dir(os.path.dirname(authorized_keys), mode=0700)
        util.write_file(authorized_keys, content, mode=0600)
        util.chownbyid(authorized_keys, pwent.pw_uid, pwent.pw_gid)


def parse_ssh_config(fname):
    # 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 = {}
    if not os.path.isfile(fname):
        return ret
    for line in util.load_file(fname).splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        (key, val) = line.split(None, 1)
        key = key.strip().lower()
        if key:
            ret[key] = val
    return ret