SSH utility functions for key manipulation
[ganeti-github.git] / lib / ssh.py
1 #
2 #
3
4 # Copyright (C) 2006, 2007, 2010, 2011 Google Inc.
5 # All rights reserved.
6 #
7 # Redistribution and use in source and binary forms, with or without
8 # modification, are permitted provided that the following conditions are
9 # met:
10 #
11 # 1. Redistributions of source code must retain the above copyright notice,
12 # this list of conditions and the following disclaimer.
13 #
14 # 2. Redistributions in binary form must reproduce the above copyright
15 # notice, this list of conditions and the following disclaimer in the
16 # documentation and/or other materials provided with the distribution.
17 #
18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
19 # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
20 # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
21 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
22 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
23 # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
24 # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
25 # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
26 # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
27 # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30
31 """Module encapsulating ssh functionality.
32
33 """
34
35
36 import logging
37 import os
38 import shutil
39 import tempfile
40
41 from collections import namedtuple
42 from functools import partial
43
44 from ganeti import utils
45 from ganeti import errors
46 from ganeti import constants
47 from ganeti import netutils
48 from ganeti import pathutils
49 from ganeti import vcluster
50 from ganeti import compat
51 from ganeti import serializer
52 from ganeti import ssconf
53
54
55 def GetUserFiles(user, mkdir=False, dircheck=True, kind=constants.SSHK_DSA,
56 _homedir_fn=None):
57 """Return the paths of a user's SSH files.
58
59 @type user: string
60 @param user: Username
61 @type mkdir: bool
62 @param mkdir: Whether to create ".ssh" directory if it doesn't exist
63 @type dircheck: bool
64 @param dircheck: Whether to check if ".ssh" directory exists
65 @type kind: string
66 @param kind: One of L{constants.SSHK_ALL}
67 @rtype: tuple; (string, string, string)
68 @return: Tuple containing three file system paths; the private SSH key file,
69 the public SSH key file and the user's C{authorized_keys} file
70 @raise errors.OpExecError: When home directory of the user can not be
71 determined
72 @raise errors.OpExecError: Regardless of the C{mkdir} parameters, this
73 exception is raised if C{~$user/.ssh} is not a directory and C{dircheck}
74 is set to C{True}
75
76 """
77 if _homedir_fn is None:
78 _homedir_fn = utils.GetHomeDir
79
80 user_dir = _homedir_fn(user)
81 if not user_dir:
82 raise errors.OpExecError("Cannot resolve home of user '%s'" % user)
83
84 if kind == constants.SSHK_DSA:
85 suffix = "dsa"
86 elif kind == constants.SSHK_RSA:
87 suffix = "rsa"
88 elif kind == constants.SSHK_ECDSA:
89 suffix = "ecdsa"
90 else:
91 raise errors.ProgrammerError("Unknown SSH key kind '%s'" % kind)
92
93 ssh_dir = utils.PathJoin(user_dir, ".ssh")
94 if mkdir:
95 utils.EnsureDirs([(ssh_dir, constants.SECURE_DIR_MODE)])
96 elif dircheck and not os.path.isdir(ssh_dir):
97 raise errors.OpExecError("Path %s is not a directory" % ssh_dir)
98
99 return [utils.PathJoin(ssh_dir, base)
100 for base in ["id_%s" % suffix, "id_%s.pub" % suffix,
101 "authorized_keys"]]
102
103
104 def GetAllUserFiles(user, mkdir=False, dircheck=True, _homedir_fn=None):
105 """Wrapper over L{GetUserFiles} to retrieve files for all SSH key types.
106
107 See L{GetUserFiles} for details.
108
109 @rtype: tuple; (string, dict with string as key, tuple of (string, string) as
110 value)
111
112 """
113 helper = compat.partial(GetUserFiles, user, mkdir=mkdir, dircheck=dircheck,
114 _homedir_fn=_homedir_fn)
115 result = [(kind, helper(kind=kind)) for kind in constants.SSHK_ALL]
116
117 authorized_keys = [i for (_, (_, _, i)) in result]
118
119 assert len(frozenset(authorized_keys)) == 1, \
120 "Different paths for authorized_keys were returned"
121
122 return (authorized_keys[0],
123 dict((kind, (privkey, pubkey))
124 for (kind, (privkey, pubkey, _)) in result))
125
126
127 def _SplitSshKey(key):
128 """Splits a line for SSH's C{authorized_keys} file.
129
130 If the line has no options (e.g. no C{command="..."}), only the significant
131 parts, the key type and its hash, are used. Otherwise the whole line is used
132 (split at whitespace).
133
134 @type key: string
135 @param key: Key line
136 @rtype: tuple
137
138 """
139 parts = key.split()
140
141 if parts and parts[0] in constants.SSHAK_ALL:
142 # If the key has no options in front of it, we only want the significant
143 # fields
144 return (False, parts[:2])
145 else:
146 # Can't properly split the line, so use everything
147 return (True, parts)
148
149
150 def AddAuthorizedKeys(file_obj, keys):
151 """Adds a list of SSH public key to an authorized_keys file.
152
153 @type file_obj: str or file handle
154 @param file_obj: path to authorized_keys file
155 @type keys: list of str
156 @param keys: list of strings containing keys
157
158 """
159 key_field_list = [(key, _SplitSshKey(key)) for key in keys]
160
161 if isinstance(file_obj, basestring):
162 f = open(file_obj, "a+")
163 else:
164 f = file_obj
165
166 try:
167 nl = True
168 for line in f:
169 # Ignore whitespace changes
170 line_key = _SplitSshKey(line)
171 key_field_list[:] = [(key, split_key) for (key, split_key)
172 in key_field_list
173 if split_key != line_key]
174 nl = line.endswith("\n")
175 else:
176 if not nl:
177 f.write("\n")
178 for (key, _) in key_field_list:
179 f.write(key.rstrip("\r\n"))
180 f.write("\n")
181 f.flush()
182 finally:
183 f.close()
184
185
186 def HasAuthorizedKey(file_obj, key):
187 """Check if a particular key is in the 'authorized_keys' file.
188
189 @type file_obj: str or file handle
190 @param file_obj: path to authorized_keys file
191 @type key: str
192 @param key: string containing key
193
194 """
195 key_fields = _SplitSshKey(key)
196
197 if isinstance(file_obj, basestring):
198 f = open(file_obj, "r")
199 else:
200 f = file_obj
201
202 try:
203 for line in f:
204 # Ignore whitespace changes
205 line_key = _SplitSshKey(line)
206 if line_key == key_fields:
207 return True
208 finally:
209 f.close()
210
211 return False
212
213
214 def CheckForMultipleKeys(file_obj, node_names):
215 """Check if there is at most one key per host in 'authorized_keys' file.
216
217 @type file_obj: str or file handle
218 @param file_obj: path to authorized_keys file
219 @type node_names: list of str
220 @param node_names: list of names of nodes of the cluster
221 @returns: a dictionary with hostnames which occur more than once
222
223 """
224
225 if isinstance(file_obj, basestring):
226 f = open(file_obj, "r")
227 else:
228 f = file_obj
229
230 occurrences = {}
231
232 try:
233 index = 0
234 for line in f:
235 index += 1
236 if line.startswith("#"):
237 continue
238 chunks = line.split()
239 # find the chunk with user@hostname
240 user_hostname = [chunk.strip() for chunk in chunks if "@" in chunk][0]
241 if not user_hostname in occurrences:
242 occurrences[user_hostname] = []
243 occurrences[user_hostname].append(index)
244 finally:
245 f.close()
246
247 bad_occurrences = {}
248 for user_hostname, occ in occurrences.items():
249 _, hostname = user_hostname.split("@")
250 if hostname in node_names and len(occ) > 1:
251 bad_occurrences[user_hostname] = occ
252
253 return bad_occurrences
254
255
256 def AddAuthorizedKey(file_obj, key):
257 """Adds an SSH public key to an authorized_keys file.
258
259 @type file_obj: str or file handle
260 @param file_obj: path to authorized_keys file
261 @type key: str
262 @param key: string containing key
263
264 """
265 AddAuthorizedKeys(file_obj, [key])
266
267
268 def RemoveAuthorizedKeys(file_name, keys):
269 """Removes public SSH keys from an authorized_keys file.
270
271 @type file_name: str
272 @param file_name: path to authorized_keys file
273 @type keys: list of str
274 @param keys: list of strings containing keys
275
276 """
277 key_field_list = [_SplitSshKey(key) for key in keys]
278
279 fd, tmpname = tempfile.mkstemp(dir=os.path.dirname(file_name))
280 try:
281 out = os.fdopen(fd, "w")
282 try:
283 f = open(file_name, "r")
284 try:
285 for line in f:
286 # Ignore whitespace changes while comparing lines
287 if _SplitSshKey(line) not in key_field_list:
288 out.write(line)
289
290 out.flush()
291 os.rename(tmpname, file_name)
292 finally:
293 f.close()
294 finally:
295 out.close()
296 except:
297 utils.RemoveFile(tmpname)
298 raise
299
300
301 def RemoveAuthorizedKey(file_name, key):
302 """Removes an SSH public key from an authorized_keys file.
303
304 @type file_name: str
305 @param file_name: path to authorized_keys file
306 @type key: str
307 @param key: string containing key
308
309 """
310 RemoveAuthorizedKeys(file_name, [key])
311
312
313 def _AddPublicKeyProcessLine(new_uuid, new_key, line_uuid, line_key, found):
314 """Processes one line of the public key file when adding a key.
315
316 This is a sub function that can be called within the
317 C{_ManipulatePublicKeyFile} function. It processes one line of the public
318 key file, checks if this line contains the key to add already and if so,
319 notes the occurrence in the return value.
320
321 @type new_uuid: string
322 @param new_uuid: the node UUID of the node whose key is added
323 @type new_key: string
324 @param new_key: the SSH key to be added
325 @type line_uuid: the UUID of the node whose line in the public key file
326 is processed in this function call
327 @param line_key: the SSH key of the node whose line in the public key
328 file is processed in this function call
329 @type found: boolean
330 @param found: whether or not the (UUID, key) pair of the node whose key
331 is being added was found in the public key file already.
332 @rtype: (boolean, string)
333 @return: a possibly updated value of C{found} and the processed line
334
335 """
336 if line_uuid == new_uuid and line_key == new_key:
337 logging.debug("SSH key of node '%s' already in key file.", new_uuid)
338 found = True
339 return (found, "%s %s\n" % (line_uuid, line_key))
340
341
342 def _AddPublicKeyElse(new_uuid, new_key):
343 """Adds a new SSH key to the key file if it did not exist already.
344
345 This is an auxiliary function for C{_ManipulatePublicKeyFile} which
346 is carried out when a new key is added to the public key file and
347 after processing the whole file, we found out that the key does
348 not exist in the file yet but needs to be appended at the end.
349
350 @type new_uuid: string
351 @param new_uuid: the UUID of the node whose key is added
352 @type new_key: string
353 @param new_key: the SSH key to be added
354 @rtype: string
355 @return: a new line to be added to the file
356
357 """
358 return "%s %s\n" % (new_uuid, new_key)
359
360
361 def _RemovePublicKeyProcessLine(
362 target_uuid, _target_key,
363 line_uuid, line_key, found):
364 """Processes a line in the public key file when aiming for removing a key.
365
366 This is an auxiliary function for C{_ManipulatePublicKeyFile} when we
367 are removing a key from the public key file. This particular function
368 only checks if the current line contains the UUID of the node in
369 question and writes the line to the temporary file otherwise.
370
371 @type target_uuid: string
372 @param target_uuid: UUID of the node whose key is being removed
373 @type _target_key: string
374 @param _target_key: SSH key of the node (not used)
375 @type line_uuid: string
376 @param line_uuid: UUID of the node whose line is processed in this call
377 @type line_key: string
378 @param line_key: SSH key of the nodes whose line is processed in this call
379 @type found: boolean
380 @param found: whether or not the UUID was already found.
381 @rtype: (boolean, string)
382 @return: a tuple, indicating if the target line was found and the processed
383 line; the line is 'None', if the original line is removed
384
385 """
386 if line_uuid != target_uuid:
387 return (found, "%s %s\n" % (line_uuid, line_key))
388 else:
389 return (True, None)
390
391
392 def _RemovePublicKeyElse(
393 target_uuid, _target_key):
394 """Logs when we tried to remove a key that does not exist.
395
396 This is an auxiliary function for C{_ManipulatePublicKeyFile} which is
397 run after we have processed the complete public key file and did not find
398 the key to be removed.
399
400 @type target_uuid: string
401 @param target_uuid: the UUID of the node whose key was supposed to be removed
402 @type _target_key: string
403 @param _target_key: the key of the node which was supposed to be removed
404 (not used)
405 @rtype: string
406 @return: in this case, always None
407
408 """
409 logging.debug("Trying to remove key of node '%s' which is not in list"
410 " of public keys.", target_uuid)
411 return None
412
413
414 def _ReplaceNameByUuidProcessLine(
415 node_name, _key, line_identifier, line_key, found, node_uuid=None):
416 """Replaces a node's name with its UUID on a matching line in the key file.
417
418 This is an auxiliary function for C{_ManipulatePublicKeyFile} which processes
419 a line of the ganeti public key file. If the line in question matches the
420 node's name, the name will be replaced by the node's UUID.
421
422 @type node_name: string
423 @param node_name: name of the node to be replaced by the UUID
424 @type _key: string
425 @param _key: SSH key of the node (not used)
426 @type line_identifier: string
427 @param line_identifier: an identifier of a node in a line of the public key
428 file. This can be either a node name or a node UUID, depending on if it
429 got replaced already or not.
430 @type line_key: string
431 @param line_key: SSH key of the node whose line is processed
432 @type found: boolean
433 @param found: whether or not the line matches the node's name
434 @type node_uuid: string
435 @param node_uuid: the node's UUID which will replace the node name
436 @rtype: (boolean, string)
437 @return: a tuple indicating whether the target line was found and the
438 processed line
439
440 """
441 if node_name == line_identifier:
442 return (True, "%s %s\n" % (node_uuid, line_key))
443 else:
444 return (found, "%s %s\n" % (line_identifier, line_key))
445
446
447 def _ReplaceNameByUuidElse(
448 node_uuid, node_name, _key):
449 """Logs a debug message when we try to replace a key that is not there.
450
451 This is an implementation of the auxiliary C{process_else_fn} function for
452 the C{_ManipulatePubKeyFile} function when we use it to replace a line
453 in the public key file that is indexed by the node's name instead of the
454 node's UUID.
455
456 @type node_uuid: string
457 @param node_uuid: the node's UUID
458 @type node_name: string
459 @param node_name: the node's UUID
460 @type _key: string (not used)
461 @param _key: the node's SSH key (not used)
462 @rtype: string
463 @return: in this case, always None
464
465 """
466 logging.debug("Trying to replace node name '%s' with UUID '%s', but"
467 " no line with that name was found.", node_name, node_uuid)
468 return None
469
470
471 def _ParseKeyLine(line, error_fn):
472 """Parses a line of the public key file.
473
474 @type line: string
475 @param line: line of the public key file
476 @type error_fn: function
477 @param error_fn: function to process error messages
478 @rtype: tuple (string, string)
479 @return: a tuple containing the UUID of the node and a string containing
480 the SSH key and possible more parameters for the key
481
482 """
483 if len(line.rstrip()) == 0:
484 return (None, None)
485 chunks = line.split(" ")
486 if len(chunks) < 2:
487 raise error_fn("Error parsing public SSH key file. Line: '%s'"
488 % line)
489 uuid = chunks[0]
490 key = " ".join(chunks[1:]).rstrip()
491 return (uuid, key)
492
493
494 def _ManipulatePubKeyFile(target_identifier, target_key,
495 key_file=pathutils.SSH_PUB_KEYS,
496 error_fn=errors.ProgrammerError,
497 process_line_fn=None, process_else_fn=None):
498 """Manipulates the list of public SSH keys of the cluster.
499
500 This is a general function to manipulate the public key file. It needs
501 two auxiliary functions C{process_line_fn} and C{process_else_fn} to
502 work. Generally, the public key file is processed as follows:
503 1) The function processes each line of the original ganeti public key file,
504 applies the C{process_line_fn} function on it, which returns a possibly
505 manipulated line and an indicator whether the line in question was found.
506 If a line is returned, it is added to a list of lines for later writing
507 to the file.
508 2) If all lines are processed and the 'found' variable is False, the
509 seconds auxiliary function C{process_else_fn} is called to possibly
510 add more lines to the list of lines.
511 3) Finally, the list of lines is assembled to a string and written
512 atomically to the public key file, thereby overriding it.
513
514 If the public key file does not exist, we create it. This is necessary for
515 a smooth transition after an upgrade.
516
517 @type target_identifier: str
518 @param target_identifier: identifier of the node whose key is added; in most
519 cases this is the node's UUID, but in some it is the node's host name
520 @type target_key: str
521 @param target_key: string containing a public SSH key (a complete line
522 possibly including more parameters than just the key)
523 @type key_file: str
524 @param key_file: filename of the file of public node keys (optional
525 parameter for testing)
526 @type error_fn: function
527 @param error_fn: Function that returns an exception, used to customize
528 exception types depending on the calling context
529 @type process_line_fn: function
530 @param process_line_fn: function to process one line of the public key file
531 @type process_else_fn: function
532 @param process_else_fn: function to be called if no line of the key file
533 matches the target uuid
534
535 """
536 assert process_else_fn is not None
537 assert process_line_fn is not None
538
539 old_lines = []
540 f_orig = None
541 if os.path.exists(key_file):
542 try:
543 f_orig = open(key_file, "r")
544 old_lines = f_orig.readlines()
545 finally:
546 f_orig.close()
547 else:
548 try:
549 f_orig = open(key_file, "w")
550 f_orig.close()
551 except IOError as e:
552 raise errors.SshUpdateError("Cannot create public key file: %s" % e)
553
554 found = False
555 new_lines = []
556 for line in old_lines:
557 (uuid, key) = _ParseKeyLine(line, error_fn)
558 if not uuid:
559 continue
560 (new_found, new_line) = process_line_fn(target_identifier, target_key,
561 uuid, key, found)
562 if new_found:
563 found = True
564 if new_line is not None:
565 new_lines.append(new_line)
566 if not found:
567 new_line = process_else_fn(target_identifier, target_key)
568 if new_line is not None:
569 new_lines.append(new_line)
570 new_file_content = "".join(new_lines)
571 utils.WriteFile(key_file, data=new_file_content)
572
573
574 def AddPublicKey(new_uuid, new_key, key_file=pathutils.SSH_PUB_KEYS,
575 error_fn=errors.ProgrammerError):
576 """Adds a new key to the list of public keys.
577
578 @see: _ManipulatePubKeyFile for parameter descriptions.
579
580 """
581 _ManipulatePubKeyFile(new_uuid, new_key, key_file=key_file,
582 process_line_fn=_AddPublicKeyProcessLine,
583 process_else_fn=_AddPublicKeyElse,
584 error_fn=error_fn)
585
586
587 def RemovePublicKey(target_uuid, key_file=pathutils.SSH_PUB_KEYS,
588 error_fn=errors.ProgrammerError):
589 """Removes a key from the list of public keys.
590
591 @see: _ManipulatePubKeyFile for parameter descriptions.
592
593 """
594 _ManipulatePubKeyFile(target_uuid, None, key_file=key_file,
595 process_line_fn=_RemovePublicKeyProcessLine,
596 process_else_fn=_RemovePublicKeyElse,
597 error_fn=error_fn)
598
599
600 def ReplaceNameByUuid(node_uuid, node_name, key_file=pathutils.SSH_PUB_KEYS,
601 error_fn=errors.ProgrammerError):
602 """Replaces a host name with the node's corresponding UUID.
603
604 When a node is added to the cluster, we don't know it's UUID yet. So first
605 its SSH key gets added to the public key file and in a second step, the
606 node's name gets replaced with the node's UUID as soon as we know the UUID.
607
608 @type node_uuid: string
609 @param node_uuid: the node's UUID to replace the node's name
610 @type node_name: string
611 @param node_name: the node's name to be replaced by the node's UUID
612
613 @see: _ManipulatePubKeyFile for the other parameter descriptions.
614
615 """
616 process_line_fn = partial(_ReplaceNameByUuidProcessLine, node_uuid=node_uuid)
617 process_else_fn = partial(_ReplaceNameByUuidElse, node_uuid=node_uuid)
618 _ManipulatePubKeyFile(node_name, None, key_file=key_file,
619 process_line_fn=process_line_fn,
620 process_else_fn=process_else_fn,
621 error_fn=error_fn)
622
623
624 def ClearPubKeyFile(key_file=pathutils.SSH_PUB_KEYS, mode=0600):
625 """Resets the content of the public key file.
626
627 """
628 utils.WriteFile(key_file, data="", mode=mode)
629
630
631 def OverridePubKeyFile(key_map, key_file=pathutils.SSH_PUB_KEYS):
632 """Overrides the public key file with a list of given keys.
633
634 @type key_map: dict from str to list of str
635 @param key_map: dictionary mapping uuids to lists of SSH keys
636
637 """
638 new_lines = []
639 for (uuid, keys) in key_map.items():
640 for key in keys:
641 new_lines.append("%s %s\n" % (uuid, key))
642 new_file_content = "".join(new_lines)
643 utils.WriteFile(key_file, data=new_file_content)
644
645
646 def QueryPubKeyFile(target_uuids, key_file=pathutils.SSH_PUB_KEYS,
647 error_fn=errors.ProgrammerError):
648 """Retrieves a map of keys for the requested node UUIDs.
649
650 @type target_uuids: str or list of str
651 @param target_uuids: UUID of the node to retrieve the key for or a list
652 of UUIDs of nodes to retrieve the keys for
653 @type key_file: str
654 @param key_file: filename of the file of public node keys (optional
655 parameter for testing)
656 @type error_fn: function
657 @param error_fn: Function that returns an exception, used to customize
658 exception types depending on the calling context
659 @rtype: dict mapping strings to list of strings
660 @return: dictionary mapping node uuids to their ssh keys
661
662 """
663 all_keys = target_uuids is None
664 if isinstance(target_uuids, str):
665 target_uuids = [target_uuids]
666 result = {}
667 f = open(key_file, "r")
668 try:
669 for line in f:
670 (uuid, key) = _ParseKeyLine(line, error_fn)
671 if not uuid:
672 continue
673 if all_keys or (uuid in target_uuids):
674 if uuid not in result:
675 result[uuid] = []
676 result[uuid].append(key)
677 finally:
678 f.close()
679 return result
680
681
682 def InitSSHSetup(key_type, key_bits, error_fn=errors.OpPrereqError,
683 _homedir_fn=None, _suffix=""):
684 """Setup the SSH configuration for the node.
685
686 This generates a dsa keypair for root, adds the pub key to the
687 permitted hosts and adds the hostkey to its own known hosts.
688
689 @param key_type: the type of SSH keypair to be generated
690 @param key_bits: the key length, in bits, to be used
691
692 """
693 priv_key, _, auth_keys = GetUserFiles(constants.SSH_LOGIN_USER, kind=key_type,
694 mkdir=True, _homedir_fn=_homedir_fn)
695
696 new_priv_key_name = priv_key + _suffix
697 new_pub_key_name = priv_key + _suffix + ".pub"
698
699 for name in new_priv_key_name, new_pub_key_name:
700 if os.path.exists(name):
701 utils.CreateBackup(name)
702 utils.RemoveFile(name)
703
704 result = utils.RunCmd(["ssh-keygen", "-b", str(key_bits), "-t", key_type,
705 "-f", new_priv_key_name,
706 "-q", "-N", ""])
707 if result.failed:
708 raise error_fn("Could not generate ssh keypair, error %s" %
709 result.output)
710
711 AddAuthorizedKey(auth_keys, utils.ReadFile(new_pub_key_name))
712
713
714 def InitPubKeyFile(master_uuid, key_type, key_file=pathutils.SSH_PUB_KEYS):
715 """Creates the public key file and adds the master node's SSH key.
716
717 @type master_uuid: str
718 @param master_uuid: the master node's UUID
719 @type key_type: one of L{constants.SSHK_ALL}
720 @param key_type: the type of ssh key to be used
721 @type key_file: str
722 @param key_file: name of the file containing the public keys
723
724 """
725 _, pub_key, _ = GetUserFiles(constants.SSH_LOGIN_USER, kind=key_type)
726 ClearPubKeyFile(key_file=key_file)
727 key = utils.ReadFile(pub_key)
728 AddPublicKey(master_uuid, key, key_file=key_file)
729
730
731 class SshRunner:
732 """Wrapper for SSH commands.
733
734 """
735 def __init__(self, cluster_name):
736 """Initializes this class.
737
738 @type cluster_name: str
739 @param cluster_name: name of the cluster
740
741 """
742 self.cluster_name = cluster_name
743 family = ssconf.SimpleStore().GetPrimaryIPFamily()
744 self.ipv6 = (family == netutils.IP6Address.family)
745
746 def _BuildSshOptions(self, batch, ask_key, use_cluster_key,
747 strict_host_check, private_key=None, quiet=True,
748 port=None):
749 """Builds a list with needed SSH options.
750
751 @param batch: same as ssh's batch option
752 @param ask_key: allows ssh to ask for key confirmation; this
753 parameter conflicts with the batch one
754 @param use_cluster_key: if True, use the cluster name as the
755 HostKeyAlias name
756 @param strict_host_check: this makes the host key checking strict
757 @param private_key: use this private key instead of the default
758 @param quiet: whether to enable -q to ssh
759 @param port: the SSH port to use, or None to use the default
760
761 @rtype: list
762 @return: the list of options ready to use in L{utils.process.RunCmd}
763
764 """
765 options = [
766 "-oEscapeChar=none",
767 "-oHashKnownHosts=no",
768 "-oGlobalKnownHostsFile=%s" % pathutils.SSH_KNOWN_HOSTS_FILE,
769 "-oUserKnownHostsFile=/dev/null",
770 "-oCheckHostIp=no",
771 ]
772
773 if use_cluster_key:
774 options.append("-oHostKeyAlias=%s" % self.cluster_name)
775
776 if quiet:
777 options.append("-q")
778
779 if private_key:
780 options.append("-i%s" % private_key)
781
782 if port:
783 options.append("-oPort=%d" % port)
784
785 # TODO: Too many boolean options, maybe convert them to more descriptive
786 # constants.
787
788 # Note: ask_key conflicts with batch mode
789 if batch:
790 if ask_key:
791 raise errors.ProgrammerError("SSH call requested conflicting options")
792
793 options.append("-oBatchMode=yes")
794
795 if strict_host_check:
796 options.append("-oStrictHostKeyChecking=yes")
797 else:
798 options.append("-oStrictHostKeyChecking=no")
799
800 else:
801 # non-batch mode
802
803 if ask_key:
804 options.append("-oStrictHostKeyChecking=ask")
805 elif strict_host_check:
806 options.append("-oStrictHostKeyChecking=yes")
807 else:
808 options.append("-oStrictHostKeyChecking=no")
809
810 if self.ipv6:
811 options.append("-6")
812 else:
813 options.append("-4")
814
815 return options
816
817 def BuildCmd(self, hostname, user, command, batch=True, ask_key=False,
818 tty=False, use_cluster_key=True, strict_host_check=True,
819 private_key=None, quiet=True, port=None):
820 """Build an ssh command to execute a command on a remote node.
821
822 @param hostname: the target host, string
823 @param user: user to auth as
824 @param command: the command
825 @param batch: if true, ssh will run in batch mode with no prompting
826 @param ask_key: if true, ssh will run with
827 StrictHostKeyChecking=ask, so that we can connect to an
828 unknown host (not valid in batch mode)
829 @param use_cluster_key: whether to expect and use the
830 cluster-global SSH key
831 @param strict_host_check: whether to check the host's SSH key at all
832 @param private_key: use this private key instead of the default
833 @param quiet: whether to enable -q to ssh
834 @param port: the SSH port on which the node's daemon is running
835
836 @return: the ssh call to run 'command' on the remote host.
837
838 """
839 argv = [constants.SSH]
840 argv.extend(self._BuildSshOptions(batch, ask_key, use_cluster_key,
841 strict_host_check, private_key,
842 quiet=quiet, port=port))
843 if tty:
844 argv.extend(["-t", "-t"])
845
846 argv.append("%s@%s" % (user, hostname))
847
848 # Insert variables for virtual nodes
849 argv.extend("export %s=%s;" %
850 (utils.ShellQuote(name), utils.ShellQuote(value))
851 for (name, value) in
852 vcluster.EnvironmentForHost(hostname).items())
853
854 argv.append(command)
855
856 return argv
857
858 def Run(self, *args, **kwargs):
859 """Runs a command on a remote node.
860
861 This method has the same return value as `utils.RunCmd()`, which it
862 uses to launch ssh.
863
864 Args: see SshRunner.BuildCmd.
865
866 @rtype: L{utils.process.RunResult}
867 @return: the result as from L{utils.process.RunCmd()}
868
869 """
870 return utils.RunCmd(self.BuildCmd(*args, **kwargs))
871
872 def CopyFileToNode(self, node, port, filename):
873 """Copy a file to another node with scp.
874
875 @param node: node in the cluster
876 @param filename: absolute pathname of a local file
877
878 @rtype: boolean
879 @return: the success of the operation
880
881 """
882 if not os.path.isabs(filename):
883 logging.error("File %s must be an absolute path", filename)
884 return False
885
886 if not os.path.isfile(filename):
887 logging.error("File %s does not exist", filename)
888 return False
889
890 command = [constants.SCP, "-p"]
891 command.extend(self._BuildSshOptions(True, False, True, True, port=port))
892 command.append(filename)
893 if netutils.IP6Address.IsValid(node):
894 node = netutils.FormatAddress((node, None))
895
896 command.append("%s:%s" % (node, vcluster.ExchangeNodeRoot(node, filename)))
897
898 result = utils.RunCmd(command)
899
900 if result.failed:
901 logging.error("Copy to node %s failed (%s) error '%s',"
902 " command was '%s'",
903 node, result.fail_reason, result.output, result.cmd)
904
905 return not result.failed
906
907 def VerifyNodeHostname(self, node, ssh_port):
908 """Verify hostname consistency via SSH.
909
910 This functions connects via ssh to a node and compares the hostname
911 reported by the node to the name with have (the one that we
912 connected to).
913
914 This is used to detect problems in ssh known_hosts files
915 (conflicting known hosts) and inconsistencies between dns/hosts
916 entries and local machine names
917
918 @param node: nodename of a host to check; can be short or
919 full qualified hostname
920 @param ssh_port: the port of a SSH daemon running on the node
921
922 @return: (success, detail), where:
923 - success: True/False
924 - detail: string with details
925
926 """
927 cmd = ("if test -z \"$GANETI_HOSTNAME\"; then"
928 " hostname --fqdn;"
929 "else"
930 " echo \"$GANETI_HOSTNAME\";"
931 "fi")
932 retval = self.Run(node, constants.SSH_LOGIN_USER, cmd,
933 quiet=False, port=ssh_port)
934
935 if retval.failed:
936 msg = "ssh problem"
937 output = retval.output
938 if output:
939 msg += ": %s" % output
940 else:
941 msg += ": %s (no output)" % retval.fail_reason
942 logging.error("Command %s failed: %s", retval.cmd, msg)
943 return False, msg
944
945 remotehostname = retval.stdout.strip()
946
947 if not remotehostname or remotehostname != node:
948 if node.startswith(remotehostname + "."):
949 msg = "hostname not FQDN"
950 else:
951 msg = "hostname mismatch"
952 return False, ("%s: expected %s but got %s" %
953 (msg, node, remotehostname))
954
955 return True, "host matches"
956
957
958 def WriteKnownHostsFile(cfg, file_name):
959 """Writes the cluster-wide equally known_hosts file.
960
961 """
962 data = ""
963 if cfg.GetRsaHostKey():
964 data += "%s ssh-rsa %s\n" % (cfg.GetClusterName(), cfg.GetRsaHostKey())
965 if cfg.GetDsaHostKey():
966 data += "%s ssh-dss %s\n" % (cfg.GetClusterName(), cfg.GetDsaHostKey())
967
968 utils.WriteFile(file_name, mode=0600, data=data)
969
970
971 def _EnsureCorrectGanetiVersion(cmd):
972 """Ensured the correct Ganeti version before running a command via SSH.
973
974 Before a command is run on a node via SSH, it makes sense in some
975 situations to ensure that this node is indeed running the correct
976 version of Ganeti like the rest of the cluster.
977
978 @type cmd: string
979 @param cmd: string
980 @rtype: list of strings
981 @return: a list of commands with the newly added ones at the beginning
982
983 """
984 logging.debug("Ensure correct Ganeti version: %s", cmd)
985
986 version = constants.DIR_VERSION
987 all_cmds = [["test", "-d", os.path.join(pathutils.PKGLIBDIR, version)]]
988 if constants.HAS_GNU_LN:
989 all_cmds.extend([["ln", "-s", "-f", "-T",
990 os.path.join(pathutils.PKGLIBDIR, version),
991 os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")],
992 ["ln", "-s", "-f", "-T",
993 os.path.join(pathutils.SHAREDIR, version),
994 os.path.join(pathutils.SYSCONFDIR, "ganeti/share")]])
995 else:
996 all_cmds.extend([["rm", "-f",
997 os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")],
998 ["ln", "-s", "-f",
999 os.path.join(pathutils.PKGLIBDIR, version),
1000 os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")],
1001 ["rm", "-f",
1002 os.path.join(pathutils.SYSCONFDIR, "ganeti/share")],
1003 ["ln", "-s", "-f",
1004 os.path.join(pathutils.SHAREDIR, version),
1005 os.path.join(pathutils.SYSCONFDIR, "ganeti/share")]])
1006 all_cmds.append(cmd)
1007 return all_cmds
1008
1009
1010 def RunSshCmdWithStdin(cluster_name, node, basecmd, port, data,
1011 debug=False, verbose=False, use_cluster_key=False,
1012 ask_key=False, strict_host_check=False,
1013 ensure_version=False):
1014 """Runs a command on a remote machine via SSH and provides input in stdin.
1015
1016 @type cluster_name: string
1017 @param cluster_name: Cluster name
1018 @type node: string
1019 @param node: Node name
1020 @type basecmd: string
1021 @param basecmd: Base command (path on the remote machine)
1022 @type port: int
1023 @param port: The SSH port of the remote machine or None for the default
1024 @param data: JSON-serializable input data for script (passed to stdin)
1025 @type debug: bool
1026 @param debug: Enable debug output
1027 @type verbose: bool
1028 @param verbose: Enable verbose output
1029 @type use_cluster_key: bool
1030 @param use_cluster_key: See L{ssh.SshRunner.BuildCmd}
1031 @type ask_key: bool
1032 @param ask_key: See L{ssh.SshRunner.BuildCmd}
1033 @type strict_host_check: bool
1034 @param strict_host_check: See L{ssh.SshRunner.BuildCmd}
1035
1036 """
1037 cmd = [basecmd]
1038
1039 # Pass --debug/--verbose to the external script if set on our invocation
1040 if debug:
1041 cmd.append("--debug")
1042
1043 if verbose:
1044 cmd.append("--verbose")
1045
1046 if ensure_version:
1047 all_cmds = _EnsureCorrectGanetiVersion(cmd)
1048 else:
1049 all_cmds = [cmd]
1050
1051 if port is None:
1052 port = netutils.GetDaemonPort(constants.SSH)
1053
1054 srun = SshRunner(cluster_name)
1055 scmd = srun.BuildCmd(node, constants.SSH_LOGIN_USER,
1056 utils.ShellQuoteArgs(
1057 utils.ShellCombineCommands(all_cmds)),
1058 batch=False, ask_key=ask_key, quiet=False,
1059 strict_host_check=strict_host_check,
1060 use_cluster_key=use_cluster_key,
1061 port=port)
1062
1063 tempfh = tempfile.TemporaryFile()
1064 try:
1065 tempfh.write(serializer.DumpJson(data))
1066 tempfh.seek(0)
1067
1068 result = utils.RunCmd(scmd, interactive=True, input_fd=tempfh)
1069 finally:
1070 tempfh.close()
1071
1072 if result.failed:
1073 raise errors.OpExecError("Command '%s' failed: %s" %
1074 (result.cmd, result.fail_reason))
1075
1076
1077 def ReadRemoteSshPubKeys(pub_key_file, node, cluster_name, port, ask_key,
1078 strict_host_check):
1079 """Fetches a public SSH key from a node via SSH.
1080
1081 @type pub_key_file: string
1082 @param pub_key_file: a tuple consisting of the file name of the public DSA key
1083
1084 """
1085 ssh_runner = SshRunner(cluster_name)
1086
1087 cmd = ["cat", pub_key_file]
1088 ssh_cmd = ssh_runner.BuildCmd(node, constants.SSH_LOGIN_USER,
1089 utils.ShellQuoteArgs(cmd),
1090 batch=False, ask_key=ask_key, quiet=False,
1091 strict_host_check=strict_host_check,
1092 use_cluster_key=False,
1093 port=port)
1094
1095 result = utils.RunCmd(ssh_cmd)
1096 if result.failed:
1097 raise errors.OpPrereqError("Could not fetch a public SSH key (%s) from node"
1098 " '%s': ran command '%s', failure reason: '%s'."
1099 % (pub_key_file, node, cmd, result.fail_reason),
1100 errors.ECODE_INVAL)
1101 return result.stdout
1102
1103
1104 def GetSshKeyFilenames(key_type, suffix=""):
1105 """Get filenames of the SSH key pair of the given type.
1106
1107 @type key_type: string
1108 @param key_type: type of SSH key, must be element of C{constants.SSHK_ALL}
1109 @type suffix: string
1110 @param suffix: optional suffix for the key filenames
1111 @rtype: tuple of (string, string)
1112 @returns: a tuple containing the name of the private key file and the
1113 public key file.
1114
1115 """
1116 if key_type not in constants.SSHK_ALL:
1117 raise errors.SshUpdateError("Unsupported key type '%s'. Supported key types"
1118 " are: %s." % (key_type, constants.SSHK_ALL))
1119 (_, root_keyfiles) = \
1120 GetAllUserFiles(constants.SSH_LOGIN_USER, mkdir=False, dircheck=False)
1121 if not key_type in root_keyfiles.keys():
1122 raise errors.SshUpdateError("No keyfile for key type '%s' available."
1123 % key_type)
1124
1125 key_filenames = root_keyfiles[key_type]
1126 if suffix:
1127 key_filenames = [_ComputeKeyFilePathWithSuffix(key_filename, suffix)
1128 for key_filename in key_filenames]
1129
1130 return key_filenames
1131
1132
1133 def GetSshPubKeyFilename(key_type, suffix=""):
1134 """Get filename of the public SSH key of the given type.
1135
1136 @type key_type: string
1137 @param key_type: type of SSH key, must be element of C{constants.SSHK_ALL}
1138 @type suffix: string
1139 @param suffix: optional suffix for the key filenames
1140 @rtype: string
1141 @returns: file name of the public key file
1142
1143 """
1144 return GetSshKeyFilenames(key_type, suffix=suffix)[1]
1145
1146
1147 def _ComputeKeyFilePathWithSuffix(key_filepath, suffix):
1148 """Converts the given key filename to a key filename with a suffix.
1149
1150 @type key_filepath: string
1151 @param key_filepath: path of the key file
1152 @type suffix: string
1153 @param suffix: suffix to be appended to the basename of the file
1154
1155 """
1156 path = os.path.dirname(key_filepath)
1157 ext = os.path.splitext(os.path.basename(key_filepath))[1]
1158 basename = os.path.splitext(os.path.basename(key_filepath))[0]
1159 return os.path.join(path, basename + suffix + ext)
1160
1161
1162 def ReplaceSshKeys(src_key_type, dest_key_type,
1163 src_key_suffix="", dest_key_suffix=""):
1164 """Replaces an SSH key pair by another SSH key pair.
1165
1166 Note that both parts, the private and the public key, are replaced.
1167
1168 @type src_key_type: string
1169 @param src_key_type: key type of key pair that is replacing the other
1170 key pair
1171 @type dest_key_type: string
1172 @param dest_key_type: key type of the key pair that is being replaced
1173 by the source key pair
1174 @type src_key_suffix: string
1175 @param src_key_suffix: optional suffix of the key files of the source
1176 key pair
1177 @type dest_key_suffix: string
1178 @param dest_key_suffix: optional suffix of the keey files of the
1179 destination key pair
1180
1181 """
1182 (src_priv_filename, src_pub_filename) = GetSshKeyFilenames(
1183 src_key_type, suffix=src_key_suffix)
1184 (dest_priv_filename, dest_pub_filename) = GetSshKeyFilenames(
1185 dest_key_type, suffix=dest_key_suffix)
1186
1187 if not (os.path.exists(src_priv_filename) and
1188 os.path.exists(src_pub_filename)):
1189 raise errors.SshUpdateError(
1190 "At least one of the source key files is missing: %s",
1191 ", ".join([src_priv_filename, src_pub_filename]))
1192
1193 for dest_file in [dest_priv_filename, dest_pub_filename]:
1194 if os.path.exists(dest_file):
1195 utils.CreateBackup(dest_file)
1196 utils.RemoveFile(dest_file)
1197
1198 shutil.move(src_priv_filename, dest_priv_filename)
1199 shutil.move(src_pub_filename, dest_pub_filename)
1200
1201
1202 def ReadLocalSshPubKeys(key_types, suffix=""):
1203 """Reads the local root user SSH key.
1204
1205 @type key_types: list of string
1206 @param key_types: types of SSH keys. Must be subset of constants.SSHK_ALL. If
1207 'None' or [], all available keys are returned.
1208 @type suffix: string
1209 @param suffix: optional suffix to be attached to key names when reading
1210 them. Used for temporary key files.
1211 @rtype: list of string
1212 @return: list of public keys
1213
1214 """
1215 fetch_key_types = []
1216 if key_types:
1217 fetch_key_types += key_types
1218 else:
1219 fetch_key_types = constants.SSHK_ALL
1220
1221 (_, root_keyfiles) = \
1222 GetAllUserFiles(constants.SSH_LOGIN_USER, mkdir=False, dircheck=False)
1223
1224 result_keys = []
1225 for (public_key_type, (_, public_key_file)) in root_keyfiles.items():
1226
1227 if public_key_type not in fetch_key_types:
1228 continue
1229
1230 public_key_dir = os.path.dirname(public_key_file)
1231 public_key_filename = ""
1232 if suffix:
1233 public_key_filename = \
1234 os.path.splitext(os.path.basename(public_key_file))[0] \
1235 + suffix + ".pub"
1236 else:
1237 public_key_filename = public_key_file
1238 public_key_path = os.path.join(public_key_dir,
1239 public_key_filename)
1240
1241 if not os.path.exists(public_key_path):
1242 raise errors.SshUpdateError("Cannot find SSH public key of type '%s'."
1243 % public_key_type)
1244 else:
1245 key = utils.ReadFile(public_key_path)
1246 result_keys.append(key)
1247
1248 return result_keys
1249
1250
1251 # Update gnt-cluster.rst when changing which combinations are valid.
1252 KeyBitInfo = namedtuple('KeyBitInfo', ['default', 'validation_fn'])
1253 SSH_KEY_VALID_BITS = {
1254 constants.SSHK_DSA: KeyBitInfo(1024, lambda b: b == 1024),
1255 constants.SSHK_RSA: KeyBitInfo(2048, lambda b: b >= 768),
1256 constants.SSHK_ECDSA: KeyBitInfo(384, lambda b: b in [256, 384, 521]),
1257 }
1258
1259
1260 def DetermineKeyBits(key_type, key_bits, old_key_type, old_key_bits):
1261 """Checks the key bits to be used for a given key type, or provides defaults.
1262
1263 @type key_type: one of L{constants.SSHK_ALL}
1264 @param key_type: The key type to use.
1265 @type key_bits: positive int or None
1266 @param key_bits: The number of bits to use, if supplied by user.
1267 @type old_key_type: one of L{constants.SSHK_ALL} or None
1268 @param old_key_type: The previously used key type, if any.
1269 @type old_key_bits: positive int or None
1270 @param old_key_bits: The previously used number of bits, if any.
1271
1272 @rtype: positive int
1273 @return: The number of bits to use.
1274
1275 """
1276 if key_bits is None:
1277 if old_key_type is not None and old_key_type == key_type:
1278 key_bits = old_key_bits
1279 else:
1280 key_bits = SSH_KEY_VALID_BITS[key_type].default
1281
1282 if not SSH_KEY_VALID_BITS[key_type].validation_fn(key_bits):
1283 raise errors.OpPrereqError("Invalid key type and bit size combination:"
1284 " %s with %s bits" % (key_type, key_bits),
1285 errors.ECODE_INVAL)
1286
1287 return key_bits