Introduce ssl_update tool
authorHelga Velroyen <helgav@google.com>
Fri, 5 Jun 2015 13:45:00 +0000 (15:45 +0200)
committerHelga Velroyen <helgav@google.com>
Mon, 6 Jul 2015 10:45:45 +0000 (12:45 +0200)
In order to renew client certificates via SSH (rather than
on the fly via SSL as it was before), we need a new tool
which can be called on remote nodes via SSH.

Signed-off-by: Helga Velroyen <helgav@google.com>
Reviewed-by: Klaus Aehlig <aehlig@google.com>

Makefile.am
lib/tools/ssl_update.py [copied from lib/tools/node_cleanup.py with 52% similarity]
src/Ganeti/Constants.hs
test/py/ganeti.tools.ssl_update_unittest.py [copied from test/py/ganeti.backend_unittest-runasroot.py with 51% similarity]

index 5eecb8e..b4fdba0 100644 (file)
@@ -551,7 +551,8 @@ pytools_PYTHON = \
        lib/tools/ensure_dirs.py \
        lib/tools/node_cleanup.py \
        lib/tools/node_daemon_setup.py \
-       lib/tools/prepare_node_join.py
+       lib/tools/prepare_node_join.py \
+       lib/tools/ssl_update.py
 
 utils_PYTHON = \
        lib/utils/__init__.py \
@@ -2307,6 +2308,7 @@ tools/ensure-dirs: MODULE = ganeti.tools.ensure_dirs
 tools/node-daemon-setup: MODULE = ganeti.tools.node_daemon_setup
 tools/prepare-node-join: MODULE = ganeti.tools.prepare_node_join
 tools/node-cleanup: MODULE = ganeti.tools.node_cleanup
+tools/ssl-update: MODULE = ganeti.tools.ssl_update
 $(HS_BUILT_TEST_HELPERS): TESTROLE = $(patsubst test/hs/%,%,$@)
 
 $(PYTHON_BOOTSTRAP) $(gnt_scripts) $(gnt_python_sbin_SCRIPTS): Makefile | stamp-directories
similarity index 52%
copy from lib/tools/node_cleanup.py
copy to lib/tools/ssl_update.py
index f8ec076..36453d2 100644 (file)
@@ -1,7 +1,7 @@
 #
 #
 
-# Copyright (C) 2012 Google Inc.
+# Copyright (C) 2015 Google Inc.
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
@@ -27,7 +27,7 @@
 # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
-"""Script to configure the node daemon.
+"""Script to recreate and sign the client SSL certificates.
 
 """
 
@@ -36,12 +36,28 @@ import os.path
 import optparse
 import sys
 import logging
+import time
 
 from ganeti import cli
 from ganeti import constants
-from ganeti import pathutils
-from ganeti import ssconf
+from ganeti import errors
 from ganeti import utils
+from ganeti import ht
+from ganeti import pathutils
+from ganeti.tools import common
+
+
+_DATA_CHECK = ht.TStrictDict(False, True, {
+  constants.NDS_CLUSTER_NAME: ht.TNonEmptyString,
+  constants.NDS_NODE_DAEMON_CERTIFICATE: ht.TNonEmptyString,
+  constants.NDS_NODE_NAME: ht.TNonEmptyString,
+  })
+
+
+class SslSetupError(errors.GenericError):
+  """Local class for reporting errors.
+
+  """
 
 
 def ParseOptions():
@@ -50,28 +66,43 @@ def ParseOptions():
   @return: Options and arguments
 
   """
-  parser = optparse.OptionParser(usage="%prog [--no-backup]",
+  parser = optparse.OptionParser(usage="%prog [--dry-run]",
                                  prog=os.path.basename(sys.argv[0]))
   parser.add_option(cli.DEBUG_OPT)
   parser.add_option(cli.VERBOSE_OPT)
-  parser.add_option(cli.YES_DOIT_OPT)
-  parser.add_option("--no-backup", dest="backup", default=True,
-                    action="store_false",
-                    help="Whether to create backup copies of deleted files")
+  parser.add_option(cli.DRY_RUN_OPT)
 
   (opts, args) = parser.parse_args()
 
-  return VerifyOptions(parser, opts, args)
+  return common.VerifyOptions(parser, opts, args)
 
 
-def VerifyOptions(parser, opts, args):
-  """Verifies options and arguments for correctness.
+def RegenerateClientCertificate(
+    data, client_cert=pathutils.NODED_CLIENT_CERT_FILE,
+    signing_cert=pathutils.NODED_CERT_FILE):
+  """Regenerates the client certificate of the node.
+
+  @type data: string
+  @param data: the JSON-formated input data
 
   """
-  if args:
-    parser.error("No arguments are expected")
+  if not os.path.exists(signing_cert):
+    raise SslSetupError("The signing certificate '%s' cannot be found."
+                        % signing_cert)
+
+  # TODO: This sets the serial number to the number of seconds
+  # since epoch. This is technically not a correct serial number
+  # (in the way SSL is supposed to be used), but it serves us well
+  # enough for now, as we don't have any infrastructure for keeping
+  # track of the number of signed certificates yet.
+  serial_no = int(time.time())
 
-  return opts
+  # The hostname of the node is provided with the input data.
+  hostname = data.get(constants.NDS_NODE_NAME)
+
+  # TODO: make backup of the file before regenerating.
+  utils.GenerateSignedSslCert(client_cert, serial_no, signing_cert,
+                              common_name=hostname)
 
 
 def Main():
@@ -83,40 +114,16 @@ def Main():
   utils.SetupToolLogging(opts.debug, opts.verbose)
 
   try:
-    # List of files to delete. Contains tuples consisting of the absolute path
-    # and a boolean denoting whether a backup copy should be created before
-    # deleting.
-    clean_files = [
-      (pathutils.CONFD_HMAC_KEY, True),
-      (pathutils.CLUSTER_CONF_FILE, True),
-      (pathutils.CLUSTER_DOMAIN_SECRET_FILE, True),
-      ]
-    clean_files.extend(map(lambda s: (s, True), pathutils.ALL_CERT_FILES))
-    clean_files.extend(map(lambda s: (s, False),
-                           ssconf.SimpleStore().GetFileList()))
-
-    if not opts.yes_do_it:
-      cli.ToStderr("Cleaning a node is irreversible. If you really want to"
-                   " clean this node, supply the --yes-do-it option.")
-      return constants.EXIT_FAILURE
-
-    logging.info("Stopping daemons")
-    result = utils.RunCmd([pathutils.DAEMON_UTIL, "stop-all"],
-                          interactive=True)
-    if result.failed:
-      raise Exception("Could not stop daemons, command '%s' failed: %s" %
-                      (result.cmd, result.fail_reason))
-
-    for (filename, backup) in clean_files:
-      if os.path.exists(filename):
-        if opts.backup and backup:
-          logging.info("Backing up %s", filename)
-          utils.CreateBackup(filename)
-
-        logging.info("Removing %s", filename)
-        utils.RemoveFile(filename)
-
-    logging.info("Node successfully cleaned")
+    data = common.LoadData(sys.stdin.read(), _DATA_CHECK)
+
+    common.VerifyClusterName(data, SslSetupError)
+
+    # Verifies whether the server certificate of the caller
+    # is the same as on this node.
+    common.VerifyCertificate(data, SslSetupError)
+
+    RegenerateClientCertificate(data)
+
   except Exception, err: # pylint: disable=W0703
     logging.debug("Caught unhandled exception", exc_info=True)
 
index 106e4d4..d31189a 100644 (file)
@@ -4474,6 +4474,9 @@ ndsSsconf = "ssconf"
 ndsStartNodeDaemon :: String
 ndsStartNodeDaemon = "start_node_daemon"
 
+ndsNodeName :: String
+ndsNodeName = "node_name"
+
 -- * VCluster related constants
 
 vClusterEtcHosts :: String
similarity index 51%
copy from test/py/ganeti.backend_unittest-runasroot.py
copy to test/py/ganeti.tools.ssl_update_unittest.py
index e0f1690..e4b3ad9 100755 (executable)
@@ -1,7 +1,7 @@
 #!/usr/bin/python
 #
 
-# Copyright (C) 2012 Google Inc.
+# Copyright (C) 2015 Google Inc.
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 
-"""Script for testing ganeti.backend (tests requiring root access)"""
+"""Script for testing ganeti.tools.ssl_update"""
 
-import os
-import tempfile
+import unittest
 import shutil
-import errno
+import tempfile
+import os.path
+import OpenSSL
+import time
 
+from ganeti import errors
 from ganeti import constants
-from ganeti import utils
+from ganeti import serializer
+from ganeti import pathutils
 from ganeti import compat
-from ganeti import backend
+from ganeti import utils
+from ganeti.tools import ssl_update
 
 import testutils
 
 
-class TestCommonRestrictedCmdCheck(testutils.GanetiTestCase):
+class TestGenerateClientCert(unittest.TestCase):
   def setUp(self):
     self.tmpdir = tempfile.mkdtemp()
 
-  def tearDown(self):
-    shutil.rmtree(self.tmpdir)
-
-  def _PrepareTest(self):
-    tmpname = utils.PathJoin(self.tmpdir, "foobar")
-    os.mkdir(tmpname)
-    os.chmod(tmpname, 0700)
-    return tmpname
-
-  def testCorrectOwner(self):
-    tmpname = self._PrepareTest()
-
-    os.chown(tmpname, 0, 0)
-    (status, value) = backend._CommonRestrictedCmdCheck(tmpname, None)
-    self.assertTrue(status)
-    self.assertTrue(value)
+    self.client_cert = os.path.join(self.tmpdir, "client.pem")
 
-  def testWrongOwner(self):
-    tmpname = self._PrepareTest()
+    self.server_cert = os.path.join(self.tmpdir, "server.pem")
+    some_serial_no = int(time.time())
+    utils.GenerateSelfSignedSslCert(self.server_cert, some_serial_no)
 
-    tests = [
-      (1, 0),
-      (0, 1),
-      (100, 50),
-      ]
-
-    for (uid, gid) in tests:
-      self.assertFalse(uid == os.getuid() and gid == os.getgid())
-      os.chown(tmpname, uid, gid)
+  def tearDown(self):
+    shutil.rmtree(self.tmpdir)
 
-      (status, errmsg) = backend._CommonRestrictedCmdCheck(tmpname, None)
-      self.assertFalse(status)
-      self.assertTrue("foobar' is not owned by " in errmsg)
+  def testRegnerateClientCertificate(self):
+    my_node_name = "mynode.example.com"
+    data = {constants.NDS_CLUSTER_NAME: "winnie_poohs_cluster",
+            constants.NDS_NODE_DAEMON_CERTIFICATE: "some_cert",
+            constants.NDS_NODE_NAME: my_node_name}
+
+    ssl_update.RegenerateClientCertificate(data, client_cert=self.client_cert,
+                                           signing_cert=self.server_cert)
+
+    client_cert_pem = utils.ReadFile(self.client_cert)
+    server_cert_pem = utils.ReadFile(self.server_cert)
+    client_cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM,
+                                                  client_cert_pem)
+    signing_cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM,
+                                                   server_cert_pem)
+    self.assertEqual(client_cert.get_issuer().CN, signing_cert.get_subject().CN)
+    self.assertEqual(client_cert.get_subject().CN, my_node_name)
 
 
 if __name__ == "__main__":