On Wed, Jul 30, 2014 at 11:31 AM, 'Helga Velroyen' via ganeti-devel <
ganeti-devel@googlegroups.com> wrote:

> This patch introduced infrastructure to handle the
> newly introduced file of public SSH keys of potential
> master candidates (as described in
> "design-node-security.rst"). It supports the operation
> to add and remove keys and to query the file for a set of
> keys. In this patch it does not get called by any code yet;
> this will be done in the next patches. Unit tests are
> included.
>
> Signed-off-by: Helga Velroyen <hel...@google.com>
> ---
>  lib/pathutils.py               |   1 +
>  lib/ssh.py                     | 337
> +++++++++++++++++++++++++++++++++++++++++
>  test/py/ganeti.ssh_unittest.py |  80 ++++++++++
>  3 files changed, 418 insertions(+)
>
> diff --git a/lib/pathutils.py b/lib/pathutils.py
> index 0a1d962..2715504 100644
> --- a/lib/pathutils.py
> +++ b/lib/pathutils.py
> @@ -75,6 +75,7 @@ SSH_HOST_DSA_PRIV = _constants.SSH_HOST_DSA_PRIV
>  SSH_HOST_DSA_PUB = _constants.SSH_HOST_DSA_PUB
>  SSH_HOST_RSA_PRIV = _constants.SSH_HOST_RSA_PRIV
>  SSH_HOST_RSA_PUB = _constants.SSH_HOST_RSA_PUB
> +SSH_PUB_KEYS = DATA_DIR + "/ganeti_pub_keys"
>
>  BDEV_CACHE_DIR = RUN_DIR + "/bdev-cache"
>  DISK_LINKS_DIR = RUN_DIR + "/instance-disks"
> diff --git a/lib/ssh.py b/lib/ssh.py
> index d54684b..61c7d2e 100644
> --- a/lib/ssh.py
> +++ b/lib/ssh.py
> @@ -28,6 +28,8 @@ import logging
>  import os
>  import tempfile
>
> +from functools import partial
> +
>  from ganeti import utils
>  from ganeti import errors
>  from ganeti import constants
> @@ -196,6 +198,341 @@ def RemoveAuthorizedKey(file_name, key):
>      raise
>
>
> +def _AddPublicKeyProcessLine(new_uuid, new_key, line_uuid, line_key,
> tmp_file,
> +                             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 thoe node whose line in the public key
>

s/thoe/the/


> +    file is processed in this function call
> +  @type tmp_file: file descriptor
> +  @param tmp_file: the temporary file to which the manipulated public key
> +    file is written to, before replacing the original public key file
> +    automically
> +  @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
> +  @return: a possibly updated value of C{found}
> +
> +  """
> +  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
> +  else:
> +    tmp_file.write("%s %s" % (line_uuid, line_key))
>

As discussed offline, if the key already exists in the file, it will
actually get removed.


> +  return found
> +
> +
> +def _AddPublicKeyElse(new_uuid, new_key, tmp_file):
> +  """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
> +  @type tmp_file: file descriptor
> +  @param tmp_file: the file where the key is appended
> +
> +  """
> +  tmp_file.write("%s %s\n" % (new_uuid, new_key))
> +
> +
> +def _RemovePublicKeyProcessLine(
> +    target_uuid, _target_key,
> +    line_uuid, line_key, tmp_file, 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 tmp_file: file descriptor
> +  @param tmp_file: temporary file which eventually replaces the ganeti
> public
> +    key file
> +  @type found: boolean
> +  @param found: whether or not the UUID was already found.
> +
> +  """
> +  if line_uuid != target_uuid:
> +    tmp_file.write("%s %s" % (line_uuid, line_key))
> +    return found
> +  else:
> +    return True
> +
> +
> +def _RemovePublicKeyElse(
> +    target_uuid, _target_key, _tmp_file):
> +  """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)
> +  @type _tmp_file: file descriptor
> +  @param _tmp_file: the temporary file which eventually will replace the
> public
> +    key file (not used)
> +
> +  """
> +  logging.debug("Trying to remove key of node '%s' which is not in list"
> +                " of public keys.", target_uuid)
> +
> +
> +def _ReplaceNameByUuidProcessLine(
> +    node_name, _key, line_identifier, line_key, tmp_file, 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 tmp_file: file descriptor
> +  @param tmp_file: temporary file which will eventually replace the public
> +    key file
> +  @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
> +
> +  """
> +  if node_name == line_identifier:
> +    found = True
> +    tmp_file.write("%s %s" % (node_uuid, line_key))
> +  else:
> +    tmp_file.write("%s %s" % (line_identifier, line_key))
> +  return found
> +
> +
> +def _ReplaceNameByUuidElse(
> +    node_uuid, node_name, _key, _tmp_file):
> +  """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)
> +  @type _tmp_file: file descriptor
> +  @param _tmp_file: temporary file for manipulating the public key file
> +    (not used)
> +
> +  """
> +  logging.debug("Trying to replace node name '%s' with UUID '%s', but"
> +                " no line with that name was found.", node_name,
> node_uuid)
> +
> +
> +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) A temporary file is opened to write the content of the ganeti public
> key
> +  file to (possibly with changes).
> +  2) The function processes each line of the original ganeti public key
> file,
> +  applies the C{process_line_fn} function on it, which possibly writes the
> +  original line, a changed line or no line to the temporary file. If
> +  the return value of the C{process_line_fn} function is True, it will
> +  be recorded in the 'found' variable for later use.
> +  3) 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 temporary file.
> +  4) Finally, the temporary file is written to disk and moved to the
> original
> +  files name to ensure atomic writing.
> +
> +  @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
> +
> +  fd_tmp, tmpname = tempfile.mkstemp(dir=os.path.dirname(key_file))
> +  try:
> +    f_tmp = os.fdopen(fd_tmp, "w")
> +    try:
> +      f_orig = open(key_file, "r")
> +      try:
> +        found = False
> +        for line in f_orig:
> +          if len(line.strip()) == 0:
> +            continue
> +          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:])
> +          if process_line_fn(target_identifier, target_key, uuid,
> +                             key, f_tmp, found):
> +            found = True
> +        if not found:
> +          process_else_fn(target_identifier, target_key, f_tmp)
> +        f_tmp.flush()
> +        os.rename(tmpname, key_file)
> +      finally:
> +        f_orig.close()
> +    finally:
> +      f_tmp.close()
> +  except:
> +    utils.RemoveFile(tmpname)
> +    raise
>

Perhaps we could use here WriteFile to simplify the code, which does
precisely atomic update of a file, passing it a function that read the
original file and writes the output to a given file descriptor.

But if you feel that this is better, we can keep it as it is.


> +
> +
> +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 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
> +
> +  """
> +  if isinstance(target_uuids, str):
> +    target_uuids = [target_uuids]
> +  result = {}
> +  f = open(key_file, "r")
> +  try:
> +    for line in f:
>


> +      if len(line.strip()) == 0:
> +        continue
> +      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()
>

This part duplicates code from _ManipulatePubKeyFile, so I'd suggest to add
 a separate function that parses a line and converts it into a (uuid, key)
pair (or None, if the line is empty).

Or perhaps it could be a generator that reads a file, skips over empty
lines and yields all the pairs.


> +      if uuid in target_uuids:
> +        if uuid not in result:
> +          result[uuid] = []
> +        result[uuid].append(key)
> +  finally:
> +    f.close()
> +  return result
> +
> +
>  def InitSSHSetup(error_fn=errors.OpPrereqError):
>    """Setup the SSH configuration for the node.
>
> diff --git a/test/py/ganeti.ssh_unittest.py b/test/py/
> ganeti.ssh_unittest.py
> index 621d2c0..cd8da19 100755
> --- a/test/py/ganeti.ssh_unittest.py
> +++ b/test/py/ganeti.ssh_unittest.py
> @@ -216,5 +216,85 @@ class TestSshKeys(testutils.GanetiTestCase):
>        " ssh-dss AAAAB3NzaC1w520smc01ms0jfJs22 root@key-b\n")
>
>
> +class TestPublicSshKeys(testutils.GanetiTestCase):
> +  """Test case for the handling of the list of public ssh keys."""
> +
> +  KEY_A = "ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a"
> +  KEY_B = "ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b"
> +  UUID_1 = "123-456"
> +  UUID_2 = "789-ABC"
> +
> +  def setUp(self):
> +    testutils.GanetiTestCase.setUp(self)
> +
> +  def testAddingAndRemovingPubKey(self):
> +    pub_key_file = self._CreateTempFile()
> +    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
> +    ssh.AddPublicKey(self.UUID_2, self.KEY_B, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file,
> +      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
> +      "789-ABC ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
> +
> +    ssh.RemovePublicKey(self.UUID_2, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file,
> +      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n")
> +
> +  def testRemoveNonexistingKey(self):
> +    pub_key_file = self._CreateTempFile()
> +    ssh.AddPublicKey(self.UUID_1, self.KEY_B, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file,
> +      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
> +
> +    ssh.RemovePublicKey(self.UUID_2, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file,
> +      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
> +
> +  def testRemoveAllExistingKeys(self):
> +    pub_key_file = self._CreateTempFile()
> +    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
> +    ssh.AddPublicKey(self.UUID_1, self.KEY_B, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file,
> +      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
> +      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
> +
> +    ssh.RemovePublicKey(self.UUID_1, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file, "")
> +
> +  def testRemoveKeyFromEmptyFile(self):
> +    pub_key_file = self._CreateTempFile()
> +    ssh.RemovePublicKey(self.UUID_2, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file, "")
> +
> +  def testRetrieveKeys(self):
> +    pub_key_file = self._CreateTempFile()
> +    ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
> +    ssh.AddPublicKey(self.UUID_2, self.KEY_B, key_file=pub_key_file)
> +    result = ssh.QueryPubKeyFile(self.UUID_1, key_file=pub_key_file)
> +    self.assertEquals([self.KEY_A], result[self.UUID_1])
> +
> +    target_uuids = [self.UUID_1, self.UUID_2, "non-existing-UUID"]
> +    result = ssh.QueryPubKeyFile(target_uuids, key_file=pub_key_file)
> +    self.assertEquals([self.KEY_A], result[self.UUID_1])
> +    self.assertEquals([self.KEY_B], result[self.UUID_2])
> +    self.assertEquals(2, len(result))
> +
> +  def testReplaceNameByUuid(self):
> +    pub_key_file = self._CreateTempFile()
> +    name = "my.precious.node"
> +    ssh.AddPublicKey(name, self.KEY_A, key_file=pub_key_file)
> +    ssh.AddPublicKey(self.UUID_2, self.KEY_A, key_file=pub_key_file)
> +    ssh.AddPublicKey(name, self.KEY_B, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file,
> +      "my.precious.node ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a
> \n"
> +      "789-ABC ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
> +      "my.precious.node ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b
> \n")
> +
> +    ssh.ReplaceNameByUuid(self.UUID_1, name, key_file=pub_key_file)
> +    self.assertFileContent(pub_key_file,
> +      "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
> +      "789-ABC ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n"
> +      "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n")
> +
> +
>  if __name__ == "__main__":
>    testutils.GanetiTestProgram()
>

I'd suggest to add a test for adding a key for a node that is already
there. Or perhaps two, one where the new key is the same as the old one,
and one where the new key is different.


> --
> 2.0.0.526.g5318336
>
>

Reply via email to