From 6d4a16569d3bb53641b77f1232689e15c7cf7a83 Mon Sep 17 00:00:00 2001
From: Michael Hanselmann <hansmi@google.com>
Date: Fri, 12 Mar 2010 16:16:08 +0100
Subject: [PATCH] =?UTF-8?q?Implement=20replacing=20cluster=20certs=20and?=
 =?UTF-8?q?=20keys=20via=20=E2=80=9Cgnt-cluster=20renew-crypto=E2=80=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Recent changes to β€œgnt-cluster verify” made it complain on expiring SSL
certificates. While it was possible to replace the SSL certificates and
other cluster secrets manually before, doing so was cumbersome. Cluster
certificates, keys and secrets can now be replaced easily.

Signed-off-by: Michael Hanselmann <hansmi@google.com>
Reviewed-by: Iustin Pop <iustin@google.com>
---
 lib/cli.py           |  22 +++++++++
 man/gnt-cluster.sgml |  37 ++++++++++++++-
 qa/ganeti-qa.py      |   5 ++
 qa/qa-sample.json    |   1 +
 qa/qa_cluster.py     |  48 ++++++++++++++++++-
 scripts/gnt-cluster  | 109 +++++++++++++++++++++++++++++++++++++++++--
 6 files changed, 216 insertions(+), 6 deletions(-)

diff --git a/lib/cli.py b/lib/cli.py
index 558feacf6..5d0a66a9b 100644
--- a/lib/cli.py
+++ b/lib/cli.py
@@ -79,6 +79,9 @@ __all__ = [
   "MASTER_NETDEV_OPT",
   "MC_OPT",
   "NET_OPT",
+  "NEW_CLUSTER_CERT_OPT",
+  "NEW_HMAC_KEY_OPT",
+  "NEW_RAPI_CERT_OPT",
   "NEW_SECONDARY_OPT",
   "NIC_PARAMS_OPT",
   "NODE_LIST_OPT",
@@ -102,6 +105,7 @@ __all__ = [
   "OFFLINE_OPT",
   "OS_OPT",
   "OS_SIZE_OPT",
+  "RAPI_CERT_OPT",
   "READD_OPT",
   "REBOOT_TYPE_OPT",
   "SECONDARY_IP_OPT",
@@ -860,6 +864,24 @@ EARLY_RELEASE_OPT = cli_option("--early-release",
                                help="Release the locks on the secondary"
                                " node(s) early")
 
+NEW_CLUSTER_CERT_OPT = cli_option("--new-cluster-certificate",
+                                  dest="new_cluster_cert",
+                                  default=False, action="store_true",
+                                  help="Generate a new cluster certificate")
+
+RAPI_CERT_OPT = cli_option("--rapi-certificate", dest="rapi_cert",
+                           default=None,
+                           help="File containing new RAPI certificate")
+
+NEW_RAPI_CERT_OPT = cli_option("--new-rapi-certificate", dest="new_rapi_cert",
+                               default=None, action="store_true",
+                               help=("Generate a new self-signed RAPI"
+                                     " certificate"))
+
+NEW_HMAC_KEY_OPT = cli_option("--new-hmac-key", dest="new_hmac_key",
+                              default=False, action="store_true",
+                              help="Create a new HMAC key")
+
 
 def _ParseArgs(argv, commands, aliases):
   """Parser for the command line arguments.
diff --git a/man/gnt-cluster.sgml b/man/gnt-cluster.sgml
index 4b3b8223f..e7947d049 100644
--- a/man/gnt-cluster.sgml
+++ b/man/gnt-cluster.sgml
@@ -540,7 +540,7 @@
         <sbr>
         <arg choice="opt">--nic-parameters <replaceable>nic-param</replaceable>=<replaceable>value</replaceable><arg rep="repeat" choice="opt">,<replaceable>nic-param</replaceable>=<replaceable>value</replaceable></arg></arg>
         <sbr>
-        <arg>-C <replaceable>candidate_pool_size</replaceable></arg>
+        <arg choice="opt">-C <replaceable>candidate_pool_size</replaceable></arg>
 
       </cmdsynopsis>
 
@@ -558,7 +558,7 @@
         </para>
 
       <para>
-        The <option>-C</option> options specifies the
+        The <option>-C</option> option specifies the
         <varname>candidate_pool_size</varname> cluster parameter. This
         is the number of nodes that the master will try to keep as
         <literal>master_candidates</literal>. For more details about
@@ -703,6 +703,39 @@
       </para>
     </refsect2>
 
+    <refsect2>
+      <title>RENEW-CRYPTO</title>
+
+      <cmdsynopsis>
+        <command>renew-crypto</command>
+        <arg>-f</arg>
+        <sbr>
+        <arg choice="opt">--new-cluster-certificate</arg>
+        <arg choice="opt">--new-hmac-key</arg>
+        <sbr>
+        <arg choice="opt">--new-rapi-certificate</arg>
+        <arg choice="opt">--rapi-certificate <replaceable>rapi-cert</replaceable></arg>
+      </cmdsynopsis>
+
+      <para>
+        This command will stop all
+        Ganeti daemons in the cluster and start them again once the new
+        certificates and keys are replicated. The options
+        <option>--new-cluster-certificate</option> and
+        <option>--new-hmac-key</option> can be used to regenerate the
+        cluster-internal SSL certificate respective the HMAC key used by
+        <citerefentry>
+        <refentrytitle>ganeti-confd</refentrytitle><manvolnum>8</manvolnum>
+        </citerefentry>. To generate a new self-signed RAPI certificate (used
+        by <citerefentry>
+        <refentrytitle>ganeti-rapi</refentrytitle><manvolnum>8</manvolnum>
+        </citerefentry>) specify <option>--new-rapi-certificate</option>. If
+        you want to use your own certificate, e.g. one signed by a certificate
+        authority (CA), pass its filename to
+        <option>--rapi-certificate</option>.
+      </para>
+    </refsect2>
+
     <refsect2>
       <title>REPAIR-DISK-SIZES</title>
 
diff --git a/qa/ganeti-qa.py b/qa/ganeti-qa.py
index 73c01aee4..d90ff6b5b 100755
--- a/qa/ganeti-qa.py
+++ b/qa/ganeti-qa.py
@@ -88,6 +88,9 @@ def RunClusterTests():
   """Runs tests related to gnt-cluster.
 
   """
+  if qa_config.TestEnabled("cluster-renew-crypto"):
+    RunTest(qa_cluster.TestClusterRenewCrypto)
+
   if qa_config.TestEnabled('cluster-verify'):
     RunTest(qa_cluster.TestClusterVerify)
 
@@ -115,6 +118,7 @@ def RunClusterTests():
     RunTest(qa_rapi.TestVersion)
     RunTest(qa_rapi.TestEmptyCluster)
 
+
 def RunOsTests():
   """Runs all tests related to gnt-os.
 
@@ -176,6 +180,7 @@ def RunCommonInstanceTests(instance):
   if qa_rapi.Enabled():
     RunTest(qa_rapi.TestInstance, instance)
 
+
 def RunExportImportTests(instance, pnode):
   """Tries to export and import the instance.
 
diff --git a/qa/qa-sample.json b/qa/qa-sample.json
index d167e06e9..a150b37d7 100644
--- a/qa/qa-sample.json
+++ b/qa/qa-sample.json
@@ -45,6 +45,7 @@
     "cluster-command": true,
     "cluster-copyfile": true,
     "cluster-master-failover": true,
+    "cluster-renew-crypto": true,
     "cluster-destroy": true,
     "cluster-rename": true,
 
diff --git a/qa/qa_cluster.py b/qa/qa_cluster.py
index 9ca548337..d4efc50de 100644
--- a/qa/qa_cluster.py
+++ b/qa/qa_cluster.py
@@ -25,13 +25,15 @@
 
 import tempfile
 
+from ganeti import constants
+from ganeti import bootstrap
 from ganeti import utils
 
 import qa_config
 import qa_utils
 import qa_error
 
-from qa_utils import AssertEqual, StartSSH
+from qa_utils import AssertEqual, AssertNotEqual, StartSSH
 
 
 def _RemoveFileFromAllNodes(filename):
@@ -144,6 +146,50 @@ def TestClusterVersion():
                        utils.ShellQuoteArgs(cmd)).wait(), 0)
 
 
+def TestClusterRenewCrypto():
+  """gnt-cluster renew-crypto"""
+  master = qa_config.GetMasterNode()
+
+  # Conflicting options
+  cmd = ["gnt-cluster", "renew-crypto", "--force",
+         "--new-cluster-certificate", "--new-hmac-key",
+         "--new-rapi-certificate", "--rapi-certificate=/dev/null"]
+  AssertNotEqual(StartSSH(master["primary"],
+                          utils.ShellQuoteArgs(cmd)).wait(), 0)
+
+  # Invalid RAPI certificate
+  cmd = ["gnt-cluster", "renew-crypto", "--force",
+         "--rapi-certificate=/dev/null"]
+  AssertNotEqual(StartSSH(master["primary"],
+                          utils.ShellQuoteArgs(cmd)).wait(), 0)
+
+  # Custom RAPI certificate
+  fh = tempfile.NamedTemporaryFile()
+
+  # Ensure certificate doesn't cause "gnt-cluster verify" to complain
+  validity = constants.SSL_CERT_EXPIRATION_WARN * 3
+
+  bootstrap.GenerateSelfSignedSslCert(fh.name, validity=validity)
+
+  tmpcert = qa_utils.UploadFile(master["primary"], fh.name)
+  try:
+    cmd = ["gnt-cluster", "renew-crypto", "--force",
+           "--rapi-certificate=%s" % tmpcert]
+    AssertEqual(StartSSH(master["primary"],
+                         utils.ShellQuoteArgs(cmd)).wait(), 0)
+  finally:
+    cmd = ["rm", "-f", tmpcert]
+    AssertEqual(StartSSH(master["primary"],
+                         utils.ShellQuoteArgs(cmd)).wait(), 0)
+
+  # Normal case
+  cmd = ["gnt-cluster", "renew-crypto", "--force",
+         "--new-cluster-certificate", "--new-hmac-key",
+         "--new-rapi-certificate"]
+  AssertEqual(StartSSH(master["primary"],
+                       utils.ShellQuoteArgs(cmd)).wait(), 0)
+
+
 def TestClusterBurnin():
   """Burnin"""
   master = qa_config.GetMasterNode()
diff --git a/scripts/gnt-cluster b/scripts/gnt-cluster
index 59b73a7b0..01ecedbc9 100755
--- a/scripts/gnt-cluster
+++ b/scripts/gnt-cluster
@@ -29,6 +29,7 @@
 import sys
 import os.path
 import time
+import OpenSSL
 
 from ganeti.cli import *
 from ganeti import opcodes
@@ -493,6 +494,100 @@ def SearchTags(opts, args):
     ToStdout("%s %s", path, tag)
 
 
+def _RenewCrypto(new_cluster_cert, new_rapi_cert, rapi_cert_filename,
+                 new_hmac_key, force):
+  """Renews cluster certificates, keys and secrets.
+
+  @type new_cluster_cert: bool
+  @param new_cluster_cert: Whether to generate a new cluster certificate
+  @type new_rapi_cert: bool
+  @param new_rapi_cert: Whether to generate a new RAPI certificate
+  @type rapi_cert_filename: string
+  @param rapi_cert_filename: Path to file containing new RAPI certificate
+  @type new_hmac_key: bool
+  @param new_hmac_key: Whether to generate a new HMAC key
+  @type force: bool
+  @param force: Whether to ask user for confirmation
+
+  """
+  assert new_cluster_cert or new_rapi_cert or rapi_cert_filename or new_hmac_key
+
+  if new_rapi_cert and rapi_cert_filename:
+    ToStderr("Only one of the --new-rapi-certficate and --rapi-certificate"
+             " options can be specified at the same time.")
+    return 1
+
+  if rapi_cert_filename:
+    # Read and verify new certificate
+    try:
+      rapi_cert_pem = utils.ReadFile(rapi_cert_filename)
+
+      OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM,
+                                      rapi_cert_pem)
+    except Exception, err: # pylint: disable-msg=W0703
+      ToStderr("Can't load new RAPI certificate from %s: %s" %
+               (rapi_cert_filename, str(err)))
+      return 1
+
+    try:
+      OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, rapi_cert_pem)
+    except Exception, err: # pylint: disable-msg=W0703
+      ToStderr("Can't load new RAPI private key from %s: %s" %
+               (rapi_cert_filename, str(err)))
+      return 1
+
+  else:
+    rapi_cert_pem = None
+
+  if not force:
+    usertext = ("This requires all daemons on all nodes to be restarted and"
+                " may take some time. Continue?")
+    if not AskUser(usertext):
+      return 1
+
+  def _RenewCryptoInner(ctx):
+    ctx.feedback_fn("Updating certificates and keys")
+    bootstrap.GenerateClusterCrypto(new_cluster_cert, new_rapi_cert,
+                                    new_hmac_key,
+                                    rapi_cert_pem=rapi_cert_pem)
+
+    files_to_copy = []
+
+    if new_cluster_cert:
+      files_to_copy.append(constants.SSL_CERT_FILE)
+
+    if new_rapi_cert or rapi_cert_pem:
+      files_to_copy.append(constants.RAPI_CERT_FILE)
+
+    if new_hmac_key:
+      files_to_copy.append(constants.HMAC_CLUSTER_KEY)
+
+    if files_to_copy:
+      for node_name in ctx.nonmaster_nodes:
+        ctx.feedback_fn("Copying %s to %s" %
+                        (", ".join(files_to_copy), node_name))
+        for file_name in files_to_copy:
+          ctx.ssh.CopyFileToNode(node_name, file_name)
+
+  RunWhileClusterStopped(ToStdout, _RenewCryptoInner)
+
+  ToStdout("All requested certificates and keys have been replaced."
+           " Running \"gnt-cluster verify\" now is recommended.")
+
+  return 0
+
+
+def RenewCrypto(opts, args):
+  """Renews cluster certificates, keys and secrets.
+
+  """
+  return _RenewCrypto(opts.new_cluster_cert,
+                      opts.new_rapi_cert,
+                      opts.rapi_cert,
+                      opts.new_hmac_key,
+                      opts.force)
+
+
 def SetClusterParams(opts, args):
   """Modify the cluster.
 
@@ -512,10 +607,11 @@ def SetClusterParams(opts, args):
 
   vg_name = opts.vg_name
   if not opts.lvm_storage and opts.vg_name:
-    ToStdout("Options --no-lvm-storage and --vg-name conflict.")
+    ToStderr("Options --no-lvm-storage and --vg-name conflict.")
     return 1
-  elif not opts.lvm_storage:
-    vg_name = ''
+
+  if not opts.lvm_storage:
+    vg_name = ""
 
   hvlist = opts.enabled_hypervisors
   if hvlist is not None:
@@ -692,7 +788,14 @@ commands = {
      NIC_PARAMS_OPT, NOLVM_STORAGE_OPT, VG_NAME_OPT],
     "[opts...]",
     "Alters the parameters of the cluster"),
+  "renew-crypto": (
+    RenewCrypto, ARGS_NONE,
+    [NEW_CLUSTER_CERT_OPT, NEW_RAPI_CERT_OPT, RAPI_CERT_OPT, NEW_HMAC_KEY_OPT,
+     FORCE_OPT],
+    "[opts...]",
+    "Renews cluster certificates, keys and secrets"),
   }
 
+
 if __name__ == '__main__':
   sys.exit(GenericMain(commands, override={"tag_type": constants.TAG_CLUSTER}))
-- 
GitLab