diff --git a/lib/cli.py b/lib/cli.py index 558feacf6473abf7c93b869250103e40412ea6a8..5d0a66a9b204e463870a070f8046a9aa8e05448d 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 4b3b8223fcf333514d4deacb1170aa59d03c8819..e7947d049ed3870f8256576b2158c2f11aaaf738 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 73c01aee46dcebb7929c32cd3414ce3f1dc9fa88..d90ff6b5b98880b9302d2c9ec203586071840c67 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 d167e06e9831fcf58756d1730d4d8d6f5e5debe0..a150b37d783965ae37da1ee396dfda681a6ee261 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 9ca548337fab63b8d966c804ff12e7e92dc2bd09..d4efc50deb3b52713ac3412e8015648f2f4028de 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 59b73a7b0bdf92d5d0b7bbaa3665381f9c04f642..01ecedbc9cf681a2a729a4f97deee8f9a2e2692c 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}))