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