From 14406cc14a4d832fe83df5da27937051e41dd093 Mon Sep 17 00:00:00 2001 From: "Chris St. Pierre" Date: Thu, 3 Jan 2013 13:40:24 -0600 Subject: Cfg: Added feature to provide generation of SSH keys, authorized_keys file --- .../Plugins/Cfg/CfgAuthorizedKeysGenerator.py | 101 ++++++++ .../Server/Plugins/Cfg/CfgPrivateKeyCreator.py | 258 +++++++++++++++++++++ .../Server/Plugins/Cfg/CfgPublicKeyCreator.py | 63 +++++ src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py | 95 ++++++-- 4 files changed, 497 insertions(+), 20 deletions(-) create mode 100644 src/lib/Bcfg2/Server/Plugins/Cfg/CfgAuthorizedKeysGenerator.py create mode 100644 src/lib/Bcfg2/Server/Plugins/Cfg/CfgPrivateKeyCreator.py create mode 100644 src/lib/Bcfg2/Server/Plugins/Cfg/CfgPublicKeyCreator.py (limited to 'src/lib/Bcfg2/Server/Plugins/Cfg') diff --git a/src/lib/Bcfg2/Server/Plugins/Cfg/CfgAuthorizedKeysGenerator.py b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgAuthorizedKeysGenerator.py new file mode 100644 index 000000000..824d01023 --- /dev/null +++ b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgAuthorizedKeysGenerator.py @@ -0,0 +1,101 @@ +""" The CfgAuthorizedKeysGenerator generates ``authorized_keys`` files +based on an XML specification of which SSH keypairs should granted +access. """ + +import lxml.etree +from Bcfg2.Server.Plugin import StructFile, PluginExecutionError +from Bcfg2.Server.Plugins.Cfg import CfgGenerator, SETUP, CFG +from Bcfg2.Server.Plugins.Metadata import ClientMetadata + + +class CfgAuthorizedKeysGenerator(CfgGenerator, StructFile): + """ The CfgAuthorizedKeysGenerator generates authorized_keys files + based on an XML specification of which SSH keypairs should granted + access. """ + + #: Different configurations for different clients/groups can be + #: handled with Client and Group tags within authorizedkeys.xml + __specific__ = False + + #: Handle authorized keys XML files + __basenames__ = ['authorizedkeys.xml', 'authorized_keys.xml'] + + #: This handler is experimental, in part because it depends upon + #: the (experimental) CfgPrivateKeyCreator handler + experimental = True + + def __init__(self, fname): + CfgGenerator.__init__(self, fname, None, None) + StructFile.__init__(self, fname) + self.cache = dict() + self.core = CFG.core + __init__.__doc__ = CfgGenerator.__init__.__doc__ + + @property + def category(self): + """ The name of the metadata category that generated keys are + specific to """ + if (SETUP.cfp.has_section("sshkeys") and + SETUP.cfp.has_option("sshkeys", "category")): + return SETUP.cfp.get("sshkeys", "category") + return None + + def handle_event(self, event): + CfgGenerator.handle_event(self, event) + StructFile.HandleEvent(self, event) + self.cache = dict() + handle_event.__doc__ = CfgGenerator.handle_event.__doc__ + + def get_data(self, entry, metadata): + spec = self.XMLMatch(metadata) + rv = [] + for allow in spec.findall("Allow"): + params = '' + if allow.find("Params") is not None: + params = ",".join("=".join(p) + for p in allow.find("Params").attrib.items()) + + pubkey_name = allow.get("from") + if pubkey_name: + host = allow.get("host") + group = allow.get("group") + if host: + key_md = self.core.build_metadata(host) + elif group: + key_md = ClientMetadata("dummy", group, [group], [], + set(), set(), dict(), None, + None, None, None) + elif (self.category and + not metadata.group_in_category(self.category)): + self.logger.warning("Cfg: %s ignoring Allow from %s: " + "No group in category %s" % + (metadata.hostname, pubkey_name, + self.category)) + continue + else: + key_md = metadata + + key_entry = lxml.etree.Element("Path", name=pubkey_name) + try: + self.core.Bind(key_entry, key_md) + except PluginExecutionError: + self.logger.info("Cfg: %s skipping Allow from %s: " + "No key found" % (metadata.hostname, + pubkey_name)) + continue + if not key_entry.text: + self.logger.warning("Cfg: %s skipping Allow from %s: " + "Empty public key" % + (metadata.hostname, pubkey_name)) + continue + pubkey = key_entry.text + elif allow.text: + pubkey = allow.text.strip() + else: + self.logger.warning("Cfg: %s ignoring empty Allow tag: %s" % + (metadata.hostname, + lxml.etree.tostring(allow))) + continue + rv.append(" ".join([params, pubkey]).strip()) + return "\n".join(rv) + get_data.__doc__ = CfgGenerator.get_data.__doc__ diff --git a/src/lib/Bcfg2/Server/Plugins/Cfg/CfgPrivateKeyCreator.py b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgPrivateKeyCreator.py new file mode 100644 index 000000000..bb54c6faa --- /dev/null +++ b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgPrivateKeyCreator.py @@ -0,0 +1,258 @@ +""" The CfgPrivateKeyCreator creates SSH keys on the fly. """ + +import os +import shutil +import tempfile +import subprocess +from Bcfg2.Server.Plugin import PluginExecutionError, StructFile +from Bcfg2.Server.Plugins.Cfg import CfgCreator, CfgCreationError, SETUP +from Bcfg2.Server.Plugins.Cfg.CfgPublicKeyCreator import CfgPublicKeyCreator +try: + import Bcfg2.Encryption + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + + +class CfgPrivateKeyCreator(CfgCreator, StructFile): + """The CfgPrivateKeyCreator creates SSH keys on the fly. """ + + #: Different configurations for different clients/groups can be + #: handled with Client and Group tags within privkey.xml + __specific__ = False + + #: Handle XML specifications of private keys + __basenames__ = ['privkey.xml'] + + def __init__(self, fname): + CfgCreator.__init__(self, fname) + StructFile.__init__(self, fname) + + pubkey_path = os.path.dirname(self.name) + ".pub" + pubkey_name = os.path.join(pubkey_path, os.path.basename(pubkey_path)) + self.pubkey_creator = CfgPublicKeyCreator(pubkey_name) + __init__.__doc__ = CfgCreator.__init__.__doc__ + + @property + def category(self): + """ The name of the metadata category that generated keys are + specific to """ + if (SETUP.cfp.has_section("sshkeys") and + SETUP.cfp.has_option("sshkeys", "category")): + return SETUP.cfp.get("sshkeys", "category") + return None + + @property + def passphrase(self): + """ The passphrase used to encrypt private keys """ + if (HAS_CRYPTO and + SETUP.cfp.has_section("sshkeys") and + SETUP.cfp.has_option("sshkeys", "passphrase")): + return Bcfg2.Encryption.get_passphrases(SETUP)[SETUP.cfp.get( + "sshkeys", + "passphrase")] + return None + + def handle_event(self, event): + CfgCreator.handle_event(self, event) + StructFile.HandleEvent(self, event) + handle_event.__doc__ = CfgCreator.handle_event.__doc__ + + def _gen_keypair(self, metadata, spec=None): + """ Generate a keypair according to the given client medata + and key specification. + + :param metadata: The client metadata to generate keys for + :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata + :param spec: The key specification to follow when creating the + keys. This should be an XML document that only + contains key specification data that applies to + the given client metadata, and may be obtained by + doing ``self.XMLMatch(metadata)`` + :type spec: lxml.etree._Element + :returns: None + """ + if spec is None: + spec = self.XMLMatch(metadata) + + # set key parameters + ktype = "rsa" + bits = None + params = spec.find("Params") + if params is not None: + bits = params.get("bits") + ktype = params.get("type", ktype) + try: + passphrase = spec.find("Passphrase").text + except AttributeError: + passphrase = '' + tempdir = tempfile.mkdtemp() + try: + filename = os.path.join(tempdir, "privkey") + + # generate key pair + cmd = ["ssh-keygen", "-f", filename, "-t", ktype] + if bits: + cmd.extend(["-b", bits]) + cmd.append("-N") + log_cmd = cmd[:] + cmd.append(passphrase) + if passphrase: + log_cmd.append("******") + else: + log_cmd.append("''") + self.debug_log("Cfg: Generating new SSH key pair: %s" % + " ".join(log_cmd)) + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + err = proc.communicate()[1] + if proc.wait(): + raise CfgCreationError("Cfg: Failed to generate SSH key pair " + "at %s for %s: %s" % + (filename, metadata.hostname, err)) + elif err: + self.logger.warning("Cfg: Generated SSH key pair at %s for %s " + "with errors: %s" % (filename, + metadata.hostname, + err)) + return filename + except: + shutil.rmtree(tempdir) + raise + + def get_specificity(self, metadata, spec=None): + """ Get config settings for key generation specificity + (per-host or per-group). + + :param metadata: The client metadata to create data for + :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata + :param spec: The key specification to follow when creating the + keys. This should be an XML document that only + contains key specification data that applies to + the given client metadata, and may be obtained by + doing ``self.XMLMatch(metadata)`` + :type spec: lxml.etree._Element + :returns: dict - A dict of specificity arguments suitable for + passing to + :func:`Bcfg2.Server.Plugins.Cfg.CfgCreator.write_data` + or + :func:`Bcfg2.Server.Plugins.Cfg.CfgCreator.get_filename` + """ + if spec is None: + spec = self.XMLMatch(metadata) + category = spec.get("category", self.category) + print("category=%s" % category) + if category is None: + per_host_default = "true" + else: + per_host_default = "false" + per_host = spec.get("perhost", per_host_default).lower() == "true" + + specificity = dict(host=metadata.hostname) + if category and not per_host: + group = metadata.group_in_category(category) + if group: + specificity = dict(group=group, + prio=int(spec.get("priority", 50))) + else: + self.logger.info("Cfg: %s has no group in category %s, " + "creating host-specific key" % + (metadata.hostname, category)) + return specificity + + # pylint: disable=W0221 + def create_data(self, entry, metadata, return_pair=False): + """ Create data for the given entry on the given client + + :param entry: The abstract entry to create data for. This + will not be modified + :type entry: lxml.etree._Element + :param metadata: The client metadata to create data for + :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata + :param return_pair: Return a tuple of ``(public key, private + key)`` instead of just the private key. + This is used by + :class:`Bcfg2.Server.Plugins.Cfg.CfgPublicKeyCreator.CfgPublicKeyCreator` + to create public keys as requested. + :type return_pair: bool + :returns: string - The private key data + :returns: tuple - Tuple of ``(public key, private key)``, if + ``return_pair`` is set to True + """ + spec = self.XMLMatch(metadata) + specificity = self.get_specificity(metadata, spec) + filename = self._gen_keypair(metadata, spec) + + try: + # write the public key, stripping the comment and + # replacing it with a comment that specifies the filename. + kdata = open(filename + ".pub").read().split()[:2] + kdata.append(self.pubkey_creator.get_filename(**specificity)) + pubkey = " ".join(kdata) + "\n" + self.pubkey_creator.write_data(pubkey, **specificity) + + # encrypt the private key, write to the proper place, and + # return it + privkey = open(filename).read() + if HAS_CRYPTO and self.passphrase: + self.debug_log("Cfg: Encrypting key data at %s" % filename) + privkey = Bcfg2.Encryption.ssl_encrypt( + privkey, + self.passphrase, + algorithm=Bcfg2.Encryption.get_algorithm(SETUP)) + specificity['ext'] = '.crypt' + + self.write_data(privkey, **specificity) + + if return_pair: + return (pubkey, privkey) + else: + return privkey + finally: + shutil.rmtree(os.path.dirname(filename)) + # pylint: enable=W0221 + + def Index(self): + StructFile.Index(self) + if HAS_CRYPTO: + strict = SETUP.cfp.get("sshkeys", "decrypt", + default="strict") == "strict" + for el in self.xdata.xpath("//*[@encrypted]"): + try: + el.text = self._decrypt(el).encode('ascii', + 'xmlcharrefreplace') + except UnicodeDecodeError: + self.logger.info("Cfg: Decrypted %s to gibberish, skipping" + % el.tag) + except Bcfg2.Encryption.EVPError: + msg = "Cfg: Failed to decrypt %s element in %s" % \ + (el.tag, self.name) + if strict: + raise PluginExecutionError(msg) + else: + self.logger.warning(msg) + Index.__doc__ = StructFile.Index.__doc__ + + def _decrypt(self, element): + """ Decrypt a single encrypted element """ + if not element.text.strip(): + return + passes = Bcfg2.Encryption.get_passphrases(SETUP) + try: + passphrase = passes[element.get("encrypted")] + try: + return Bcfg2.Encryption.ssl_decrypt( + element.text, + passphrase, + algorithm=Bcfg2.Encryption.get_algorithm(SETUP)) + except Bcfg2.Encryption.EVPError: + # error is raised below + pass + except KeyError: + # bruteforce_decrypt raises an EVPError with a sensible + # error message, so we just let it propagate up the stack + return Bcfg2.Encryption.bruteforce_decrypt( + element.text, + passphrases=passes.values(), + algorithm=Bcfg2.Encryption.get_algorithm(SETUP)) + raise Bcfg2.Encryption.EVPError("Failed to decrypt") diff --git a/src/lib/Bcfg2/Server/Plugins/Cfg/CfgPublicKeyCreator.py b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgPublicKeyCreator.py new file mode 100644 index 000000000..6be438462 --- /dev/null +++ b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgPublicKeyCreator.py @@ -0,0 +1,63 @@ +""" The CfgPublicKeyCreator invokes +:class:`Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.CfgPrivateKeyCreator` +to create SSH keys on the fly. """ + +import lxml.etree +from Bcfg2.Server.Plugin import StructFile, PluginExecutionError +from Bcfg2.Server.Plugins.Cfg import CfgCreator, CfgCreationError, CFG + + +class CfgPublicKeyCreator(CfgCreator, StructFile): + """ .. currentmodule:: Bcfg2.Server.Plugins.Cfg + + The CfgPublicKeyCreator creates SSH public keys on the fly. It is + invoked by :class:`CfgPrivateKeyCreator.CfgPrivateKeyCreator` to + handle the creation of the public key, and can also call + :class:`CfgPrivateKeyCreator.CfgPrivateKeyCreator` to trigger the + creation of a keypair when a public key is created. """ + + #: Different configurations for different clients/groups can be + #: handled with Client and Group tags within privkey.xml + __specific__ = False + + #: Handle XML specifications of private keys + __basenames__ = ['pubkey.xml'] + + def __init__(self, fname): + CfgCreator.__init__(self, fname) + StructFile.__init__(self, fname) + self.cfg = CFG + __init__.__doc__ = CfgCreator.__init__.__doc__ + + def create_data(self, entry, metadata): + if entry.get("name").endswith(".pub"): + privkey = entry.get("name")[:-4] + else: + raise CfgCreationError("Cfg: Could not determine private key for " + "%s: Filename does not end in .pub" % + entry.get("name")) + + if privkey not in self.cfg.entries: + raise CfgCreationError("Cfg: Could not find Cfg entry for %s " + "(private key for %s)" % (privkey, + self.name)) + eset = self.cfg.entries[privkey] + try: + creator = eset.best_matching(metadata, + eset.get_handlers(metadata, + CfgCreator)) + except PluginExecutionError: + raise CfgCreationError("Cfg: No privkey.xml defined for %s " + "(private key for %s)" % (privkey, + self.name)) + + privkey_entry = lxml.etree.Element("Path", name=privkey) + pubkey = creator.create_data(privkey_entry, metadata, + return_pair=True)[0] + return pubkey + create_data.__doc__ = CfgCreator.create_data.__doc__ + + def handle_event(self, event): + CfgCreator.handle_event(self, event) + StructFile.HandleEvent(self, event) + handle_event.__doc__ = CfgCreator.handle_event.__doc__ diff --git a/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py b/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py index 2466d68a2..ea4a4263b 100644 --- a/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py +++ b/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py @@ -12,7 +12,7 @@ import Bcfg2.Server.Plugin import Bcfg2.Server.Lint from Bcfg2.Server.Plugin import PluginExecutionError # pylint: disable=W0622 -from Bcfg2.Compat import u_str, unicode, b64encode, b64decode, walk_packages, \ +from Bcfg2.Compat import u_str, unicode, b64encode, walk_packages, \ any, oct_mode # pylint: enable=W0622 @@ -27,6 +27,14 @@ from Bcfg2.Compat import u_str, unicode, b64encode, b64decode, walk_packages, \ #: the EntrySet children. SETUP = None +#: CFG is a reference to the :class:`Bcfg2.Server.Plugins.Cfg.Cfg` +#: plugin object created by the Bcfg2 core. This is provided so that +#: the handler objects can access it as necessary, since the existing +#: :class:`Bcfg2.Server.Plugin.helpers.GroupSpool` and +#: :class:`Bcfg2.Server.Plugin.helpers.EntrySet` classes have no +#: facility for passing it otherwise. +CFG = None + class CfgBaseFileMatcher(Bcfg2.Server.Plugin.SpecificData, Bcfg2.Server.Plugin.Debuggable): @@ -62,8 +70,8 @@ class CfgBaseFileMatcher(Bcfg2.Server.Plugin.SpecificData, #: if they handle a given event. If this explicit priority is not #: set, then :class:`CfgPlaintextGenerator.CfgPlaintextGenerator` #: would match against nearly every other sort of generator file - #: if it comes first. It's not necessary to set ``__priority`` on - #: handlers where :attr:`CfgBaseFileMatcher.__specific__` is + #: if it comes first. It's not necessary to set ``__priority__`` + #: on handlers where :attr:`CfgBaseFileMatcher.__specific__` is #: False, since they don't have a potentially open-ended regex __priority__ = 0 @@ -304,6 +312,23 @@ class CfgCreator(CfgBaseFileMatcher): client, writes its data to disk as a static file, and is not called on subsequent runs by the same client. """ + #: CfgCreators generally store their configuration in a single XML + #: file, and are thus not specific + __specific__ = False + + #: The CfgCreator interface is experimental at this time + experimental = True + + def __init__(self, fname): + """ + :param name: The full path to the file + :type name: string + + .. ----- + .. autoattribute:: Bcfg2.Server.Plugins.Cfg.CfgCreator.__specific__ + """ + CfgBaseFileMatcher.__init__(self, fname, None, None) + def create_data(self, entry, metadata): """ Create new data for the given entry and write it to disk using :func:`Bcfg2.Server.Plugins.Cfg.CfgCreator.write_data`. @@ -312,11 +337,43 @@ class CfgCreator(CfgBaseFileMatcher): :type entry: lxml.etree._Element :param metadata: The client metadata to create data for. :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata - :returns: string - the contents of the entry + :returns: string - The contents of the entry + :raises: :exc:`Bcfg2.Server.Plugins.Cfg.CfgCreationError` """ raise NotImplementedError - def write_data(self, data, host=None, group=None, prio=0): + def get_filename(self, host=None, group=None, prio=0, ext=''): + """ Get the filename where the new data will be written. If + ``host`` is given, it will be host-specific. It will be + group-specific if ``group`` and ``prio`` are given. If + neither ``host`` nor ``group`` is given, the filename will be + non-specific. + + :param host: The file applies to the given host + :type host: bool + :param group: The file applies to the given group + :type group: string + :param prio: The file has the given priority relative to other + objects that also apply to the same group. + ``group`` must also be specified. + :type prio: int + :param ext: An extension to add after the specificity (e.g., + '.crypt', to signal that an encrypted file has + been created) + :type prio: string + :returns: string - the filename + """ + basefilename = \ + os.path.join(os.path.dirname(self.name), + os.path.basename(os.path.dirname(self.name))) + if group: + return "%s.G%02d_%s%s" % (basefilename, prio, group, ext) + elif host: + return "%s.H_%s%s" % (basefilename, host, ext) + else: + return "%s%s" % (basefilename, ext) + + def write_data(self, data, host=None, group=None, prio=0, ext=''): """ Write the new data to disk. If ``host`` is given, it is written as a host-specific file, or as a group-specific file if ``group`` and ``prio`` are given. If neither ``host`` nor @@ -332,19 +389,14 @@ class CfgCreator(CfgBaseFileMatcher): objects that also apply to the same group. ``group`` must also be specified. :type prio: int + :param ext: An extension to add after the specificity (e.g., + '.crypt', to signal that an encrypted file has + been created) + :type prio: string :returns: None :raises: :exc:`Bcfg2.Server.Plugins.Cfg.CfgCreationError` """ - basefilename = \ - os.path.join(os.path.dirname(self.name), - os.path.basename(os.path.dirname(self.name))) - if group: - fileloc = "%s.G%02d_%s" % (basefilename, prio, group) - elif host: - fileloc = "%s.H_%s" % (basefilename, host) - else: - fileloc = basefilename - + fileloc = self.get_filename(host=host, group=group, prio=prio, ext=ext) self.debug_log("%s: Writing new file %s" % (self.name, fileloc)) try: os.makedirs(os.path.dirname(fileloc)) @@ -369,8 +421,9 @@ class CfgVerificationError(Exception): class CfgCreationError(Exception): - """ Raised by :class:`Bcfg2.Server.Plugins.Cfg.CfgCreator` when - various stages of data creation fail """ + """ Raised by + :func:`Bcfg2.Server.Plugins.Cfg.CfgCreator.create_data` when data + creation fails """ pass @@ -607,8 +660,8 @@ class CfgEntrySet(Bcfg2.Server.Plugin.EntrySet, :type metadata: Bcfg2.Server.Plugins.Metadata.ClientMetadata :returns: string - the data for the entry """ - creator = self.best_matching(metadata, self.get_handlers(metadata, - CfgCreator)) + creator = self.best_matching(metadata, + self.get_handlers(metadata, CfgCreator)) try: return creator.create_data(entry, metadata) @@ -766,10 +819,12 @@ class Cfg(Bcfg2.Server.Plugin.GroupSpool, es_child_cls = Bcfg2.Server.Plugin.SpecificData def __init__(self, core, datastore): - global SETUP # pylint: disable=W0603 + global SETUP, CFG # pylint: disable=W0603 Bcfg2.Server.Plugin.GroupSpool.__init__(self, core, datastore) Bcfg2.Server.Plugin.PullTarget.__init__(self) + CFG = self + SETUP = core.setup if 'validate' not in SETUP: SETUP.add_option('validate', Bcfg2.Options.CFG_VALIDATION) -- cgit v1.2.3-1-g7c22