| # |
| # |
| |
| # Copyright (C) 2006, 2007, 2010, 2011 Google Inc. |
| # All rights reserved. |
| # |
| # Redistribution and use in source and binary forms, with or without |
| # modification, are permitted provided that the following conditions are |
| # met: |
| # |
| # 1. Redistributions of source code must retain the above copyright notice, |
| # this list of conditions and the following disclaimer. |
| # |
| # 2. Redistributions in binary form must reproduce the above copyright |
| # notice, this list of conditions and the following disclaimer in the |
| # documentation and/or other materials provided with the distribution. |
| # |
| # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS |
| # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED |
| # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR |
| # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
| # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
| # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
| # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF |
| # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
| # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
| # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| |
| """Module encapsulating ssh functionality. |
| |
| """ |
| |
| |
| import logging |
| import os |
| import tempfile |
| |
| from collections import namedtuple |
| from functools import partial |
| |
| from ganeti import utils |
| from ganeti import errors |
| from ganeti import constants |
| from ganeti import netutils |
| from ganeti import pathutils |
| from ganeti import vcluster |
| from ganeti import compat |
| from ganeti import serializer |
| from ganeti import ssconf |
| |
| |
| def GetUserFiles(user, mkdir=False, dircheck=True, kind=constants.SSHK_DSA, |
| _homedir_fn=None): |
| """Return the paths of a user's SSH files. |
| |
| @type user: string |
| @param user: Username |
| @type mkdir: bool |
| @param mkdir: Whether to create ".ssh" directory if it doesn't exist |
| @type dircheck: bool |
| @param dircheck: Whether to check if ".ssh" directory exists |
| @type kind: string |
| @param kind: One of L{constants.SSHK_ALL} |
| @rtype: tuple; (string, string, string) |
| @return: Tuple containing three file system paths; the private SSH key file, |
| the public SSH key file and the user's C{authorized_keys} file |
| @raise errors.OpExecError: When home directory of the user can not be |
| determined |
| @raise errors.OpExecError: Regardless of the C{mkdir} parameters, this |
| exception is raised if C{~$user/.ssh} is not a directory and C{dircheck} |
| is set to C{True} |
| |
| """ |
| if _homedir_fn is None: |
| _homedir_fn = utils.GetHomeDir |
| |
| user_dir = _homedir_fn(user) |
| if not user_dir: |
| raise errors.OpExecError("Cannot resolve home of user '%s'" % user) |
| |
| if kind == constants.SSHK_DSA: |
| suffix = "dsa" |
| elif kind == constants.SSHK_RSA: |
| suffix = "rsa" |
| elif kind == constants.SSHK_ECDSA: |
| suffix = "ecdsa" |
| else: |
| raise errors.ProgrammerError("Unknown SSH key kind '%s'" % kind) |
| |
| ssh_dir = utils.PathJoin(user_dir, ".ssh") |
| if mkdir: |
| utils.EnsureDirs([(ssh_dir, constants.SECURE_DIR_MODE)]) |
| elif dircheck and not os.path.isdir(ssh_dir): |
| raise errors.OpExecError("Path %s is not a directory" % ssh_dir) |
| |
| return [utils.PathJoin(ssh_dir, base) |
| for base in ["id_%s" % suffix, "id_%s.pub" % suffix, |
| "authorized_keys"]] |
| |
| |
| def GetAllUserFiles(user, mkdir=False, dircheck=True, _homedir_fn=None): |
| """Wrapper over L{GetUserFiles} to retrieve files for all SSH key types. |
| |
| See L{GetUserFiles} for details. |
| |
| @rtype: tuple; (string, dict with string as key, tuple of (string, string) as |
| value) |
| |
| """ |
| helper = compat.partial(GetUserFiles, user, mkdir=mkdir, dircheck=dircheck, |
| _homedir_fn=_homedir_fn) |
| result = [(kind, helper(kind=kind)) for kind in constants.SSHK_ALL] |
| |
| authorized_keys = [i for (_, (_, _, i)) in result] |
| |
| assert len(frozenset(authorized_keys)) == 1, \ |
| "Different paths for authorized_keys were returned" |
| |
| return (authorized_keys[0], |
| dict((kind, (privkey, pubkey)) |
| for (kind, (privkey, pubkey, _)) in result)) |
| |
| |
| def _SplitSshKey(key): |
| """Splits a line for SSH's C{authorized_keys} file. |
| |
| If the line has no options (e.g. no C{command="..."}), only the significant |
| parts, the key type and its hash, are used. Otherwise the whole line is used |
| (split at whitespace). |
| |
| @type key: string |
| @param key: Key line |
| @rtype: tuple |
| |
| """ |
| parts = key.split() |
| |
| if parts and parts[0] in constants.SSHAK_ALL: |
| # If the key has no options in front of it, we only want the significant |
| # fields |
| return (False, parts[:2]) |
| else: |
| # Can't properly split the line, so use everything |
| return (True, parts) |
| |
| |
| def AddAuthorizedKeys(file_obj, keys): |
| """Adds a list of SSH public key to an authorized_keys file. |
| |
| @type file_obj: str or file handle |
| @param file_obj: path to authorized_keys file |
| @type keys: list of str |
| @param keys: list of strings containing keys |
| |
| """ |
| key_field_list = [(key, _SplitSshKey(key)) for key in keys] |
| |
| if isinstance(file_obj, basestring): |
| f = open(file_obj, "a+") |
| else: |
| f = file_obj |
| |
| try: |
| nl = True |
| for line in f: |
| # Ignore whitespace changes |
| line_key = _SplitSshKey(line) |
| key_field_list[:] = [(key, split_key) for (key, split_key) |
| in key_field_list |
| if split_key != line_key] |
| nl = line.endswith("\n") |
| else: |
| if not nl: |
| f.write("\n") |
| for (key, _) in key_field_list: |
| f.write(key.rstrip("\r\n")) |
| f.write("\n") |
| f.flush() |
| finally: |
| f.close() |
| |
| |
| def HasAuthorizedKey(file_obj, key): |
| """Check if a particular key is in the 'authorized_keys' file. |
| |
| @type file_obj: str or file handle |
| @param file_obj: path to authorized_keys file |
| @type key: str |
| @param key: string containing key |
| |
| """ |
| key_fields = _SplitSshKey(key) |
| |
| if isinstance(file_obj, basestring): |
| f = open(file_obj, "r") |
| else: |
| f = file_obj |
| |
| try: |
| for line in f: |
| # Ignore whitespace changes |
| line_key = _SplitSshKey(line) |
| if line_key == key_fields: |
| return True |
| finally: |
| f.close() |
| |
| return False |
| |
| |
| def CheckForMultipleKeys(file_obj, node_names): |
| """Check if there is at most one key per host in 'authorized_keys' file. |
| |
| @type file_obj: str or file handle |
| @param file_obj: path to authorized_keys file |
| @type node_names: list of str |
| @param node_names: list of names of nodes of the cluster |
| @returns: a dictionary with hostnames which occur more than once |
| |
| """ |
| |
| if isinstance(file_obj, basestring): |
| f = open(file_obj, "r") |
| else: |
| f = file_obj |
| |
| occurrences = {} |
| |
| try: |
| index = 0 |
| for line in f: |
| index += 1 |
| if line.startswith("#"): |
| continue |
| chunks = line.split() |
| # find the chunk with user@hostname |
| user_hostname = [chunk.strip() for chunk in chunks if "@" in chunk][0] |
| if not user_hostname in occurrences: |
| occurrences[user_hostname] = [] |
| occurrences[user_hostname].append(index) |
| finally: |
| f.close() |
| |
| bad_occurrences = {} |
| for user_hostname, occ in occurrences.items(): |
| _, hostname = user_hostname.split("@") |
| if hostname in node_names and len(occ) > 1: |
| bad_occurrences[user_hostname] = occ |
| |
| return bad_occurrences |
| |
| |
| def AddAuthorizedKey(file_obj, key): |
| """Adds an SSH public key to an authorized_keys file. |
| |
| @type file_obj: str or file handle |
| @param file_obj: path to authorized_keys file |
| @type key: str |
| @param key: string containing key |
| |
| """ |
| AddAuthorizedKeys(file_obj, [key]) |
| |
| |
| def RemoveAuthorizedKeys(file_name, keys): |
| """Removes public SSH keys from an authorized_keys file. |
| |
| @type file_name: str |
| @param file_name: path to authorized_keys file |
| @type keys: list of str |
| @param keys: list of strings containing keys |
| |
| """ |
| key_field_list = [_SplitSshKey(key) for key in keys] |
| |
| fd, tmpname = tempfile.mkstemp(dir=os.path.dirname(file_name)) |
| try: |
| out = os.fdopen(fd, "w") |
| try: |
| f = open(file_name, "r") |
| try: |
| for line in f: |
| # Ignore whitespace changes while comparing lines |
| if _SplitSshKey(line) not in key_field_list: |
| out.write(line) |
| |
| out.flush() |
| os.rename(tmpname, file_name) |
| finally: |
| f.close() |
| finally: |
| out.close() |
| except: |
| utils.RemoveFile(tmpname) |
| raise |
| |
| |
| def RemoveAuthorizedKey(file_name, key): |
| """Removes an SSH public key from an authorized_keys file. |
| |
| @type file_name: str |
| @param file_name: path to authorized_keys file |
| @type key: str |
| @param key: string containing key |
| |
| """ |
| RemoveAuthorizedKeys(file_name, [key]) |
| |
| |
| def _AddPublicKeyProcessLine(new_uuid, new_key, line_uuid, line_key, found): |
| """Processes one line of the public key file when adding a key. |
| |
| This is a sub function that can be called within the |
| C{_ManipulatePublicKeyFile} function. It processes one line of the public |
| key file, checks if this line contains the key to add already and if so, |
| notes the occurrence in the return value. |
| |
| @type new_uuid: string |
| @param new_uuid: the node UUID of the node whose key is added |
| @type new_key: string |
| @param new_key: the SSH key to be added |
| @type line_uuid: the UUID of the node whose line in the public key file |
| is processed in this function call |
| @param line_key: the SSH key of the node whose line in the public key |
| file is processed in this function call |
| @type found: boolean |
| @param found: whether or not the (UUID, key) pair of the node whose key |
| is being added was found in the public key file already. |
| @rtype: (boolean, string) |
| @return: a possibly updated value of C{found} and the processed line |
| |
| """ |
| if line_uuid == new_uuid and line_key == new_key: |
| logging.debug("SSH key of node '%s' already in key file.", new_uuid) |
| found = True |
| return (found, "%s %s\n" % (line_uuid, line_key)) |
| |
| |
| def _AddPublicKeyElse(new_uuid, new_key): |
| """Adds a new SSH key to the key file if it did not exist already. |
| |
| This is an auxiliary function for C{_ManipulatePublicKeyFile} which |
| is carried out when a new key is added to the public key file and |
| after processing the whole file, we found out that the key does |
| not exist in the file yet but needs to be appended at the end. |
| |
| @type new_uuid: string |
| @param new_uuid: the UUID of the node whose key is added |
| @type new_key: string |
| @param new_key: the SSH key to be added |
| @rtype: string |
| @return: a new line to be added to the file |
| |
| """ |
| return "%s %s\n" % (new_uuid, new_key) |
| |
| |
| def _RemovePublicKeyProcessLine( |
| target_uuid, _target_key, |
| line_uuid, line_key, found): |
| """Processes a line in the public key file when aiming for removing a key. |
| |
| This is an auxiliary function for C{_ManipulatePublicKeyFile} when we |
| are removing a key from the public key file. This particular function |
| only checks if the current line contains the UUID of the node in |
| question and writes the line to the temporary file otherwise. |
| |
| @type target_uuid: string |
| @param target_uuid: UUID of the node whose key is being removed |
| @type _target_key: string |
| @param _target_key: SSH key of the node (not used) |
| @type line_uuid: string |
| @param line_uuid: UUID of the node whose line is processed in this call |
| @type line_key: string |
| @param line_key: SSH key of the nodes whose line is processed in this call |
| @type found: boolean |
| @param found: whether or not the UUID was already found. |
| @rtype: (boolean, string) |
| @return: a tuple, indicating if the target line was found and the processed |
| line; the line is 'None', if the original line is removed |
| |
| """ |
| if line_uuid != target_uuid: |
| return (found, "%s %s\n" % (line_uuid, line_key)) |
| else: |
| return (True, None) |
| |
| |
| def _RemovePublicKeyElse( |
| target_uuid, _target_key): |
| """Logs when we tried to remove a key that does not exist. |
| |
| This is an auxiliary function for C{_ManipulatePublicKeyFile} which is |
| run after we have processed the complete public key file and did not find |
| the key to be removed. |
| |
| @type target_uuid: string |
| @param target_uuid: the UUID of the node whose key was supposed to be removed |
| @type _target_key: string |
| @param _target_key: the key of the node which was supposed to be removed |
| (not used) |
| @rtype: string |
| @return: in this case, always None |
| |
| """ |
| logging.debug("Trying to remove key of node '%s' which is not in list" |
| " of public keys.", target_uuid) |
| return None |
| |
| |
| def _ReplaceNameByUuidProcessLine( |
| node_name, _key, line_identifier, line_key, found, node_uuid=None): |
| """Replaces a node's name with its UUID on a matching line in the key file. |
| |
| This is an auxiliary function for C{_ManipulatePublicKeyFile} which processes |
| a line of the ganeti public key file. If the line in question matches the |
| node's name, the name will be replaced by the node's UUID. |
| |
| @type node_name: string |
| @param node_name: name of the node to be replaced by the UUID |
| @type _key: string |
| @param _key: SSH key of the node (not used) |
| @type line_identifier: string |
| @param line_identifier: an identifier of a node in a line of the public key |
| file. This can be either a node name or a node UUID, depending on if it |
| got replaced already or not. |
| @type line_key: string |
| @param line_key: SSH key of the node whose line is processed |
| @type found: boolean |
| @param found: whether or not the line matches the node's name |
| @type node_uuid: string |
| @param node_uuid: the node's UUID which will replace the node name |
| @rtype: (boolean, string) |
| @return: a tuple indicating whether the target line was found and the |
| processed line |
| |
| """ |
| if node_name == line_identifier: |
| return (True, "%s %s\n" % (node_uuid, line_key)) |
| else: |
| return (found, "%s %s\n" % (line_identifier, line_key)) |
| |
| |
| def _ReplaceNameByUuidElse( |
| node_uuid, node_name, _key): |
| """Logs a debug message when we try to replace a key that is not there. |
| |
| This is an implementation of the auxiliary C{process_else_fn} function for |
| the C{_ManipulatePubKeyFile} function when we use it to replace a line |
| in the public key file that is indexed by the node's name instead of the |
| node's UUID. |
| |
| @type node_uuid: string |
| @param node_uuid: the node's UUID |
| @type node_name: string |
| @param node_name: the node's UUID |
| @type _key: string (not used) |
| @param _key: the node's SSH key (not used) |
| @rtype: string |
| @return: in this case, always None |
| |
| """ |
| logging.debug("Trying to replace node name '%s' with UUID '%s', but" |
| " no line with that name was found.", node_name, node_uuid) |
| return None |
| |
| |
| def _ParseKeyLine(line, error_fn): |
| """Parses a line of the public key file. |
| |
| @type line: string |
| @param line: line of the public key file |
| @type error_fn: function |
| @param error_fn: function to process error messages |
| @rtype: tuple (string, string) |
| @return: a tuple containing the UUID of the node and a string containing |
| the SSH key and possible more parameters for the key |
| |
| """ |
| if len(line.rstrip()) == 0: |
| return (None, None) |
| chunks = line.split(" ") |
| if len(chunks) < 2: |
| raise error_fn("Error parsing public SSH key file. Line: '%s'" |
| % line) |
| uuid = chunks[0] |
| key = " ".join(chunks[1:]).rstrip() |
| return (uuid, key) |
| |
| |
| def _ManipulatePubKeyFile(target_identifier, target_key, |
| key_file=pathutils.SSH_PUB_KEYS, |
| error_fn=errors.ProgrammerError, |
| process_line_fn=None, process_else_fn=None): |
| """Manipulates the list of public SSH keys of the cluster. |
| |
| This is a general function to manipulate the public key file. It needs |
| two auxiliary functions C{process_line_fn} and C{process_else_fn} to |
| work. Generally, the public key file is processed as follows: |
| 1) The function processes each line of the original ganeti public key file, |
| applies the C{process_line_fn} function on it, which returns a possibly |
| manipulated line and an indicator whether the line in question was found. |
| If a line is returned, it is added to a list of lines for later writing |
| to the file. |
| 2) If all lines are processed and the 'found' variable is False, the |
| seconds auxiliary function C{process_else_fn} is called to possibly |
| add more lines to the list of lines. |
| 3) Finally, the list of lines is assembled to a string and written |
| atomically to the public key file, thereby overriding it. |
| |
| If the public key file does not exist, we create it. This is necessary for |
| a smooth transition after an upgrade. |
| |
| @type target_identifier: str |
| @param target_identifier: identifier of the node whose key is added; in most |
| cases this is the node's UUID, but in some it is the node's host name |
| @type target_key: str |
| @param target_key: string containing a public SSH key (a complete line |
| possibly including more parameters than just the key) |
| @type key_file: str |
| @param key_file: filename of the file of public node keys (optional |
| parameter for testing) |
| @type error_fn: function |
| @param error_fn: Function that returns an exception, used to customize |
| exception types depending on the calling context |
| @type process_line_fn: function |
| @param process_line_fn: function to process one line of the public key file |
| @type process_else_fn: function |
| @param process_else_fn: function to be called if no line of the key file |
| matches the target uuid |
| |
| """ |
| assert process_else_fn is not None |
| assert process_line_fn is not None |
| |
| old_lines = [] |
| f_orig = None |
| if os.path.exists(key_file): |
| try: |
| f_orig = open(key_file, "r") |
| old_lines = f_orig.readlines() |
| finally: |
| f_orig.close() |
| else: |
| try: |
| f_orig = open(key_file, "w") |
| f_orig.close() |
| except IOError as e: |
| raise errors.SshUpdateError("Cannot create public key file: %s" % e) |
| |
| found = False |
| new_lines = [] |
| for line in old_lines: |
| (uuid, key) = _ParseKeyLine(line, error_fn) |
| if not uuid: |
| continue |
| (new_found, new_line) = process_line_fn(target_identifier, target_key, |
| uuid, key, found) |
| if new_found: |
| found = True |
| if new_line is not None: |
| new_lines.append(new_line) |
| if not found: |
| new_line = process_else_fn(target_identifier, target_key) |
| if new_line is not None: |
| new_lines.append(new_line) |
| new_file_content = "".join(new_lines) |
| utils.WriteFile(key_file, data=new_file_content) |
| |
| |
| def AddPublicKey(new_uuid, new_key, key_file=pathutils.SSH_PUB_KEYS, |
| error_fn=errors.ProgrammerError): |
| """Adds a new key to the list of public keys. |
| |
| @see: _ManipulatePubKeyFile for parameter descriptions. |
| |
| """ |
| _ManipulatePubKeyFile(new_uuid, new_key, key_file=key_file, |
| process_line_fn=_AddPublicKeyProcessLine, |
| process_else_fn=_AddPublicKeyElse, |
| error_fn=error_fn) |
| |
| |
| def RemovePublicKey(target_uuid, key_file=pathutils.SSH_PUB_KEYS, |
| error_fn=errors.ProgrammerError): |
| """Removes a key from the list of public keys. |
| |
| @see: _ManipulatePubKeyFile for parameter descriptions. |
| |
| """ |
| _ManipulatePubKeyFile(target_uuid, None, key_file=key_file, |
| process_line_fn=_RemovePublicKeyProcessLine, |
| process_else_fn=_RemovePublicKeyElse, |
| error_fn=error_fn) |
| |
| |
| def ReplaceNameByUuid(node_uuid, node_name, key_file=pathutils.SSH_PUB_KEYS, |
| error_fn=errors.ProgrammerError): |
| """Replaces a host name with the node's corresponding UUID. |
| |
| When a node is added to the cluster, we don't know it's UUID yet. So first |
| its SSH key gets added to the public key file and in a second step, the |
| node's name gets replaced with the node's UUID as soon as we know the UUID. |
| |
| @type node_uuid: string |
| @param node_uuid: the node's UUID to replace the node's name |
| @type node_name: string |
| @param node_name: the node's name to be replaced by the node's UUID |
| |
| @see: _ManipulatePubKeyFile for the other parameter descriptions. |
| |
| """ |
| process_line_fn = partial(_ReplaceNameByUuidProcessLine, node_uuid=node_uuid) |
| process_else_fn = partial(_ReplaceNameByUuidElse, node_uuid=node_uuid) |
| _ManipulatePubKeyFile(node_name, None, key_file=key_file, |
| process_line_fn=process_line_fn, |
| process_else_fn=process_else_fn, |
| error_fn=error_fn) |
| |
| |
| def ClearPubKeyFile(key_file=pathutils.SSH_PUB_KEYS, mode=0600): |
| """Resets the content of the public key file. |
| |
| """ |
| utils.WriteFile(key_file, data="", mode=mode) |
| |
| |
| def OverridePubKeyFile(key_map, key_file=pathutils.SSH_PUB_KEYS): |
| """Overrides the public key file with a list of given keys. |
| |
| @type key_map: dict from str to list of str |
| @param key_map: dictionary mapping uuids to lists of SSH keys |
| |
| """ |
| new_lines = [] |
| for (uuid, keys) in key_map.items(): |
| for key in keys: |
| new_lines.append("%s %s\n" % (uuid, key)) |
| new_file_content = "".join(new_lines) |
| utils.WriteFile(key_file, data=new_file_content) |
| |
| |
| def QueryPubKeyFile(target_uuids, key_file=pathutils.SSH_PUB_KEYS, |
| error_fn=errors.ProgrammerError): |
| """Retrieves a map of keys for the requested node UUIDs. |
| |
| @type target_uuids: str or list of str |
| @param target_uuids: UUID of the node to retrieve the key for or a list |
| of UUIDs of nodes to retrieve the keys for |
| @type key_file: str |
| @param key_file: filename of the file of public node keys (optional |
| parameter for testing) |
| @type error_fn: function |
| @param error_fn: Function that returns an exception, used to customize |
| exception types depending on the calling context |
| @rtype: dict mapping strings to list of strings |
| @return: dictionary mapping node uuids to their ssh keys |
| |
| """ |
| all_keys = target_uuids is None |
| if isinstance(target_uuids, str): |
| target_uuids = [target_uuids] |
| result = {} |
| f = open(key_file, "r") |
| try: |
| for line in f: |
| (uuid, key) = _ParseKeyLine(line, error_fn) |
| if not uuid: |
| continue |
| if all_keys or (uuid in target_uuids): |
| if uuid not in result: |
| result[uuid] = [] |
| result[uuid].append(key) |
| finally: |
| f.close() |
| return result |
| |
| |
| def InitSSHSetup(key_type, key_bits, error_fn=errors.OpPrereqError, |
| _homedir_fn=None, _suffix=""): |
| """Setup the SSH configuration for the node. |
| |
| This generates a dsa keypair for root, adds the pub key to the |
| permitted hosts and adds the hostkey to its own known hosts. |
| |
| @param key_type: the type of SSH keypair to be generated |
| @param key_bits: the key length, in bits, to be used |
| |
| """ |
| priv_key, _, auth_keys = GetUserFiles(constants.SSH_LOGIN_USER, kind=key_type, |
| mkdir=True, _homedir_fn=_homedir_fn) |
| |
| new_priv_key_name = priv_key + _suffix |
| new_pub_key_name = priv_key + _suffix + ".pub" |
| |
| for name in new_priv_key_name, new_pub_key_name: |
| if os.path.exists(name): |
| utils.CreateBackup(name) |
| utils.RemoveFile(name) |
| |
| result = utils.RunCmd(["ssh-keygen", "-b", str(key_bits), "-t", key_type, |
| "-f", new_priv_key_name, |
| "-q", "-N", ""]) |
| if result.failed: |
| raise error_fn("Could not generate ssh keypair, error %s" % |
| result.output) |
| |
| AddAuthorizedKey(auth_keys, utils.ReadFile(new_pub_key_name)) |
| |
| |
| def InitPubKeyFile(master_uuid, key_type, key_file=pathutils.SSH_PUB_KEYS): |
| """Creates the public key file and adds the master node's SSH key. |
| |
| @type master_uuid: str |
| @param master_uuid: the master node's UUID |
| @type key_type: one of L{constants.SSHK_ALL} |
| @param key_type: the type of ssh key to be used |
| @type key_file: str |
| @param key_file: name of the file containing the public keys |
| |
| """ |
| _, pub_key, _ = GetUserFiles(constants.SSH_LOGIN_USER, kind=key_type) |
| ClearPubKeyFile(key_file=key_file) |
| key = utils.ReadFile(pub_key) |
| AddPublicKey(master_uuid, key, key_file=key_file) |
| |
| |
| class SshRunner: |
| """Wrapper for SSH commands. |
| |
| """ |
| def __init__(self, cluster_name): |
| """Initializes this class. |
| |
| @type cluster_name: str |
| @param cluster_name: name of the cluster |
| |
| """ |
| self.cluster_name = cluster_name |
| family = ssconf.SimpleStore().GetPrimaryIPFamily() |
| self.ipv6 = (family == netutils.IP6Address.family) |
| |
| def _BuildSshOptions(self, batch, ask_key, use_cluster_key, |
| strict_host_check, private_key=None, quiet=True, |
| port=None): |
| """Builds a list with needed SSH options. |
| |
| @param batch: same as ssh's batch option |
| @param ask_key: allows ssh to ask for key confirmation; this |
| parameter conflicts with the batch one |
| @param use_cluster_key: if True, use the cluster name as the |
| HostKeyAlias name |
| @param strict_host_check: this makes the host key checking strict |
| @param private_key: use this private key instead of the default |
| @param quiet: whether to enable -q to ssh |
| @param port: the SSH port to use, or None to use the default |
| |
| @rtype: list |
| @return: the list of options ready to use in L{utils.process.RunCmd} |
| |
| """ |
| options = [ |
| "-oEscapeChar=none", |
| "-oHashKnownHosts=no", |
| "-oGlobalKnownHostsFile=%s" % pathutils.SSH_KNOWN_HOSTS_FILE, |
| "-oUserKnownHostsFile=/dev/null", |
| "-oCheckHostIp=no", |
| ] |
| |
| if use_cluster_key: |
| options.append("-oHostKeyAlias=%s" % self.cluster_name) |
| |
| if quiet: |
| options.append("-q") |
| |
| if private_key: |
| options.append("-i%s" % private_key) |
| |
| if port: |
| options.append("-oPort=%d" % port) |
| |
| # TODO: Too many boolean options, maybe convert them to more descriptive |
| # constants. |
| |
| # Note: ask_key conflicts with batch mode |
| if batch: |
| if ask_key: |
| raise errors.ProgrammerError("SSH call requested conflicting options") |
| |
| options.append("-oBatchMode=yes") |
| |
| if strict_host_check: |
| options.append("-oStrictHostKeyChecking=yes") |
| else: |
| options.append("-oStrictHostKeyChecking=no") |
| |
| else: |
| # non-batch mode |
| |
| if ask_key: |
| options.append("-oStrictHostKeyChecking=ask") |
| elif strict_host_check: |
| options.append("-oStrictHostKeyChecking=yes") |
| else: |
| options.append("-oStrictHostKeyChecking=no") |
| |
| if self.ipv6: |
| options.append("-6") |
| else: |
| options.append("-4") |
| |
| return options |
| |
| def BuildCmd(self, hostname, user, command, batch=True, ask_key=False, |
| tty=False, use_cluster_key=True, strict_host_check=True, |
| private_key=None, quiet=True, port=None): |
| """Build an ssh command to execute a command on a remote node. |
| |
| @param hostname: the target host, string |
| @param user: user to auth as |
| @param command: the command |
| @param batch: if true, ssh will run in batch mode with no prompting |
| @param ask_key: if true, ssh will run with |
| StrictHostKeyChecking=ask, so that we can connect to an |
| unknown host (not valid in batch mode) |
| @param use_cluster_key: whether to expect and use the |
| cluster-global SSH key |
| @param strict_host_check: whether to check the host's SSH key at all |
| @param private_key: use this private key instead of the default |
| @param quiet: whether to enable -q to ssh |
| @param port: the SSH port on which the node's daemon is running |
| |
| @return: the ssh call to run 'command' on the remote host. |
| |
| """ |
| argv = [constants.SSH] |
| argv.extend(self._BuildSshOptions(batch, ask_key, use_cluster_key, |
| strict_host_check, private_key, |
| quiet=quiet, port=port)) |
| if tty: |
| argv.extend(["-t", "-t"]) |
| |
| argv.append("%s@%s" % (user, hostname)) |
| |
| # Insert variables for virtual nodes |
| argv.extend("export %s=%s;" % |
| (utils.ShellQuote(name), utils.ShellQuote(value)) |
| for (name, value) in |
| vcluster.EnvironmentForHost(hostname).items()) |
| |
| argv.append(command) |
| |
| return argv |
| |
| def Run(self, *args, **kwargs): |
| """Runs a command on a remote node. |
| |
| This method has the same return value as `utils.RunCmd()`, which it |
| uses to launch ssh. |
| |
| Args: see SshRunner.BuildCmd. |
| |
| @rtype: L{utils.process.RunResult} |
| @return: the result as from L{utils.process.RunCmd()} |
| |
| """ |
| return utils.RunCmd(self.BuildCmd(*args, **kwargs)) |
| |
| def CopyFileToNode(self, node, port, filename): |
| """Copy a file to another node with scp. |
| |
| @param node: node in the cluster |
| @param filename: absolute pathname of a local file |
| |
| @rtype: boolean |
| @return: the success of the operation |
| |
| """ |
| if not os.path.isabs(filename): |
| logging.error("File %s must be an absolute path", filename) |
| return False |
| |
| if not os.path.isfile(filename): |
| logging.error("File %s does not exist", filename) |
| return False |
| |
| command = [constants.SCP, "-p"] |
| command.extend(self._BuildSshOptions(True, False, True, True, port=port)) |
| command.append(filename) |
| if netutils.IP6Address.IsValid(node): |
| node = netutils.FormatAddress((node, None)) |
| |
| command.append("%s:%s" % (node, vcluster.ExchangeNodeRoot(node, filename))) |
| |
| result = utils.RunCmd(command) |
| |
| if result.failed: |
| logging.error("Copy to node %s failed (%s) error '%s'," |
| " command was '%s'", |
| node, result.fail_reason, result.output, result.cmd) |
| |
| return not result.failed |
| |
| def VerifyNodeHostname(self, node, ssh_port): |
| """Verify hostname consistency via SSH. |
| |
| This functions connects via ssh to a node and compares the hostname |
| reported by the node to the name with have (the one that we |
| connected to). |
| |
| This is used to detect problems in ssh known_hosts files |
| (conflicting known hosts) and inconsistencies between dns/hosts |
| entries and local machine names |
| |
| @param node: nodename of a host to check; can be short or |
| full qualified hostname |
| @param ssh_port: the port of a SSH daemon running on the node |
| |
| @return: (success, detail), where: |
| - success: True/False |
| - detail: string with details |
| |
| """ |
| cmd = ("if test -z \"$GANETI_HOSTNAME\"; then" |
| " hostname --fqdn;" |
| "else" |
| " echo \"$GANETI_HOSTNAME\";" |
| "fi") |
| retval = self.Run(node, constants.SSH_LOGIN_USER, cmd, |
| quiet=False, port=ssh_port) |
| |
| if retval.failed: |
| msg = "ssh problem" |
| output = retval.output |
| if output: |
| msg += ": %s" % output |
| else: |
| msg += ": %s (no output)" % retval.fail_reason |
| logging.error("Command %s failed: %s", retval.cmd, msg) |
| return False, msg |
| |
| remotehostname = retval.stdout.strip() |
| |
| if not remotehostname or remotehostname != node: |
| if node.startswith(remotehostname + "."): |
| msg = "hostname not FQDN" |
| else: |
| msg = "hostname mismatch" |
| return False, ("%s: expected %s but got %s" % |
| (msg, node, remotehostname)) |
| |
| return True, "host matches" |
| |
| |
| def WriteKnownHostsFile(cfg, file_name): |
| """Writes the cluster-wide equally known_hosts file. |
| |
| """ |
| data = "" |
| if cfg.GetRsaHostKey(): |
| data += "%s ssh-rsa %s\n" % (cfg.GetClusterName(), cfg.GetRsaHostKey()) |
| if cfg.GetDsaHostKey(): |
| data += "%s ssh-dss %s\n" % (cfg.GetClusterName(), cfg.GetDsaHostKey()) |
| |
| utils.WriteFile(file_name, mode=0600, data=data) |
| |
| |
| def _EnsureCorrectGanetiVersion(cmd): |
| """Ensured the correct Ganeti version before running a command via SSH. |
| |
| Before a command is run on a node via SSH, it makes sense in some |
| situations to ensure that this node is indeed running the correct |
| version of Ganeti like the rest of the cluster. |
| |
| @type cmd: string |
| @param cmd: string |
| @rtype: list of strings |
| @return: a list of commands with the newly added ones at the beginning |
| |
| """ |
| logging.debug("Ensure correct Ganeti version: %s", cmd) |
| |
| version = constants.DIR_VERSION |
| all_cmds = [["test", "-d", os.path.join(pathutils.PKGLIBDIR, version)]] |
| if constants.HAS_GNU_LN: |
| all_cmds.extend([["ln", "-s", "-f", "-T", |
| os.path.join(pathutils.PKGLIBDIR, version), |
| os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")], |
| ["ln", "-s", "-f", "-T", |
| os.path.join(pathutils.SHAREDIR, version), |
| os.path.join(pathutils.SYSCONFDIR, "ganeti/share")]]) |
| else: |
| all_cmds.extend([["rm", "-f", |
| os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")], |
| ["ln", "-s", "-f", |
| os.path.join(pathutils.PKGLIBDIR, version), |
| os.path.join(pathutils.SYSCONFDIR, "ganeti/lib")], |
| ["rm", "-f", |
| os.path.join(pathutils.SYSCONFDIR, "ganeti/share")], |
| ["ln", "-s", "-f", |
| os.path.join(pathutils.SHAREDIR, version), |
| os.path.join(pathutils.SYSCONFDIR, "ganeti/share")]]) |
| all_cmds.append(cmd) |
| return all_cmds |
| |
| |
| def RunSshCmdWithStdin(cluster_name, node, basecmd, port, data, |
| debug=False, verbose=False, use_cluster_key=False, |
| ask_key=False, strict_host_check=False, |
| ensure_version=False): |
| """Runs a command on a remote machine via SSH and provides input in stdin. |
| |
| @type cluster_name: string |
| @param cluster_name: Cluster name |
| @type node: string |
| @param node: Node name |
| @type basecmd: string |
| @param basecmd: Base command (path on the remote machine) |
| @type port: int |
| @param port: The SSH port of the remote machine or None for the default |
| @param data: JSON-serializable input data for script (passed to stdin) |
| @type debug: bool |
| @param debug: Enable debug output |
| @type verbose: bool |
| @param verbose: Enable verbose output |
| @type use_cluster_key: bool |
| @param use_cluster_key: See L{ssh.SshRunner.BuildCmd} |
| @type ask_key: bool |
| @param ask_key: See L{ssh.SshRunner.BuildCmd} |
| @type strict_host_check: bool |
| @param strict_host_check: See L{ssh.SshRunner.BuildCmd} |
| |
| """ |
| cmd = [basecmd] |
| |
| # Pass --debug/--verbose to the external script if set on our invocation |
| if debug: |
| cmd.append("--debug") |
| |
| if verbose: |
| cmd.append("--verbose") |
| |
| if ensure_version: |
| all_cmds = _EnsureCorrectGanetiVersion(cmd) |
| else: |
| all_cmds = [cmd] |
| |
| if port is None: |
| port = netutils.GetDaemonPort(constants.SSH) |
| |
| srun = SshRunner(cluster_name) |
| scmd = srun.BuildCmd(node, constants.SSH_LOGIN_USER, |
| utils.ShellQuoteArgs( |
| utils.ShellCombineCommands(all_cmds)), |
| batch=False, ask_key=ask_key, quiet=False, |
| strict_host_check=strict_host_check, |
| use_cluster_key=use_cluster_key, |
| port=port) |
| |
| tempfh = tempfile.TemporaryFile() |
| try: |
| tempfh.write(serializer.DumpJson(data)) |
| tempfh.seek(0) |
| |
| result = utils.RunCmd(scmd, interactive=True, input_fd=tempfh) |
| finally: |
| tempfh.close() |
| |
| if result.failed: |
| raise errors.OpExecError("Command '%s' failed: %s" % |
| (result.cmd, result.fail_reason)) |
| |
| |
| def ReadRemoteSshPubKeys(pub_key_file, node, cluster_name, port, ask_key, |
| strict_host_check): |
| """Fetches a public SSH key from a node via SSH. |
| |
| @type pub_key_file: string |
| @param pub_key_file: a tuple consisting of the file name of the public DSA key |
| |
| """ |
| ssh_runner = SshRunner(cluster_name) |
| |
| cmd = ["cat", pub_key_file] |
| ssh_cmd = ssh_runner.BuildCmd(node, constants.SSH_LOGIN_USER, |
| utils.ShellQuoteArgs(cmd), |
| batch=False, ask_key=ask_key, quiet=False, |
| strict_host_check=strict_host_check, |
| use_cluster_key=False, |
| port=port) |
| |
| result = utils.RunCmd(ssh_cmd) |
| if result.failed: |
| raise errors.OpPrereqError("Could not fetch a public SSH key (%s) from node" |
| " '%s': ran command '%s', failure reason: '%s'." |
| % (pub_key_file, node, cmd, result.fail_reason), |
| errors.ECODE_INVAL) |
| return result.stdout |
| |
| |
| # Update gnt-cluster.rst when changing which combinations are valid. |
| KeyBitInfo = namedtuple('KeyBitInfo', ['default', 'validation_fn']) |
| SSH_KEY_VALID_BITS = { |
| constants.SSHK_DSA: KeyBitInfo(1024, lambda b: b == 1024), |
| constants.SSHK_RSA: KeyBitInfo(2048, lambda b: b >= 768), |
| constants.SSHK_ECDSA: KeyBitInfo(384, lambda b: b in [256, 384, 521]), |
| } |
| |
| |
| def DetermineKeyBits(key_type, key_bits, old_key_type, old_key_bits): |
| """Checks the key bits to be used for a given key type, or provides defaults. |
| |
| @type key_type: one of L{constants.SSHK_ALL} |
| @param key_type: The key type to use. |
| @type key_bits: positive int or None |
| @param key_bits: The number of bits to use, if supplied by user. |
| @type old_key_type: one of L{constants.SSHK_ALL} or None |
| @param old_key_type: The previously used key type, if any. |
| @type old_key_bits: positive int or None |
| @param old_key_bits: The previously used number of bits, if any. |
| |
| @rtype: positive int |
| @return: The number of bits to use. |
| |
| """ |
| if key_bits is None: |
| if old_key_type is not None and old_key_type == key_type: |
| key_bits = old_key_bits |
| else: |
| key_bits = SSH_KEY_VALID_BITS[key_type].default |
| |
| if not SSH_KEY_VALID_BITS[key_type].validation_fn(key_bits): |
| raise errors.OpPrereqError("Invalid key type and bit size combination:" |
| " %s with %s bits" % (key_type, key_bits), |
| errors.ECODE_INVAL) |
| |
| return key_bits |