Commit 2be56731 authored by Helga Velroyen's avatar Helga Velroyen

Introducing the 'ssh_update' tool

In order to update the 'ganeti_pub_keys' and the
'authorized_keys' files of various nodes via SSH, we
introduce the tool 'ssh_update'. It works similar to the
tool 'prepare_node_join', which is also a tool invoked
via SSH on a remote note.

This patch includes some refactoring to reuse code from
the 'prepare_node_join' tool and provides unit tests as
well. Note that the actual invocation of the 'ssh_update'
tool will be done in later patches of this series.
Signed-off-by: default avatarHelga Velroyen <helgav@google.com>
Reviewed-by: default avatarPetr Pudlak <pudlak@google.com>
parent f1193a87
......@@ -127,6 +127,7 @@
/tools/node-cleanup
/tools/node-daemon-setup
/tools/prepare-node-join
/tools/ssh-update
# scripts
/scripts/gnt-backup
......
......@@ -320,6 +320,8 @@ CLEANFILES = \
tools/net-common \
tools/users-setup \
tools/vcluster-setup \
tools/prepare-node-join \
tools/ssh-update \
stamp-directories \
stamp-srclinks \
$(nodist_pkgpython_PYTHON) \
......@@ -566,7 +568,9 @@ 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/common.py \
lib/tools/ssh_update.py
utils_PYTHON = \
lib/utils/__init__.py \
......@@ -1172,7 +1176,8 @@ PYTHON_BOOTSTRAP = \
tools/ensure-dirs \
tools/node-cleanup \
tools/node-daemon-setup \
tools/prepare-node-join
tools/prepare-node-join \
tools/ssh-update
qa_scripts = \
qa/__init__.py \
......@@ -1348,7 +1353,8 @@ pkglib_python_scripts = \
nodist_pkglib_python_scripts = \
tools/ensure-dirs \
tools/node-daemon-setup \
tools/prepare-node-join
tools/prepare-node-join \
tools/ssh-update
pkglib_python_basenames = \
$(patsubst daemons/%,%,$(patsubst tools/%,%,\
......@@ -2247,6 +2253,7 @@ tools/burnin: MODULE = ganeti.tools.burnin
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/ssh-update: MODULE = ganeti.tools.ssh_update
tools/node-cleanup: MODULE = ganeti.tools.node_cleanup
$(HS_BUILT_TEST_HELPERS): TESTROLE = $(patsubst test/hs/%,%,$@)
......
......@@ -64,6 +64,7 @@ IMPORT_EXPORT_DAEMON = _constants.PKGLIBDIR + "/import-export"
KVM_CONSOLE_WRAPPER = _constants.PKGLIBDIR + "/tools/kvm-console-wrapper"
KVM_IFUP = _constants.PKGLIBDIR + "/kvm-ifup"
PREPARE_NODE_JOIN = _constants.PKGLIBDIR + "/prepare-node-join"
SSH_UPDATE = _constants.PKGLIBDIR + "/ssh-update"
NODE_DAEMON_SETUP = _constants.PKGLIBDIR + "/node-daemon-setup"
XEN_CONSOLE_WRAPPER = _constants.PKGLIBDIR + "/tools/xen-console-wrapper"
CFGUPGRADE = _constants.PKGLIBDIR + "/tools/cfgupgrade"
......
#
#
# Copyright (C) 2014 Google Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""Common functions for tool scripts.
"""
import OpenSSL
from ganeti import constants
from ganeti import errors
from ganeti import utils
from ganeti import serializer
from ganeti import ssconf
def VerifyOptions(parser, opts, args):
"""Verifies options and arguments for correctness.
"""
if args:
parser.error("No arguments are expected")
return opts
def _VerifyCertificate(cert_pem, error_fn,
_check_fn=utils.CheckNodeCertificate):
"""Verifies a certificate against the local node daemon certificate.
@type cert_pem: string
@param cert_pem: Certificate in PEM format (no key)
"""
try:
OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, cert_pem)
except OpenSSL.crypto.Error, err:
pass
else:
raise error_fn("No private key may be given")
try:
cert = \
OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert_pem)
except Exception, err:
raise errors.X509CertError("(stdin)",
"Unable to load certificate: %s" % err)
_check_fn(cert)
def VerifyCertificate(data, error_fn, _verify_fn=_VerifyCertificate):
"""Verifies cluster certificate.
@type data: dict
"""
cert = data.get(constants.SSHS_NODE_DAEMON_CERTIFICATE)
if cert:
_verify_fn(cert, error_fn)
def VerifyClusterName(data, error_fn,
_verify_fn=ssconf.VerifyClusterName):
"""Verifies cluster name.
@type data: dict
"""
name = data.get(constants.SSHS_CLUSTER_NAME)
if name:
_verify_fn(name)
else:
raise error_fn("Cluster name must be specified")
def LoadData(raw, data_check):
"""Parses and verifies input data.
@rtype: dict
"""
return serializer.LoadAndVerifyJson(raw, data_check)
......@@ -36,17 +36,15 @@ import os.path
import optparse
import sys
import logging
import OpenSSL
from ganeti import cli
from ganeti import constants
from ganeti import errors
from ganeti import pathutils
from ganeti import utils
from ganeti import serializer
from ganeti import ht
from ganeti import ssh
from ganeti import ssconf
from ganeti.tools import common
_SSH_KEY_LIST_ITEM = \
......@@ -91,65 +89,7 @@ def ParseOptions():
(opts, args) = parser.parse_args()
return VerifyOptions(parser, opts, args)
def VerifyOptions(parser, opts, args):
"""Verifies options and arguments for correctness.
"""
if args:
parser.error("No arguments are expected")
return opts
def _VerifyCertificate(cert_pem, _check_fn=utils.CheckNodeCertificate):
"""Verifies a certificate against the local node daemon certificate.
@type cert_pem: string
@param cert_pem: Certificate in PEM format (no key)
"""
try:
OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, cert_pem)
except OpenSSL.crypto.Error, err:
pass
else:
raise JoinError("No private key may be given")
try:
cert = \
OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert_pem)
except Exception, err:
raise errors.X509CertError("(stdin)",
"Unable to load certificate: %s" % err)
_check_fn(cert)
def VerifyCertificate(data, _verify_fn=_VerifyCertificate):
"""Verifies cluster certificate.
@type data: dict
"""
cert = data.get(constants.SSHS_NODE_DAEMON_CERTIFICATE)
if cert:
_verify_fn(cert)
def VerifyClusterName(data, _verify_fn=ssconf.VerifyClusterName):
"""Verifies cluster name.
@type data: dict
"""
name = data.get(constants.SSHS_CLUSTER_NAME)
if name:
_verify_fn(name)
else:
raise JoinError("Cluster name must be specified")
return common.VerifyOptions(parser, opts, args)
def _UpdateKeyFiles(keys, dry_run, keyfiles):
......@@ -250,15 +190,6 @@ def UpdateSshRoot(data, dry_run, _homedir_fn=None):
ssh.AddAuthorizedKeys(auth_keys_file, all_authorized_keys)
def LoadData(raw):
"""Parses and verifies input data.
@rtype: dict
"""
return serializer.LoadAndVerifyJson(raw, _DATA_CHECK)
def Main():
"""Main routine.
......@@ -268,11 +199,11 @@ def Main():
utils.SetupToolLogging(opts.debug, opts.verbose)
try:
data = LoadData(sys.stdin.read())
data = common.LoadData(sys.stdin.read(), _DATA_CHECK)
# Check if input data is correct
VerifyClusterName(data)
VerifyCertificate(data)
common.VerifyClusterName(data, JoinError)
common.VerifyCertificate(data, JoinError)
# Update SSH files
UpdateSshDaemon(data, opts.dry_run)
......
#
#
# Copyright (C) 2014 Google Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# 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 update a node's SSH key files.
This script is used to update the node's 'authorized_keys' and
'ganeti_pub_key' files. It will be called via SSH from the master
node.
"""
import os
import os.path
import optparse
import sys
import logging
from ganeti import cli
from ganeti import constants
from ganeti import errors
from ganeti import utils
from ganeti import ht
from ganeti import ssh
from ganeti import pathutils
from ganeti.tools import common
_DATA_CHECK = ht.TStrictDict(False, True, {
constants.SSHS_CLUSTER_NAME: ht.TNonEmptyString,
constants.SSHS_NODE_DAEMON_CERTIFICATE: ht.TNonEmptyString,
constants.SSHS_SSH_PUBLIC_KEYS:
ht.TDictOf(ht.TNonEmptyString, ht.TListOf(ht.TNonEmptyString)),
constants.SSHS_SSH_AUTHORIZED_KEYS:
ht.TDictOf(ht.TNonEmptyString, ht.TListOf(ht.TNonEmptyString)),
})
class SshUpdateError(errors.GenericError):
"""Local class for reporting errors.
"""
def ParseOptions():
"""Parses the options passed to the program.
@return: Options and arguments
"""
program = os.path.basename(sys.argv[0])
parser = optparse.OptionParser(
usage="%prog [--dry-run] [--verbose] [--debug]", prog=program)
parser.add_option(cli.DEBUG_OPT)
parser.add_option(cli.VERBOSE_OPT)
parser.add_option(cli.DRY_RUN_OPT)
(opts, args) = parser.parse_args()
return common.VerifyOptions(parser, opts, args)
def UpdateAuthorizedKeys(data, dry_run, _homedir_fn=None):
"""Updates root's C{authorized_keys} file.
@type data: dict
@param data: Input data
@type dry_run: boolean
@param dry_run: Whether to perform a dry run
"""
authorized_keys = data.get(constants.SSHS_SSH_AUTHORIZED_KEYS)
if authorized_keys:
(auth_keys_file, _) = \
ssh.GetAllUserFiles(constants.SSH_LOGIN_USER, mkdir=True,
_homedir_fn=_homedir_fn)
if dry_run:
logging.info("This is a dry run, not modifying %s", auth_keys_file)
else:
all_authorized_keys = []
for keys in authorized_keys.values():
all_authorized_keys += keys
if not os.path.exists(auth_keys_file):
utils.WriteFile(auth_keys_file, mode=0600, data="")
ssh.AddAuthorizedKeys(auth_keys_file, all_authorized_keys)
def UpdatePubKeyFile(data, dry_run, key_file=pathutils.SSH_PUB_KEYS):
"""Updates the file of public SSH keys.
@type data: dict
@param data: Input data
@type dry_run: boolean
@param dry_run: Whether to perform a dry run
"""
public_keys = data.get(constants.SSHS_SSH_PUBLIC_KEYS)
if not public_keys:
logging.info("No public keys received. Not modifying"
" the public key file at all.")
return
if dry_run:
logging.info("This is a dry run, not modifying %s", key_file)
ssh.OverridePubKeyFile(public_keys, key_file=key_file)
def Main():
"""Main routine.
"""
opts = ParseOptions()
utils.SetupToolLogging(opts.debug, opts.verbose)
try:
data = common.LoadData(sys.stdin.read(), _DATA_CHECK)
# Check if input data is correct
common.VerifyClusterName(data, SshUpdateError)
common.VerifyCertificate(data, SshUpdateError)
# Update SSH files
UpdateAuthorizedKeys(data, opts.dry_run)
UpdatePubKeyFile(data, opts.dry_run)
logging.info("Setup finished successfully")
except Exception, err: # pylint: disable=W0703
logging.debug("Caught unhandled exception", exc_info=True)
(retcode, message) = cli.FormatError(err)
logging.error(message)
return retcode
else:
return constants.EXIT_SUCCESS
......@@ -34,7 +34,6 @@ import unittest
import shutil
import tempfile
import os.path
import OpenSSL
from ganeti import errors
from ganeti import constants
......@@ -43,30 +42,31 @@ from ganeti import pathutils
from ganeti import compat
from ganeti import utils
from ganeti.tools import prepare_node_join
from ganeti.tools import common
import testutils
_JoinError = prepare_node_join.JoinError
_DATA_CHECK = prepare_node_join._DATA_CHECK
class TestLoadData(unittest.TestCase):
def testNoJson(self):
self.assertRaises(errors.ParseError, prepare_node_join.LoadData, "")
self.assertRaises(errors.ParseError, prepare_node_join.LoadData, "}")
self.assertRaises(errors.ParseError, common.LoadData, "", _DATA_CHECK)
self.assertRaises(errors.ParseError, common.LoadData, "}", _DATA_CHECK)
def testInvalidDataStructure(self):
raw = serializer.DumpJson({
"some other thing": False,
})
self.assertRaises(errors.ParseError, prepare_node_join.LoadData, raw)
self.assertRaises(errors.ParseError, common.LoadData, raw, _DATA_CHECK)
raw = serializer.DumpJson([])
self.assertRaises(errors.ParseError, prepare_node_join.LoadData, raw)
self.assertRaises(errors.ParseError, common.LoadData, raw, _DATA_CHECK)
def testEmptyDict(self):
raw = serializer.DumpJson({})
self.assertEqual(prepare_node_join.LoadData(raw), {})
self.assertEqual(common.LoadData(raw, _DATA_CHECK), {})
def testValidData(self):
key_list = [[constants.SSHK_DSA, "private foo", "public bar"]]
......@@ -78,7 +78,7 @@ class TestLoadData(unittest.TestCase):
{"nodeuuid01234": ["foo"],
"nodeuuid56789": ["bar"]}}
raw = serializer.DumpJson(data_dict)
self.assertEqual(prepare_node_join.LoadData(raw), data_dict)
self.assertEqual(common.LoadData(raw, _DATA_CHECK), data_dict)
class TestVerifyCertificate(testutils.GanetiTestCase):
......@@ -91,20 +91,21 @@ class TestVerifyCertificate(testutils.GanetiTestCase):
shutil.rmtree(self.tmpdir)
def testNoCert(self):
prepare_node_join.VerifyCertificate({}, _verify_fn=NotImplemented)
common.VerifyCertificate({}, error_fn=prepare_node_join.JoinError,
_verify_fn=NotImplemented)
def testGivenPrivateKey(self):
cert_filename = testutils.TestDataFilename("cert2.pem")
cert_pem = utils.ReadFile(cert_filename)
self.assertRaises(_JoinError, prepare_node_join._VerifyCertificate,
cert_pem, _check_fn=NotImplemented)
self.assertRaises(_JoinError, common._VerifyCertificate,
cert_pem, _JoinError, _check_fn=NotImplemented)
def testInvalidCertificate(self):
self.assertRaises(errors.X509CertError,
prepare_node_join._VerifyCertificate,
common._VerifyCertificate,
"Something that's not a certificate",
_check_fn=NotImplemented)
_JoinError, _check_fn=NotImplemented)
@staticmethod
def _Check(cert):
......@@ -113,7 +114,8 @@ class TestVerifyCertificate(testutils.GanetiTestCase):
def testSuccessfulCheck(self):
cert_filename = testutils.TestDataFilename("cert1.pem")
cert_pem = utils.ReadFile(cert_filename)
prepare_node_join._VerifyCertificate(cert_pem, _check_fn=self._Check)
common._VerifyCertificate(cert_pem, _JoinError,
_check_fn=self._Check)
class TestVerifyClusterName(unittest.TestCase):
......@@ -126,8 +128,8 @@ class TestVerifyClusterName(unittest.TestCase):
shutil.rmtree(self.tmpdir)
def testNoName(self):
self.assertRaises(_JoinError, prepare_node_join.VerifyClusterName,
{}, _verify_fn=NotImplemented)
self.assertRaises(_JoinError, common.VerifyClusterName,
{}, _JoinError, _verify_fn=NotImplemented)
@staticmethod
def _FailingVerify(name):
......@@ -139,8 +141,8 @@ class TestVerifyClusterName(unittest.TestCase):
constants.SSHS_CLUSTER_NAME: "cluster.example.com",
}
self.assertRaises(errors.GenericError, prepare_node_join.VerifyClusterName,
data, _verify_fn=self._FailingVerify)
self.assertRaises(errors.GenericError, common.VerifyClusterName,
data, _JoinError, _verify_fn=self._FailingVerify)
class TestUpdateSshDaemon(unittest.TestCase):
......
#!/usr/bin/python
#
# Copyright (C) 2014 Google Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""Script for testing ganeti.tools.ssh_update"""
import unittest
import shutil
import tempfile
import os.path
from ganeti import constants
from ganeti import utils
from ganeti.tools import ssh_update
import testutils
_JoinError = ssh_update.SshUpdateError
_DATA_CHECK = ssh_update._DATA_CHECK
class TestUpdateAuthorizedKeys(testutils.GanetiTestCase):
def setUp(self):
testutils.GanetiTestCase.setUp(self)
self.tmpdir = tempfile.mkdtemp()
self.sshdir = utils.PathJoin(self.tmpdir, ".ssh")
def tearDown(self):
unittest.TestCase.tearDown(self)
shutil.rmtree(self.tmpdir)
def _GetHomeDir(self, user):
self.assertEqual(user, constants.SSH_LOGIN_USER)
return self.tmpdir
def testNoKeys(self):
data_empty_keys = {
constants.SSHS_SSH_AUTHORIZED_KEYS: {},
}
for data in [{}, data_empty_keys]:
for dry_run in [False, True]:
ssh_update.UpdateAuthorizedKeys(data, dry_run,
_homedir_fn=NotImplemented)
self.assertEqual(os.listdir(self.tmpdir), [])
def testDryRun(self):
data = {
constants.SSHS_SSH_AUTHORIZED_KEYS: {
"node1" : ["key11", "key12", "key13"],
"node2" : ["key21", "key22"]},
}
ssh_update.UpdateAuthorizedKeys(data, True,
_homedir_fn=self._GetHomeDir)
self.assertEqual(os.listdir(self.tmpdir), [".ssh"])