diff options
-rw-r--r-- | doc/development/cfg.txt | 16 | ||||
-rw-r--r-- | doc/exts/xmlschema.py | 37 | ||||
-rw-r--r-- | doc/server/plugins/generators/cfg.txt | 215 | ||||
-rw-r--r-- | schemas/authorizedkeys.xsd | 105 | ||||
-rw-r--r-- | schemas/privkey.xsd | 138 | ||||
-rw-r--r-- | schemas/pubkey.xsd | 16 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Lint/Validate.py | 4 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/Cfg/CfgAuthorizedKeysGenerator.py | 101 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/Cfg/CfgPrivateKeyCreator.py | 258 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/Cfg/CfgPublicKeyCreator.py | 63 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py | 95 | ||||
-rw-r--r-- | testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py | 51 | ||||
-rw-r--r-- | testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgAuthorizedKeysGenerator.py | 176 | ||||
-rw-r--r-- | testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgPrivateKeyCreator.py | 435 | ||||
-rw-r--r-- | testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgPublicKeyCreator.py | 76 | ||||
-rw-r--r-- | testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py | 61 |
16 files changed, 1766 insertions, 81 deletions
diff --git a/doc/development/cfg.txt b/doc/development/cfg.txt index ba71232e7..6533e0d7a 100644 --- a/doc/development/cfg.txt +++ b/doc/development/cfg.txt @@ -20,10 +20,11 @@ implement more than one handler type. Cfg Handler Types ================= -There are four different types of Cfg handlers. A new handler must +There are several different types of Cfg handlers. A new handler must inherit either from one of these classes, or from an existing handler. .. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgGenerator +.. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgCreator .. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgFilter .. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgInfo .. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgVerifier @@ -43,6 +44,7 @@ Cfg Exceptions Cfg handlers may produce the following exceptions: .. autoexception:: Bcfg2.Server.Plugins.Cfg.CfgVerificationError +.. autoexception:: Bcfg2.Server.Plugins.Cfg.CfgCreationError In addition, Cfg handlers may produce the following base plugin exceptions: @@ -53,10 +55,11 @@ exceptions: .. autoexception:: Bcfg2.Server.Plugin.exceptions.PluginInitError :noindex: -Accessing Configuration Options -=============================== +Global Variables +================ .. autodata:: Bcfg2.Server.Plugins.Cfg.SETUP +.. autodata:: Bcfg2.Server.Plugins.Cfg.CFG Existing Cfg Handlers ===================== @@ -70,6 +73,13 @@ Generators .. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgEncryptedGenerator.CfgEncryptedGenerator .. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgEncryptedGenshiGenerator.CfgEncryptedGenshiGenerator .. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgEncryptedCheetahGenerator.CfgEncryptedCheetahGenerator +.. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgAuthorizedKeysGenerator.CfgAuthorizedKeysGenerator + +Creators +-------- + +.. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.CfgPrivateKeyCreator +.. autoclass:: Bcfg2.Server.Plugins.Cfg.CfgPublicKeyCreator.CfgPublicKeyCreator Filters ------- diff --git a/doc/exts/xmlschema.py b/doc/exts/xmlschema.py index fc5788107..81affc610 100644 --- a/doc/exts/xmlschema.py +++ b/doc/exts/xmlschema.py @@ -299,15 +299,24 @@ class XMLDocumentor(object): def document_complexType(self): rv = nodes.definition_list() + content = self.entity.find("xs:simpleContent", namespaces=NSMAP) + if content is not None: + base = content.xpath("xs:extension|xs:restriction", + namespaces=NSMAP)[0] + attr_container = base + else: + base = None + attr_container = self.entity + ##### ATTRIBUTES ##### table, tbody = self.get_attr_table() - attrs = self.get_attrs(self.entity) + attrs = self.get_attrs(attr_container) if attrs: tbody.extend(attrs) foreign_attr_groups = nodes.bullet_list() - for agroup in self.entity.xpath("xs:attributeGroup", - namespaces=NSMAP): + for agroup in attr_container.xpath("xs:attributeGroup", + namespaces=NSMAP): # if the attribute group is in another namespace, just # link to it ns, name = self.split_ns(agroup.get('ref')) @@ -349,11 +358,17 @@ class XMLDocumentor(object): append_node(rv, nodes.definition, *groups) ##### TEXT CONTENT ##### - if (self.include['text'] and - self.entity.get("mixed", "false").lower() == "true"): - append_node(rv, nodes.term, text("Text content:")) - append_node(rv, nodes.definition, - build_paragraph(self.get_values_from_simpletype())) + if self.include['text']: + if self.entity.get("mixed", "false").lower() == "true": + append_node(rv, nodes.term, text("Text content:")) + append_node(rv, nodes.definition, + build_paragraph(self.get_values_from_simpletype())) + elif base is not None: + append_node(rv, nodes.term, text("Text content:")) + append_node( + rv, nodes.definition, + build_paragraph(self.get_values_from_simpletype(content))) + return [rv] def document_attributeGroup(self): @@ -544,8 +559,10 @@ class XMLDocumentor(object): if entity is None: entity = self.entity # todo: xs:union, xs:list - restriction = entity.find("xs:restriction", namespaces=NSMAP) - if restriction is None: + try: + restriction = entity.xpath("xs:restriction|xs:extension", + namespaces=NSMAP)[0] + except IndexError: return "Any" doc = self.get_doc(restriction) if len(doc) == 1 and len(doc[0]) == 0: diff --git a/doc/server/plugins/generators/cfg.txt b/doc/server/plugins/generators/cfg.txt index 94394f98f..a33028a13 100644 --- a/doc/server/plugins/generators/cfg.txt +++ b/doc/server/plugins/generators/cfg.txt @@ -29,7 +29,7 @@ in ``Cfg/etc/passwd/passwd``, while the ssh pam module config file, ``/etc/pam.d/sshd``, goes in ``Cfg/etc/pam.d/sshd/sshd``. The reason for the like-name directory is to allow multiple versions of each file to exist, as described below. Note that these files are exact copies of what -will appear on the client machine (except when using genshi or cheetah +will appear on the client machine (except when using Genshi or Cheetah templating -- see below). Group-Specific Files @@ -355,6 +355,219 @@ either order, e.g.:: To encrypt or decrypt a file, use :ref:`bcfg2-crypt`. +.. _server-plugins-generators-cfg-sshkeys: + +SSH Keys +======== + +.. versionadded:: 1.3.0 + +Cfg can also be used to automatically create and distribute SSH key +pairs and the ``authorized_keys`` file. + +Keys can be created one of two ways: + +* Host-specific keys, where each client has its own key pair. This is + the default. +* Group-specific keys. To do this, you must set ``category`` in + either ``bcfg2.conf`` (see "Configuration" below) or in + ``privkey.xml``. Keys created for a given client will be specific + to that client's group in the specified category. + +Group-specific keys are useful if, for instance, you have multiple +distinct environments (development, testing, production, for example) +and want to maintain separate keys for each environment. + +This feature actually creates static keys, much like the +:ref:`server-plugins-generators-sshbase` plugin creates SSH +certificates. It doesn't generate them on the fly for each request; +it generates the key once, then saves it to the filesystem. + +Creating key pairs +------------------ + +To create an SSH key pair, you need to define how the private key will +be created in ``privkey.xml``. For instance, to create +``/home/foo/.ssh/id_rsa``, you would create +``/var/lib/bcfg2/Cfg/home/foo/.ssh/id_rsa/privkey.xml``. + +This will create *both* the private key and the public key; the latter +is created by appending ``.pub`` to the private key filename. It is +not possible to change the public key filename. + +You may *optionally* also create a corresponding ``pubkey.xml``, which +will allow the key pair to be created when the public key is +requested. (For the example above, you'd create +``/var/lib/bcfg2/Cfg/home/foo/.ssh/id_rsa.pub/pubkey.xml``. This can +speed up the propagation of SSH keys throughout your managed systems, +particularly if you use the ``authorized_keys`` generation feature. + +``privkey.xml`` +~~~~~~~~~~~~~~~ + +``privkey.xml`` contains a top-level ``PrivateKey`` element, and is +structured as follows: + +.. xml:element:: PrivateKey + :linktotype: + +``pubkey.xml`` +~~~~~~~~~~~~~~~ + +``pubkey.xml`` only ever contains a single line: + +.. code-block:: xml + + <PublicKey/> + +.. xml:element:: PublicKey + +It acts only as a flag to Bcfg2 that a key pair should be generated, if +none exists, using the associated ``privkey.xml`` file. The path to +``privkey.xml`` is determined by removing ``.pub`` from the directory +containing ``pubkey.xml``. I.e., if you create +``/var/lib/bcfg2/Cfg/home/foo/.ssh/id_rsa.pub/pubkey.xml``, then Bcfg2 +will use ``/var/lib/bcfg2/Cfg/home/foo/.ssh/id_rsa/privkey.xml`` to +create the key pair. + +Use of ``pubkey.xml`` is optional, but is recommended. If you do not +use ``pubkey.xml`` files, you may encounter two problems: + +* On the first Bcfg2 client run on a given client, the private keys + may be present but the public keys may not be. This will be fixed + by running ``bcfg2`` again. +* If you are including an automatically created public key in + ``authorized_keys``, it will not be created until the client the key + is for requests the key pair. + +As an example of this latter scenario, suppose that your +``authorized_keys.xml`` allows access to foo.example.com from +``/root/.ssh/id_rsa.pub`` for bar.example.com. If bar.example.com has +not run the Bcfg2 client, then no key pair will have been generated, +and generating the foo.example.com ``authorized_keys`` file will +create a warning. But if you create +``Cfg/root/.ssh/id_rsa.pub/pubkey.xml``, then building +``authorized_keys`` for foo.example.com will create root's keypair for +bar.example.com. + +.. note:: + + In order to use ``pubkey.xml``, there *must* be a corresponding + ``privkey.xml``. You cannot, for instance, populate a directory + with manually-generated private SSH keys, drop ``pubkey.xml`` in + the related public key directory, and expect Bcfg2 to generate the + public keys. It will not. + +Examples +~~~~~~~~ + +``privkey.xml`` can, at its simplest, be very simple indeed: + +.. code-block:: xml + + <PrivateKey/> + +This will create a private key with all defaults. Or it can be more +complex: + +.. code-block:: xml + + <PrivateKey category="environment"/> + <Params bits="1024" type="dsa"/> + <Group name="secure"> + <Passphrase encrypted="secure">U2FsdGVkX19xACol83uyPELP94s4CmngD12oU6PLLuE=</Passphrase> + </Group> + </PrivateKey> + +This creates a 1024-bit DSA key for each group in the ``environment`` +category, and keys for clients in the ``secure`` group will be +protected with the given (encrypted) passphrase. + +To complete the example, assume that this file was saved at +``/var/lib/bcfg2/Cfg/home/foo/.ssh/id_rsa/privkey.xml``. If a client +in the ``development`` group, which is a group in the ``environment`` +category, requests the private key, then the following files would be +created:: + + /var/lib/bcfg2/Cfg/home/foo/.ssh/id_rsa/id_rsa.G50_development + /var/lib/bcfg2/Cfg/home/foo/.ssh/id_rsa.pub/id_rsa.pub.G50_development + +``/var/lib/bcfg2/Cfg/home/foo/.ssh/id_rsa.pub`` would be created if it +did not exist. + +Subsequent clients that were also members of the ``development`` +environment would get the keys that have already been generated. + +``pubkey.xml`` always contains a single empty tag: + +.. code-block:: xml + + <PublicKey/> + +Generating ``authorized_keys`` +------------------------------ + +``authorized_keys`` can be automatically generated from public SSH +keys that exist in the Cfg tree. The keys in question can be +generated from ``privkey.xml``, or they can be manually created. + +If a key doesn't exist when ``authorized_keys`` is generated, the key +will only be created if ``pubkey.xml`` exists. If that is not the +case, a warning will be produced. + +To generate ``authorized_keys``, create ``authorized_keys.xml``, e.g.: +``/var/lib/bcfg2/Cfg/root/.ssh/authorized_keys/authorized_keys.xml``. + +``authorized_keys.xml`` +~~~~~~~~~~~~~~~~~~~~~~~ + +``authorized_keys.xml`` is structured as follows: + +.. xml:element:: AuthorizedKeys + :linktotype: + +Example +~~~~~~~ + +.. code-block:: xml + + <AuthorizedKeys> + <Group name="some_group"> + <Allow from="/root/.ssh/id_rsa.pub"/> + <Allow from="/root/.ssh/id_rsa.pub" group="test"/> + </Group> + <Allow from="/root/.ssh/id_rsa.pub" host="foo.example.com"/> + <Allow from="/home/foo_user/.ssh/id_rsa.pub"> + <Params command="/home/foo_user/.ssh/ssh_command_filter"/> + </Allow> + <Allow> + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDw/rgKQeARRAHK5bQQhAAe1b+gzdtqBXWrZIQ6cIaLgxqj76TwZ3DY4A6aW9RgC4zzd0p4a9MfsScUIB4+UeZsx9GopUj4U6H8Vz7S3pXxrr4E9logVLuSfOLFbI/wMWNRuOANqquLYQ+JYWKeP4kagkVp0aAWp7mH5IOI0rp0A6qE2you4ep9N/nKvHDrtypwhYBWprsgTUXXMHnAWGmyuHGYWxNYBV9AARPdAvZfb8ggtuwibcOULlyK4DdVNbDTAN1/BDBE1ve6WZDcrc386KhqUGj/yoRyPjNZ46uZiOjRr3cdY6yUZoCwzzxvm5vle6mEbLjHgjGEMQMArzM9 vendor@example.com + </Allow> + </AuthorizedKeys> + +Configuration +------------- + +In addition to ``privkey.xml`` and ``authorized_keys.xml``, described +above, the behavior of the SSH key generation feature can be +influenced by several options in ``bcfg2.conf``: + ++----------------+---------------------------------------------------------+-----------------------+------------+ +| Option | Description | Values | Default | ++================+=========================================================+=======================+============+ +| ``passphrase`` | Use the named passphrase to encrypt private keys on the | String | None | +| | filesystem. The passphrase must be defined in the | | | +| | ``[encryption]`` section. See :ref:`server-encryption` | | | +| | for more details on encryption in Bcfg2 in general. | | | ++----------------+---------------------------------------------------------+-----------------------+------------+ +| ``category`` | Generate keys specific to groups in the given category. | String | None | +| | It is best to pick a category that all clients have a | | | +| | group from. | | | ++----------------+---------------------------------------------------------+-----------------------+------------+ +| ``decrypt`` | If decrypt is set to ``lax``, then a key that cannot be | ``strict`` or ``lax`` | ``strict`` | +| | decrypted will produce a warning instead of an error. | | | ++----------------+---------------------------------------------------------+-----------------------+------------+ + Deltas ====== diff --git a/schemas/authorizedkeys.xsd b/schemas/authorizedkeys.xsd new file mode 100644 index 000000000..848f99bae --- /dev/null +++ b/schemas/authorizedkeys.xsd @@ -0,0 +1,105 @@ +<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xml:lang="en"> + <xsd:annotation> + <xsd:documentation> + Schema for :ref:`server-plugins-generators-cfg-sshkeys` + ``authorizedkeys.xml`` + </xsd:documentation> + </xsd:annotation> + + <xsd:complexType name="AuthorizedKeysGroupType"> + <xsd:annotation> + <xsd:documentation> + An **AuthorizedKeysGroupType** is a tag used to provide logic. + Child entries of an AuthorizedKeysGroupType tag only apply to + machines that match the condition specified -- either + membership in a group, or a matching client name. + :xml:attribute:`AuthorizedKeysGroupType:negate` can be set to + negate the sense of the match. + </xsd:documentation> + </xsd:annotation> + <xsd:choice minOccurs="1" maxOccurs="unbounded"> + <xsd:element name="Allow" type="AllowType"/> + <xsd:element name="Group" type="AuthorizedKeysGroupType"/> + <xsd:element name="Client" type="AuthorizedKeysGroupType"/> + </xsd:choice> + <xsd:attribute name='name' type='xsd:string'> + <xsd:annotation> + <xsd:documentation> + The name of the client or group to match on. Child entries + will only apply to this client or group (unless + :xml:attribute:`AuthorizedKeysGroupType:negate` is set). + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute name='negate' type='xsd:boolean'> + <xsd:annotation> + <xsd:documentation> + Negate the sense of the match, so that child entries only + apply to a client if it is not a member of the given group + or does not have the given name. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + </xsd:complexType> + + <xsd:complexType name="AllowType" mixed="true"> + <xsd:annotation> + <xsd:documentation> + Allow access from a public key, given either as text content, + or described by the attributes. + </xsd:documentation> + </xsd:annotation> + <xsd:choice minOccurs="0" maxOccurs="unbounded"> + <xsd:element name="Params" type="AuthorizedKeysParamsType"/> + </xsd:choice> + <xsd:attribute name="from" type="xsd:string"> + <xsd:annotation> + <xsd:documentation> + The path of the public key to allow. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute name="group" type="xsd:string"> + <xsd:annotation> + <xsd:documentation> + Use a public key specific to the given group, instead of the + public key specific to the appropriate category group of the + current client. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute name="host" type="xsd:string"> + <xsd:annotation> + <xsd:documentation> + Use a public key specific to the given host. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + </xsd:complexType> + + <xsd:complexType name="AuthorizedKeysParamsType"> + <xsd:annotation> + <xsd:documentation> + Specify parameters for public key authentication and + connection. See :manpage:`sshd(8)` for details on allowable + parameters. + </xsd:documentation> + </xsd:annotation> + <xsd:anyAttribute processContents="lax"/> + </xsd:complexType> + + <xsd:element name="AuthorizedKeys"> + <xsd:annotation> + <xsd:documentation> + Top-level tag for describing a generated SSH key pair. + </xsd:documentation> + </xsd:annotation> + <xsd:complexType> + <xsd:choice minOccurs="1" maxOccurs="unbounded"> + <xsd:element name="Allow" type="AllowType"/> + <xsd:element name="Group" type="AuthorizedKeysGroupType"/> + <xsd:element name="Client" type="AuthorizedKeysGroupType"/> + </xsd:choice> + </xsd:complexType> + </xsd:element> +</xsd:schema> diff --git a/schemas/privkey.xsd b/schemas/privkey.xsd new file mode 100644 index 000000000..b8d9e317d --- /dev/null +++ b/schemas/privkey.xsd @@ -0,0 +1,138 @@ +<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xml:lang="en"> + <xsd:annotation> + <xsd:documentation> + Schema for :ref:`server-plugins-generators-cfg-sshkeys` ``privkey.xml`` + </xsd:documentation> + </xsd:annotation> + + <xsd:complexType name="PrivateKeyGroupType"> + <xsd:annotation> + <xsd:documentation> + An **PrivateKeyGroupType** is a tag used to provide logic. + Child entries of a PrivateKeyGroupType tag only apply to + machines that match the condition specified -- either + membership in a group, or a matching client name. + :xml:attribute:`PrivateKeyGroupType:negate` can be set to + negate the sense of the match. + </xsd:documentation> + </xsd:annotation> + <xsd:choice minOccurs="1" maxOccurs="unbounded"> + <xsd:element name="Passphrase" type="PassphraseType"/> + <xsd:element name="Params" type="PrivateKeyParamsType"/> + <xsd:element name="Group" type="PrivateKeyGroupType"/> + <xsd:element name="Client" type="PrivateKeyGroupType"/> + </xsd:choice> + <xsd:attribute name='name' type='xsd:string'> + <xsd:annotation> + <xsd:documentation> + The name of the client or group to match on. Child entries + will only apply to this client or group (unless + :xml:attribute:`PrivateKeyGroupType:negate` is set). + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute name='negate' type='xsd:boolean'> + <xsd:annotation> + <xsd:documentation> + Negate the sense of the match, so that child entries only + apply to a client if it is not a member of the given group + or does not have the given name. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + </xsd:complexType> + + <xsd:simpleType name="PrivateKeyTypeEnum"> + <xsd:annotation> + <xsd:documentation> + Available private key formats + </xsd:documentation> + </xsd:annotation> + <xsd:restriction base="xsd:string"> + <xsd:enumeration value="rsa"/> + <xsd:enumeration value="dsa"/> + </xsd:restriction> + </xsd:simpleType> + + <xsd:complexType name="PassphraseType"> + <xsd:annotation> + <xsd:documentation> + Specify the private key passphrase. + </xsd:documentation> + </xsd:annotation> + <xsd:simpleContent> + <xsd:extension base="xsd:string"> + <xsd:attribute name="encrypted" type="xsd:string"> + <xsd:annotation> + <xsd:documentation> + The name of the passphrase to use to encrypt this + private key on the filesystem (in Bcfg2). + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + </xsd:extension> + </xsd:simpleContent> + </xsd:complexType> + + <xsd:complexType name="PrivateKeyParamsType"> + <xsd:annotation> + <xsd:documentation> + Specify parameters for creating the private key + </xsd:documentation> + </xsd:annotation> + <xsd:attribute name="bits" type="xsd:positiveInteger"> + <xsd:annotation> + <xsd:documentation> + Number of bits in the key. See :manpage:`ssh-keygen(1)` for + defaults. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute name="type" type="PrivateKeyTypeEnum" default="rsa"> + <xsd:annotation> + <xsd:documentation> + Key type to create. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + </xsd:complexType> + + <xsd:element name="PrivateKey"> + <xsd:annotation> + <xsd:documentation> + Top-level tag for describing a generated SSH key pair. + </xsd:documentation> + </xsd:annotation> + <xsd:complexType> + <xsd:choice minOccurs="0" maxOccurs="unbounded"> + <xsd:element name="Passphrase" type="PassphraseType"/> + <xsd:element name="Params" type="PrivateKeyParamsType"/> + <xsd:element name="Group" type="PrivateKeyGroupType"/> + <xsd:element name="Client" type="PrivateKeyGroupType"/> + </xsd:choice> + <xsd:attribute name="perhost" type="xsd:boolean"> + <xsd:annotation> + <xsd:documentation> + Create keys on a per-host basis (rather than on a per-group + basis). + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute name="category" type="xsd:string"> + <xsd:annotation> + <xsd:documentation> + Create keys specific to the given category, instead of + specific to the category given in ``bcfg2.conf``. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + <xsd:attribute name="priority" type="xsd:positiveInteger" default="50"> + <xsd:annotation> + <xsd:documentation> + Create group-specific keys with the given priority. + </xsd:documentation> + </xsd:annotation> + </xsd:attribute> + </xsd:complexType> + </xsd:element> +</xsd:schema> diff --git a/schemas/pubkey.xsd b/schemas/pubkey.xsd new file mode 100644 index 000000000..5671a818d --- /dev/null +++ b/schemas/pubkey.xsd @@ -0,0 +1,16 @@ +<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xml:lang="en"> + <xsd:annotation> + <xsd:documentation> + Schema for :ref:`server-plugins-generators-cfg-sshkeys` ``pubkey.xml`` + </xsd:documentation> + </xsd:annotation> + + <xsd:element name="PublicKey"> + <xsd:annotation> + <xsd:documentation> + Top-level tag for flagging a generated SSH public key. + </xsd:documentation> + </xsd:annotation> + <xsd:complexType/> + </xsd:element> +</xsd:schema> diff --git a/src/lib/Bcfg2/Server/Lint/Validate.py b/src/lib/Bcfg2/Server/Lint/Validate.py index e93338ae4..37bc230d1 100644 --- a/src/lib/Bcfg2/Server/Lint/Validate.py +++ b/src/lib/Bcfg2/Server/Lint/Validate.py @@ -18,6 +18,10 @@ class Validate(Bcfg2.Server.Lint.ServerlessPlugin): {"Metadata/groups.xml": "metadata.xsd", "Metadata/clients.xml": "clients.xsd", "Cfg/**/info.xml": "info.xsd", + "Cfg/**/privkey.xml": "privkey.xsd", + "Cfg/**/pubkey.xml": "pubkey.xsd", + "Cfg/**/authorizedkeys.xml": "authorizedkeys.xsd", + "Cfg/**/authorized_keys.xml": "authorizedkeys.xsd", "SSHbase/**/info.xml": "info.xsd", "SSLCA/**/info.xml": "info.xsd", "TGenshi/**/info.xml": "info.xsd", 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) diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py index 559742d00..6dbdc7667 100644 --- a/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py +++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugin/Testhelpers.py @@ -433,9 +433,12 @@ class TestXMLFileBacked(TestFileBacked): xdata = dict() mock_parse.side_effect = lambda p: xdata[p] + base = os.path.dirname(self.path) + # basic functionality - xdata['/test/test2.xml'] = lxml.etree.Element("Test").getroottree() - xfb._follow_xincludes(xdata=xdata['/test/test2.xml']) + test2 = os.path.join(base, 'test2.xml') + xdata[test2] = lxml.etree.Element("Test").getroottree() + xfb._follow_xincludes(xdata=xdata[test2]) self.assertFalse(xfb.add_monitor.called) if (not hasattr(self.test_obj, "xdata") or @@ -443,56 +446,56 @@ class TestXMLFileBacked(TestFileBacked): # if xdata is settable, test that method of getting data # to _follow_xincludes reset() - xfb.xdata = xdata['/test/test2.xml'].getroot() + xfb.xdata = xdata[test2].getroot() xfb._follow_xincludes() self.assertFalse(xfb.add_monitor.called) xfb.xdata = None reset() - xfb._follow_xincludes(fname="/test/test2.xml") + xfb._follow_xincludes(fname=test2) self.assertFalse(xfb.add_monitor.called) # test one level of xinclude xdata[self.path] = lxml.etree.Element("Test").getroottree() lxml.etree.SubElement(xdata[self.path].getroot(), Bcfg2.Server.XI_NAMESPACE + "include", - href="/test/test2.xml") + href=test2) reset() xfb._follow_xincludes(fname=self.path) - xfb.add_monitor.assert_called_with("/test/test2.xml") + xfb.add_monitor.assert_called_with(test2) self.assertItemsEqual(mock_parse.call_args_list, [call(f) for f in xdata.keys()]) - mock_exists.assert_called_with("/test/test2.xml") + mock_exists.assert_called_with(test2) reset() xfb._follow_xincludes(fname=self.path, xdata=xdata[self.path]) - xfb.add_monitor.assert_called_with("/test/test2.xml") + xfb.add_monitor.assert_called_with(test2) self.assertItemsEqual(mock_parse.call_args_list, [call(f) for f in xdata.keys() if f != self.path]) - mock_exists.assert_called_with("/test/test2.xml") + mock_exists.assert_called_with(test2) # test two-deep level of xinclude, with some files in another # directory - xdata["/test/test3.xml"] = \ - lxml.etree.Element("Test").getroottree() - lxml.etree.SubElement(xdata["/test/test3.xml"].getroot(), + test3 = os.path.join(base, "test3.xml") + test4 = os.path.join(base, "test_dir", "test4.xml") + test5 = os.path.join(base, "test_dir", "test5.xml") + test6 = os.path.join(base, "test_dir", "test6.xml") + xdata[test3] = lxml.etree.Element("Test").getroottree() + lxml.etree.SubElement(xdata[test3].getroot(), Bcfg2.Server.XI_NAMESPACE + "include", - href="/test/test_dir/test4.xml") - xdata["/test/test_dir/test4.xml"] = \ - lxml.etree.Element("Test").getroottree() - lxml.etree.SubElement(xdata["/test/test_dir/test4.xml"].getroot(), + href=test4) + xdata[test4] = lxml.etree.Element("Test").getroottree() + lxml.etree.SubElement(xdata[test4].getroot(), Bcfg2.Server.XI_NAMESPACE + "include", - href="/test/test_dir/test5.xml") - xdata['/test/test_dir/test5.xml'] = \ - lxml.etree.Element("Test").getroottree() - xdata['/test/test_dir/test6.xml'] = \ - lxml.etree.Element("Test").getroottree() + href=test5) + xdata[test5] = lxml.etree.Element("Test").getroottree() + xdata[test6] = lxml.etree.Element("Test").getroottree() # relative includes lxml.etree.SubElement(xdata[self.path].getroot(), Bcfg2.Server.XI_NAMESPACE + "include", href="test3.xml") - lxml.etree.SubElement(xdata["/test/test3.xml"].getroot(), + lxml.etree.SubElement(xdata[test3].getroot(), Bcfg2.Server.XI_NAMESPACE + "include", href="test_dir/test6.xml") @@ -526,10 +529,6 @@ class TestXMLFileBacked(TestFileBacked): xfb.extras = [] xfb.xdata = None - # syntax error - xfb.data = "<" - self.assertRaises(PluginInitError, xfb.Index) - # no xinclude reset() xdata = lxml.etree.Element("Test", name="test") diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgAuthorizedKeysGenerator.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgAuthorizedKeysGenerator.py new file mode 100644 index 000000000..23a77d1e5 --- /dev/null +++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgAuthorizedKeysGenerator.py @@ -0,0 +1,176 @@ +import os +import sys +import lxml.etree +from mock import Mock, MagicMock, patch +from Bcfg2.Server.Plugins.Cfg.CfgAuthorizedKeysGenerator import * +from Bcfg2.Server.Plugin import PluginExecutionError + +# add all parent testsuite directories to sys.path to allow (most) +# relative imports in python 2.4 +path = os.path.dirname(__file__) +while path != "/": + if os.path.basename(path).lower().startswith("test"): + sys.path.append(path) + if os.path.basename(path) == "testsuite": + break + path = os.path.dirname(path) +from common import * +from TestServer.TestPlugins.TestCfg.Test_init import TestCfgGenerator +from TestServer.TestPlugin.Testhelpers import TestStructFile + + +class TestCfgAuthorizedKeysGenerator(TestCfgGenerator, TestStructFile): + test_obj = CfgAuthorizedKeysGenerator + should_monitor = False + + def get_obj(self, name=None, core=None, fam=None): + if name is None: + name = self.path + Bcfg2.Server.Plugins.Cfg.CfgAuthorizedKeysGenerator.CFG = Mock() + if core is not None: + Bcfg2.Server.Plugins.Cfg.CfgAuthorizedKeysGenerator.CFG.core = core + return self.test_obj(name) + + @patch("Bcfg2.Server.Plugins.Cfg.CfgGenerator.handle_event") + @patch("Bcfg2.Server.Plugin.helpers.StructFile.HandleEvent") + def test_handle_event(self, mock_HandleEvent, mock_handle_event): + akg = self.get_obj() + evt = Mock() + akg.handle_event(evt) + mock_HandleEvent.assert_called_with(akg, evt) + mock_handle_event.assert_called_with(akg, evt) + + def test_category(self): + akg = self.get_obj() + cfp = Mock() + cfp.has_section.return_value = False + cfp.has_option.return_value = False + Bcfg2.Server.Plugins.Cfg.CfgAuthorizedKeysGenerator.SETUP = Mock() + Bcfg2.Server.Plugins.Cfg.CfgAuthorizedKeysGenerator.SETUP.cfp = cfp + + self.assertIsNone(akg.category) + cfp.has_section.assert_called_with("sshkeys") + + cfp.reset_mock() + cfp.has_section.return_value = True + self.assertIsNone(akg.category) + cfp.has_section.assert_called_with("sshkeys") + cfp.has_option.assert_called_with("sshkeys", "category") + + cfp.reset_mock() + cfp.has_option.return_value = True + self.assertEqual(akg.category, cfp.get.return_value) + cfp.has_section.assert_called_with("sshkeys") + cfp.has_option.assert_called_with("sshkeys", "category") + cfp.get.assert_called_with("sshkeys", "category") + + @patch("Bcfg2.Server.Plugins.Cfg.CfgAuthorizedKeysGenerator.ClientMetadata") + @patch("Bcfg2.Server.Plugins.Cfg.CfgAuthorizedKeysGenerator.CfgAuthorizedKeysGenerator.category", "category") + def test_get_data(self, mock_ClientMetadata): + akg = self.get_obj() + akg.XMLMatch = Mock() + + def ClientMetadata(host, profile, groups, *args): + rv = Mock() + rv.hostname = host + rv.profile = profile + rv.groups = groups + return rv + + mock_ClientMetadata.side_effect = ClientMetadata + + def build_metadata(host): + rv = Mock() + rv.hostname = host + rv.profile = host + return rv + + akg.core.build_metadata = Mock() + akg.core.build_metadata.side_effect = build_metadata + + def Bind(ent, md): + ent.text = "%s %s" % (md.profile, ent.get("name")) + return ent + + akg.core.Bind = Mock() + akg.core.Bind.side_effect = Bind + metadata = Mock() + metadata.profile = "profile" + metadata.group_in_category.return_value = "profile" + entry = lxml.etree.Element("Path", name="/root/.ssh/authorized_keys") + + def reset(): + mock_ClientMetadata.reset_mock() + akg.XMLMatch.reset_mock() + akg.core.build_metadata.reset_mock() + akg.core.Bind.reset_mock() + metadata.reset_mock() + + pubkey = "/home/foo/.ssh/id_rsa.pub" + spec = lxml.etree.Element("AuthorizedKeys") + lxml.etree.SubElement(spec, "Allow", attrib={"from": pubkey}) + akg.XMLMatch.return_value = spec + self.assertEqual(akg.get_data(entry, metadata), "profile %s" % pubkey) + akg.XMLMatch.assert_called_with(metadata) + self.assertEqual(akg.core.Bind.call_args[0][0].get("name"), pubkey) + self.assertEqual(akg.core.Bind.call_args[0][1], metadata) + + reset() + group = "somegroup" + spec = lxml.etree.Element("AuthorizedKeys") + lxml.etree.SubElement(spec, "Allow", + attrib={"from": pubkey, "group": group}) + akg.XMLMatch.return_value = spec + self.assertEqual(akg.get_data(entry, metadata), + "%s %s" % (group, pubkey)) + akg.XMLMatch.assert_called_with(metadata) + self.assertItemsEqual(mock_ClientMetadata.call_args[0][2], [group]) + self.assertEqual(akg.core.Bind.call_args[0][0].get("name"), pubkey) + self.assertIn(group, akg.core.Bind.call_args[0][1].groups) + + reset() + host = "baz.example.com" + spec = lxml.etree.Element("AuthorizedKeys") + lxml.etree.SubElement( + lxml.etree.SubElement(spec, + "Allow", + attrib={"from": pubkey, "host": host}), + "Params", foo="foo", bar="bar=bar") + akg.XMLMatch.return_value = spec + self.assertEqual(akg.get_data(entry, metadata), + "foo=foo,bar=bar=bar %s %s" % (host, pubkey)) + akg.XMLMatch.assert_called_with(metadata) + akg.core.build_metadata.assert_called_with(host) + self.assertEqual(akg.core.Bind.call_args[0][0].get("name"), pubkey) + self.assertEqual(akg.core.Bind.call_args[0][1].hostname, host) + + reset() + spec = lxml.etree.Element("AuthorizedKeys") + text = lxml.etree.SubElement(spec, "Allow") + text.text = "ssh-rsa publickey /foo/bar\n" + lxml.etree.SubElement(text, "Params", foo="foo") + akg.XMLMatch.return_value = spec + self.assertEqual(akg.get_data(entry, metadata), + "foo=foo %s" % text.text.strip()) + akg.XMLMatch.assert_called_with(metadata) + self.assertFalse(akg.core.build_metadata.called) + self.assertFalse(akg.core.Bind.called) + + reset() + lxml.etree.SubElement(spec, "Allow", attrib={"from": pubkey}) + akg.XMLMatch.return_value = spec + self.assertItemsEqual(akg.get_data(entry, metadata).splitlines(), + ["foo=foo %s" % text.text.strip(), + "profile %s" % pubkey]) + akg.XMLMatch.assert_called_with(metadata) + + reset() + metadata.group_in_category.return_value = '' + spec = lxml.etree.Element("AuthorizedKeys") + lxml.etree.SubElement(spec, "Allow", attrib={"from": pubkey}) + akg.XMLMatch.return_value = spec + self.assertEqual(akg.get_data(entry, metadata), '') + akg.XMLMatch.assert_called_with(metadata) + self.assertFalse(akg.core.build_metadata.called) + self.assertFalse(akg.core.Bind.called) + self.assertFalse(mock_ClientMetadata.called) diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgPrivateKeyCreator.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgPrivateKeyCreator.py new file mode 100644 index 000000000..dd18306cb --- /dev/null +++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgPrivateKeyCreator.py @@ -0,0 +1,435 @@ +import os +import sys +import lxml.etree +from mock import Mock, MagicMock, patch +from Bcfg2.Server.Plugins.Cfg import CfgCreationError +from Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator import * +from Bcfg2.Server.Plugin import PluginExecutionError +try: + from Bcfg2.Encryption import EVPError + HAS_CRYPTO = True +except: + HAS_CRYPTO = False + +# add all parent testsuite directories to sys.path to allow (most) +# relative imports in python 2.4 +path = os.path.dirname(__file__) +while path != "/": + if os.path.basename(path).lower().startswith("test"): + sys.path.append(path) + if os.path.basename(path) == "testsuite": + break + path = os.path.dirname(path) +from common import * +from TestServer.TestPlugins.TestCfg.Test_init import TestCfgCreator +from TestServer.TestPlugin.Testhelpers import TestStructFile + + +class TestCfgPrivateKeyCreator(TestCfgCreator, TestStructFile): + test_obj = CfgPrivateKeyCreator + should_monitor = False + + def get_obj(self, name=None, fam=None): + return TestCfgCreator.get_obj(self, name=name) + + @patch("Bcfg2.Server.Plugins.Cfg.CfgCreator.handle_event") + @patch("Bcfg2.Server.Plugin.helpers.StructFile.HandleEvent") + def test_handle_event(self, mock_HandleEvent, mock_handle_event): + pkc = self.get_obj() + evt = Mock() + pkc.handle_event(evt) + mock_HandleEvent.assert_called_with(pkc, evt) + mock_handle_event.assert_called_with(pkc, evt) + + def test_category(self): + pkc = self.get_obj() + cfp = Mock() + cfp.has_section.return_value = False + cfp.has_option.return_value = False + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.SETUP = Mock() + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.SETUP.cfp = cfp + + self.assertIsNone(pkc.category) + cfp.has_section.assert_called_with("sshkeys") + + cfp.reset_mock() + cfp.has_section.return_value = True + self.assertIsNone(pkc.category) + cfp.has_section.assert_called_with("sshkeys") + cfp.has_option.assert_called_with("sshkeys", "category") + + cfp.reset_mock() + cfp.has_option.return_value = True + self.assertEqual(pkc.category, cfp.get.return_value) + cfp.has_section.assert_called_with("sshkeys") + cfp.has_option.assert_called_with("sshkeys", "category") + cfp.get.assert_called_with("sshkeys", "category") + + @skipUnless(HAS_CRYPTO, "No crypto libraries found, skipping") + def test_passphrase(self): + @patch("Bcfg2.Encryption.get_passphrases") + def inner(mock_get_passphrases): + pkc = self.get_obj() + cfp = Mock() + cfp.has_section.return_value = False + cfp.has_option.return_value = False + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.SETUP = Mock() + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.SETUP.cfp = cfp + + self.assertIsNone(pkc.passphrase) + cfp.has_section.assert_called_with("sshkeys") + + cfp.reset_mock() + cfp.has_section.return_value = True + self.assertIsNone(pkc.passphrase) + cfp.has_section.assert_called_with("sshkeys") + cfp.has_option.assert_called_with("sshkeys", "passphrase") + + cfp.reset_mock() + cfp.get.return_value = "test" + mock_get_passphrases.return_value = dict(test="foo", test2="bar") + cfp.has_option.return_value = True + self.assertEqual(pkc.passphrase, "foo") + cfp.has_section.assert_called_with("sshkeys") + cfp.has_option.assert_called_with("sshkeys", "passphrase") + cfp.get.assert_called_with("sshkeys", "passphrase") + mock_get_passphrases.assert_called_with(Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.SETUP) + + inner() + + @patch("shutil.rmtree") + @patch("tempfile.mkdtemp") + @patch("subprocess.Popen") + def test__gen_keypair(self, mock_Popen, mock_mkdtemp, mock_rmtree): + pkc = self.get_obj() + pkc.XMLMatch = Mock() + mock_mkdtemp.return_value = datastore + metadata = Mock() + + proc = Mock() + proc.wait.return_value = 0 + proc.communicate.return_value = MagicMock() + mock_Popen.return_value = proc + + spec = lxml.etree.Element("PrivateKey") + pkc.XMLMatch.return_value = spec + + def reset(): + pkc.XMLMatch.reset_mock() + mock_Popen.reset_mock() + mock_mkdtemp.reset_mock() + mock_rmtree.reset_mock() + + self.assertEqual(pkc._gen_keypair(metadata), + os.path.join(datastore, "privkey")) + pkc.XMLMatch.assert_called_with(metadata) + mock_mkdtemp.assert_called_with() + self.assertItemsEqual(mock_Popen.call_args[0][0], + ["ssh-keygen", "-f", + os.path.join(datastore, "privkey"), + "-t", "rsa", "-N", ""]) + + reset() + lxml.etree.SubElement(spec, "Params", bits="768", type="dsa") + passphrase = lxml.etree.SubElement(spec, "Passphrase") + passphrase.text = "foo" + + self.assertEqual(pkc._gen_keypair(metadata), + os.path.join(datastore, "privkey")) + pkc.XMLMatch.assert_called_with(metadata) + mock_mkdtemp.assert_called_with() + self.assertItemsEqual(mock_Popen.call_args[0][0], + ["ssh-keygen", "-f", + os.path.join(datastore, "privkey"), + "-t", "dsa", "-b", "768", "-N", "foo"]) + + reset() + proc.wait.return_value = 1 + self.assertRaises(CfgCreationError, pkc._gen_keypair, metadata) + mock_rmtree.assert_called_with(datastore) + + def test_get_specificity(self): + pkc = self.get_obj() + pkc.XMLMatch = Mock() + + metadata = Mock() + + def reset(): + pkc.XMLMatch.reset_mock() + metadata.group_in_category.reset_mock() + + category = "Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.CfgPrivateKeyCreator.category" + @patch(category, None) + def inner(): + pkc.XMLMatch.return_value = lxml.etree.Element("PrivateKey") + self.assertItemsEqual(pkc.get_specificity(metadata), + dict(host=metadata.hostname)) + inner() + + @patch(category, "foo") + def inner2(): + pkc.XMLMatch.return_value = lxml.etree.Element("PrivateKey") + self.assertItemsEqual(pkc.get_specificity(metadata), + dict(group=metadata.group_in_category.return_value, + prio=50)) + metadata.group_in_category.assert_called_with("foo") + + reset() + pkc.XMLMatch.return_value = lxml.etree.Element("PrivateKey", + perhost="true") + self.assertItemsEqual(pkc.get_specificity(metadata), + dict(host=metadata.hostname)) + + reset() + pkc.XMLMatch.return_value = lxml.etree.Element("PrivateKey", + category="bar") + self.assertItemsEqual(pkc.get_specificity(metadata), + dict(group=metadata.group_in_category.return_value, + prio=50)) + metadata.group_in_category.assert_called_with("bar") + + reset() + pkc.XMLMatch.return_value = lxml.etree.Element("PrivateKey", + prio="10") + self.assertItemsEqual(pkc.get_specificity(metadata), + dict(group=metadata.group_in_category.return_value, + prio=10)) + metadata.group_in_category.assert_called_with("foo") + + reset() + pkc.XMLMatch.return_value = lxml.etree.Element("PrivateKey") + metadata.group_in_category.return_value = '' + self.assertItemsEqual(pkc.get_specificity(metadata), + dict(host=metadata.hostname)) + metadata.group_in_category.assert_called_with("foo") + + inner2() + + @patch("shutil.rmtree") + @patch("%s.open" % builtins) + def test_create_data(self, mock_open, mock_rmtree): + pkc = self.get_obj() + pkc.XMLMatch = Mock() + pkc.get_specificity = MagicMock() + pkc._gen_keypair = Mock() + privkey = os.path.join(datastore, "privkey") + pkc._gen_keypair.return_value = privkey + pkc.pubkey_creator = Mock() + pkc.pubkey_creator.get_filename.return_value = "pubkey.filename" + pkc.write_data = Mock() + + entry = lxml.etree.Element("Path", name="/home/foo/.ssh/id_rsa") + metadata = Mock() + + def open_read_rv(): + mock_open.return_value.read.side_effect = lambda: "privatekey" + return "ssh-rsa publickey foo@bar.com" + + def reset(): + mock_open.reset_mock() + mock_rmtree.reset_mock() + pkc.XMLMatch.reset_mock() + pkc.get_specificity.reset_mock() + pkc._gen_keypair.reset_mock() + pkc.pubkey_creator.reset_mock() + pkc.write_data.reset_mock() + mock_open.return_value.read.side_effect = open_read_rv + + reset() + passphrase = "Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.CfgPrivateKeyCreator.passphrase" + + @patch(passphrase, None) + def inner(): + self.assertEqual(pkc.create_data(entry, metadata), "privatekey") + pkc.XMLMatch.assert_called_with(metadata) + pkc.get_specificity.assert_called_with(metadata, + pkc.XMLMatch.return_value) + pkc._gen_keypair.assert_called_with(metadata, + pkc.XMLMatch.return_value) + self.assertItemsEqual(mock_open.call_args_list, + [call(privkey + ".pub"), call(privkey)]) + pkc.pubkey_creator.get_filename.assert_called_with( + **pkc.get_specificity.return_value) + pkc.pubkey_creator.write_data.assert_called_with( + "ssh-rsa publickey pubkey.filename\n", + **pkc.get_specificity.return_value) + pkc.write_data.assert_called_with( + "privatekey", + **pkc.get_specificity.return_value) + mock_rmtree.assert_called_with(datastore) + + reset() + self.assertEqual(pkc.create_data(entry, metadata, return_pair=True), + ("ssh-rsa publickey pubkey.filename\n", + "privatekey")) + pkc.XMLMatch.assert_called_with(metadata) + pkc.get_specificity.assert_called_with(metadata, + pkc.XMLMatch.return_value) + pkc._gen_keypair.assert_called_with(metadata, + pkc.XMLMatch.return_value) + self.assertItemsEqual(mock_open.call_args_list, + [call(privkey + ".pub"), call(privkey)]) + pkc.pubkey_creator.get_filename.assert_called_with( + **pkc.get_specificity.return_value) + pkc.pubkey_creator.write_data.assert_called_with( + "ssh-rsa publickey pubkey.filename\n", + **pkc.get_specificity.return_value) + pkc.write_data.assert_called_with( + "privatekey", + **pkc.get_specificity.return_value) + mock_rmtree.assert_called_with(datastore) + + inner() + + if HAS_CRYPTO: + @patch(passphrase, "foo") + @patch("Bcfg2.Encryption.ssl_encrypt") + @patch("Bcfg2.Encryption.get_algorithm") + def inner2(mock_get_algorithm, mock_ssl_encrypt): + reset() + mock_ssl_encrypt.return_value = "encryptedprivatekey" + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.HAS_CRYPTO = True + self.assertEqual(pkc.create_data(entry, metadata), + "encryptedprivatekey") + pkc.XMLMatch.assert_called_with(metadata) + pkc.get_specificity.assert_called_with( + metadata, + pkc.XMLMatch.return_value) + pkc._gen_keypair.assert_called_with(metadata, + pkc.XMLMatch.return_value) + self.assertItemsEqual(mock_open.call_args_list, + [call(privkey + ".pub"), call(privkey)]) + pkc.pubkey_creator.get_filename.assert_called_with( + **pkc.get_specificity.return_value) + pkc.pubkey_creator.write_data.assert_called_with( + "ssh-rsa publickey pubkey.filename\n", + **pkc.get_specificity.return_value) + pkc.write_data.assert_called_with( + "encryptedprivatekey", + **pkc.get_specificity.return_value) + mock_ssl_encrypt.assert_called_with( + "privatekey", "foo", + algorithm=mock_get_algorithm.return_value) + mock_rmtree.assert_called_with(datastore) + + inner2() + + def test_Index(self): + has_crypto = Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.HAS_CRYPTO + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.HAS_CRYPTO = False + TestStructFile.test_Index(self) + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.HAS_CRYPTO = has_crypto + + @skipUnless(HAS_CRYPTO, "No crypto libraries found, skipping") + def test_Index_crypto(self): + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.SETUP = Mock() + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.SETUP.cfp.get.return_value = "strict" + + pkc = self.get_obj() + pkc._decrypt = Mock() + pkc._decrypt.return_value = 'plaintext' + pkc.data = ''' +<PrivateKey> + <Group name="test"> + <Passphrase encrypted="foo">crypted</Passphrase> + </Group> + <Group name="test" negate="true"> + <Passphrase>plain</Passphrase> + </Group> +</PrivateKey>''' + + # test successful decryption + pkc.Index() + self.assertItemsEqual( + pkc._decrypt.call_args_list, + [call(el) + for el in pkc.xdata.xpath("//Passphrase[@encrypted]")]) + for el in pkc.xdata.xpath("//Crypted"): + self.assertEqual(el.text, pkc._decrypt.return_value) + + # test failed decryption, strict + pkc._decrypt.reset_mock() + pkc._decrypt.side_effect = EVPError + self.assertRaises(PluginExecutionError, pkc.Index) + + # test failed decryption, lax + Bcfg2.Server.Plugins.Cfg.CfgPrivateKeyCreator.SETUP.cfp.get.return_value = "lax" + pkc._decrypt.reset_mock() + pkc.Index() + self.assertItemsEqual( + pkc._decrypt.call_args_list, + [call(el) + for el in pkc.xdata.xpath("//Passphrase[@encrypted]")]) + + @skipUnless(HAS_CRYPTO, "No crypto libraries found, skipping") + def test_decrypt(self): + + @patch("Bcfg2.Encryption.ssl_decrypt") + @patch("Bcfg2.Encryption.get_algorithm") + @patch("Bcfg2.Encryption.get_passphrases") + @patch("Bcfg2.Encryption.bruteforce_decrypt") + def inner(mock_bruteforce, mock_get_passphrases, mock_get_algorithm, + mock_ssl): + pkc = self.get_obj() + + def reset(): + mock_bruteforce.reset_mock() + mock_get_algorithm.reset_mock() + mock_get_passphrases.reset_mock() + mock_ssl.reset_mock() + + # test element without text contents + self.assertIsNone(pkc._decrypt(lxml.etree.Element("Test"))) + self.assertFalse(mock_bruteforce.called) + self.assertFalse(mock_get_passphrases.called) + self.assertFalse(mock_ssl.called) + + # test element with a passphrase in the config file + reset() + el = lxml.etree.Element("Test", encrypted="foo") + el.text = "crypted" + mock_get_passphrases.return_value = dict(foo="foopass", + bar="barpass") + mock_get_algorithm.return_value = "bf_cbc" + mock_ssl.return_value = "decrypted with ssl" + self.assertEqual(pkc._decrypt(el), mock_ssl.return_value) + mock_get_passphrases.assert_called_with(SETUP) + mock_get_algorithm.assert_called_with(SETUP) + mock_ssl.assert_called_with(el.text, "foopass", + algorithm="bf_cbc") + self.assertFalse(mock_bruteforce.called) + + # test failure to decrypt element with a passphrase in the config + reset() + mock_ssl.side_effect = EVPError + self.assertRaises(EVPError, pkc._decrypt, el) + mock_get_passphrases.assert_called_with(SETUP) + mock_get_algorithm.assert_called_with(SETUP) + mock_ssl.assert_called_with(el.text, "foopass", + algorithm="bf_cbc") + self.assertFalse(mock_bruteforce.called) + + # test element without valid passphrase + reset() + el.set("encrypted", "true") + mock_bruteforce.return_value = "decrypted with bruteforce" + self.assertEqual(pkc._decrypt(el), mock_bruteforce.return_value) + mock_get_passphrases.assert_called_with(SETUP) + mock_get_algorithm.assert_called_with(SETUP) + mock_bruteforce.assert_called_with(el.text, + passphrases=["foopass", + "barpass"], + algorithm="bf_cbc") + self.assertFalse(mock_ssl.called) + + # test failure to decrypt element without valid passphrase + reset() + mock_bruteforce.side_effect = EVPError + self.assertRaises(EVPError, pkc._decrypt, el) + mock_get_passphrases.assert_called_with(SETUP) + mock_get_algorithm.assert_called_with(SETUP) + mock_bruteforce.assert_called_with(el.text, + passphrases=["foopass", + "barpass"], + algorithm="bf_cbc") + self.assertFalse(mock_ssl.called) diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgPublicKeyCreator.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgPublicKeyCreator.py new file mode 100644 index 000000000..2e7b6eef4 --- /dev/null +++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/TestCfgPublicKeyCreator.py @@ -0,0 +1,76 @@ +import os +import sys +import lxml.etree +from mock import Mock, MagicMock, patch +from Bcfg2.Server.Plugins.Cfg import CfgCreationError, CfgCreator +from Bcfg2.Server.Plugins.Cfg.CfgPublicKeyCreator import * +from Bcfg2.Server.Plugin import StructFile, PluginExecutionError + +# add all parent testsuite directories to sys.path to allow (most) +# relative imports in python 2.4 +path = os.path.dirname(__file__) +while path != "/": + if os.path.basename(path).lower().startswith("test"): + sys.path.append(path) + if os.path.basename(path) == "testsuite": + break + path = os.path.dirname(path) +from common import * +from TestServer.TestPlugins.TestCfg.Test_init import TestCfgCreator +from TestServer.TestPlugin.Testhelpers import TestStructFile + + +class TestCfgPublicKeyCreator(TestCfgCreator, TestStructFile): + test_obj = CfgPublicKeyCreator + should_monitor = False + + def get_obj(self, name=None, fam=None): + return TestCfgCreator.get_obj(self, name=name) + + @patch("Bcfg2.Server.Plugins.Cfg.CfgCreator.handle_event") + @patch("Bcfg2.Server.Plugin.helpers.StructFile.HandleEvent") + def test_handle_event(self, mock_HandleEvent, mock_handle_event): + pkc = self.get_obj() + evt = Mock() + pkc.handle_event(evt) + mock_HandleEvent.assert_called_with(pkc, evt) + mock_handle_event.assert_called_with(pkc, evt) + + def test_create_data(self): + metadata = Mock() + pkc = self.get_obj() + pkc.cfg = Mock() + + privkey_entryset = Mock() + privkey_creator = Mock() + pubkey = Mock() + privkey = Mock() + privkey_creator.create_data.return_value = (pubkey, privkey) + privkey_entryset.best_matching.return_value = privkey_creator + pkc.cfg.entries = {"/home/foo/.ssh/id_rsa": privkey_entryset} + + # public key doesn't end in .pub + entry = lxml.etree.Element("Path", name="/home/bar/.ssh/bogus") + self.assertRaises(CfgCreationError, + pkc.create_data, entry, metadata) + + # private key not in cfg.entries + entry = lxml.etree.Element("Path", name="/home/bar/.ssh/id_rsa.pub") + self.assertRaises(CfgCreationError, + pkc.create_data, entry, metadata) + + # successful operation + entry = lxml.etree.Element("Path", name="/home/foo/.ssh/id_rsa.pub") + self.assertEqual(pkc.create_data(entry, metadata), pubkey) + privkey_entryset.get_handlers.assert_called_with(metadata, CfgCreator) + privkey_entryset.best_matching.assert_called_with(metadata, + privkey_entryset.get_handlers.return_value) + self.assertXMLEqual(privkey_creator.create_data.call_args[0][0], + lxml.etree.Element("Path", + name="/home/foo/.ssh/id_rsa")) + self.assertEqual(privkey_creator.create_data.call_args[0][1], metadata) + + # no privkey.xml + privkey_entryset.best_matching.side_effect = PluginExecutionError + self.assertRaises(CfgCreationError, + pkc.create_data, entry, metadata) diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py index 6412480f0..55fbb7446 100644 --- a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py +++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py @@ -82,9 +82,10 @@ class TestCfgBaseFileMatcher(TestSpecificData): self.assertFalse(self.test_obj.handles(evt)) mock_get_regex.assert_called_with( [b for b in self.test_obj.__basenames__]) - self.assertItemsEqual(match.call_args_list, - [call(evt.filename) + print("match calls: %s" % match.call_args_list) + print("expected: %s" % [call(evt.filename) for b in self.test_obj.__basenames__]) + match.assert_called_with(evt.filename) mock_get_regex.reset_mock() match.reset_mock() @@ -186,47 +187,61 @@ class TestCfgCreator(TestCfgBaseFileMatcher): test_obj = CfgCreator path = "/foo/bar/test.txt" + def get_obj(self, name=None): + if name is None: + name = self.path + return self.test_obj(name) + def test_create_data(self): cc = self.get_obj() self.assertRaises(NotImplementedError, cc.create_data, Mock(), Mock()) + def test_get_filename(self): + cc = self.get_obj() + + # tuples of (args to get_filename(), expected result) + cases = [(dict(), "/foo/bar/bar"), + (dict(prio=50), "/foo/bar/bar"), + (dict(ext=".crypt"), "/foo/bar/bar.crypt"), + (dict(ext="bar"), "/foo/bar/barbar"), + (dict(host="foo.bar.example.com"), + "/foo/bar/bar.H_foo.bar.example.com"), + (dict(host="foo.bar.example.com", prio=50, ext=".crypt"), + "/foo/bar/bar.H_foo.bar.example.com.crypt"), + (dict(group="group", prio=1), "/foo/bar/bar.G01_group"), + (dict(group="group", prio=50), "/foo/bar/bar.G50_group"), + (dict(group="group", prio=50, ext=".crypt"), + "/foo/bar/bar.G50_group.crypt")] + + for args, expected in cases: + self.assertEqual(cc.get_filename(**args), expected) + @patch("os.makedirs") @patch("%s.open" % builtins) def test_write_data(self, mock_open, mock_makedirs): cc = self.get_obj() data = "test\ntest" + parent = os.path.dirname(self.path) def reset(): mock_open.reset_mock() mock_makedirs.reset_mock() - # test writing non-specific file - cc.write_data(data) - mock_makedirs.assert_called_with("/foo/bar") - mock_open.assert_called_with("/foo/bar/bar", "wb") - mock_open.return_value.write.assert_called_with(data) - - # test writing group-specific file - reset() - cc.write_data(data, group="foogroup", prio=9) - mock_makedirs.assert_called_with("/foo/bar") - mock_open.assert_called_with("/foo/bar/bar.G09_foogroup", "wb") - mock_open.return_value.write.assert_called_with(data) - - # test writing host-specific file + # test writing file reset() - cc.write_data(data, host="foo.example.com") - mock_makedirs.assert_called_with("/foo/bar") - mock_open.assert_called_with("/foo/bar/bar.H_foo.example.com", "wb") + spec = dict(group="foogroup", prio=9) + cc.write_data(data, **spec) + mock_makedirs.assert_called_with(parent) + mock_open.assert_called_with(cc.get_filename(**spec), "wb") mock_open.return_value.write.assert_called_with(data) # test already-exists error from makedirs reset() mock_makedirs.side_effect = OSError(errno.EEXIST, self.path) cc.write_data(data) - mock_makedirs.assert_called_with("/foo/bar") - mock_open.assert_called_with("/foo/bar/bar", "wb") + mock_makedirs.assert_called_with(parent) + mock_open.assert_called_with(cc.get_filename(), "wb") mock_open.return_value.write.assert_called_with(data) # test error from open @@ -391,6 +406,10 @@ class TestCfgEntrySet(TestEntrySet): evt = Mock() evt.filename = "test.txt" handler = Mock() + handler.__basenames__ = [] + handler.__extensions__ = [] + handler.deprecated = False + handler.experimental = False handler.__specific__ = True # test handling an event with the parent entry_init |