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 <[email protected]>
---
lib/pathutils.py | 1 +
lib/ssh.py | 349 +++++++++++++++++++++++++++++++++++++++++
test/py/ganeti.ssh_unittest.py | 117 ++++++++++++++
3 files changed, 467 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..0e92317 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,353 @@ 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 the node whose line in the public key
+ 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
+ tmp_file.write("%s %s\n" % (line_uuid, line_key))
+ 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\n" % (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\n" % (node_uuid, line_key))
+ else:
+ tmp_file.write("%s %s\n" % (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 _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) 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:
+ (uuid, key) = _ParseKeyLine(line, error_fn)
+ if not uuid:
+ continue
+ 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
+
+
+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:
+ (uuid, key) = _ParseKeyLine(line, error_fn)
+ if not uuid:
+ continue
+ 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..a928268 100755
--- a/test/py/ganeti.ssh_unittest.py
+++ b/test/py/ganeti.ssh_unittest.py
@@ -216,5 +216,122 @@ 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 testAddingExistingPubKey(self):
+ expected_file_content = \
+ "123-456 ssh-dss AAAAB3NzaC1w5256closdj32mZaQU root@key-a\n" + \
+ "789-ABC ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n"
+ 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, expected_file_content)
+
+ ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
+ self.assertFileContent(pub_key_file, expected_file_content)
+
+ 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"
+ "789-ABC ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\n"
+ "123-456 ssh-dss BAasjkakfa234SFSFDA345462AAAB root@key-b\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")
+
+ def testParseEmptyLines(self):
+ pub_key_file = self._CreateTempFile()
+ ssh.AddPublicKey(self.UUID_1, self.KEY_A, key_file=pub_key_file)
+
+ # Add an empty line
+ fd = open(pub_key_file, 'a')
+ fd.write("\n")
+ fd.close()
+
+ ssh.AddPublicKey(self.UUID_2, self.KEY_B, key_file=pub_key_file)
+
+ # Add a whitespace line
+ fd = open(pub_key_file, 'a')
+ fd.write(" \n")
+ fd.close()
+
+ result = ssh.QueryPubKeyFile(self.UUID_1, key_file=pub_key_file)
+ self.assertEquals([self.KEY_A], result[self.UUID_1])
+
+
if __name__ == "__main__":
testutils.GanetiTestProgram()
--
2.1.0.rc2.206.gedb03e5