diff options
author | Jason Kincl <kincljc@ornl.gov> | 2012-12-04 08:14:33 -0500 |
---|---|---|
committer | Jason Kincl <kincljc@ornl.gov> | 2012-12-04 08:14:33 -0500 |
commit | 09a45d745269a419b0c5da0664912e061dc8e5d3 (patch) | |
tree | c5c0af33093087f10f0caf5ce021aa4cb0b4a879 | |
parent | 648c8f6e313e684d5fadc1fdbc97e08d83eb2b16 (diff) | |
parent | f35c38e87eafffb497338b9273fe84f284a41dcf (diff) | |
download | bcfg2-09a45d745269a419b0c5da0664912e061dc8e5d3.tar.gz bcfg2-09a45d745269a419b0c5da0664912e061dc8e5d3.tar.bz2 bcfg2-09a45d745269a419b0c5da0664912e061dc8e5d3.zip |
Merge remote branch 'upstream/master' into jasons-hacking
35 files changed, 1647 insertions, 504 deletions
diff --git a/doc/client/tools/posixusers.txt b/doc/client/tools/posixusers.txt new file mode 100644 index 000000000..884edc2b7 --- /dev/null +++ b/doc/client/tools/posixusers.txt @@ -0,0 +1,51 @@ +.. -*- mode: rst -*- + +.. _client-tools-posixusers: + +========== +POSIXUsers +========== + +The POSIXUsers tool handles the creation of users and groups as +defined by ``POSIXUser`` and ``POSIXGroup`` entries. For a full +description of those tags, see :ref:`server-plugins-generators-rules`. + +The POSIXUsers tool relies on the ``useradd``, ``usermod``, +``userdel``, ``groupadd``, ``groupmod``, and ``groupdel`` tools, since +there is no Python library to manage users and groups. It expects +those tools to be in ``/usr/sbin``. + +Primary group creation +====================== + +Each user must have a primary group, which can be specified with the +``group`` attribute of the ``POSIXUser`` tag. (If the ``group`` +attribute is not specified, then a group with the same name as the +user will be used.) If that group does not exist, the POSIXUsers tool +will create it automatically. It does this by adding a ``POSIXGroup`` +entry on the fly; this has a few repercussions: + +* When run in interactive mode (``-I``), Bcfg2 will prompt for + installation of the group separately from the user. +* The ``POSIXGroup`` entry is added to the same bundle as the + ``POSIXUser`` entry, so if the group is created, the bundle is + considered to have been modified and consequently Actions will be + run and Services will be restarted. This should never be a concern, + since the group can only be created, not modified (it has no + attributes other than its name), and if the group is being created + then the user will certainly be created or modified as well. +* The group is created with no specified GID number. If you need to + specify a particular GID number, you must explicitly define a + ``POSIXGroup`` entry for the group. + +Creating a baseline configuration +================================= + +The majority of users on many systems are created by the packages that +are installed, but currently Bcfg2 cannot query the package database +to determine these users. (In some cases, this is a limitation of the +packaging system.) The often-tedious task of creating a baseline that +defines all users and groups can be simplified by use of the +``tools/posixusers_baseline.py`` script, which outputs a bundle +containing all users and groups on the machine it's run on. + diff --git a/doc/development/compat.txt b/doc/development/compat.txt index d3e77b4a3..b7bf87bec 100644 --- a/doc/development/compat.txt +++ b/doc/development/compat.txt @@ -197,3 +197,9 @@ unicode In Py3k, the :func:`unicode` class is not defined, because all strings are unicode. ``Bcfg2.Compat`` defines ``unicode`` as equivalent to :func:`str` in Python 3. + +oct_mode +~~~~~~~~ + +.. autofunction:: Bcfg2.Compat.oct_mode + diff --git a/doc/development/documentation.txt b/doc/development/documentation.txt index 1e7667cc5..2a3cf46d1 100644 --- a/doc/development/documentation.txt +++ b/doc/development/documentation.txt @@ -103,5 +103,5 @@ Basics Sections -------- -Unless necessary, all the documentation follows the sections header -rules available at http://docs.python.org/documenting/rest.html#sections. +Unless necessary, all the documentation follows the sections header rules +available at http://docs.python.org/devguide/documenting.html#sections diff --git a/doc/server/plugins/generators/cfg.txt b/doc/server/plugins/generators/cfg.txt index 7d0e0acff..94394f98f 100644 --- a/doc/server/plugins/generators/cfg.txt +++ b/doc/server/plugins/generators/cfg.txt @@ -136,33 +136,33 @@ by running the template manually. To do this, run ``bcfg2-info debug``, and, once in the Python interpreter, run:: metadata = self.build_metadata("<hostname>") - path = "<relative path to template (see note below)>" - -``path`` should be set to the path to the template file with a leading -slash, relative to the Bcfg2 specification root. See `Inside Genshi -Templates`_ for examples. + source_path = "<full path to template>" + name = source_path[len(self.setup['repo']):] Then, run:: - import os, Bcfg2.Options + import os from genshi.template import TemplateLoader, NewTextTemplate - name = os.path.dirname(path[path.find('/', 1):]) - setup = Bcfg2.Options.OptionParser({'repo': - Bcfg2.Options.SERVER_REPOSITORY}) - setup.parse('--') - template = TemplateLoader().load(setup['repo'] + path, cls=NewTextTemplate) - print template.generate(metadata=metadata, path=path, name=name).render() + template = TemplateLoader().load(source_path, cls=NewTextTemplate) + data = dict(metadata=metadata, + source_path=source_path, + path=source_path, + name=name, + repo=self.setup['repo']) + print(template.generate(**data).render()) This gives you more fine-grained control over how your template is -rendered. +rendered. E.g., you can tweak the values of the variables passed to +the template, or evaluate the template manually, line-by-line, and so +on. You can also use this approach to render templates that depend on :ref:`altsrc <server-plugins-structures-altsrc>` tags by setting -``path`` to the path to the template, and setting ``name`` to the path +``source_path`` to the path to the template, and setting ``name`` to the path to the file to be generated, e.g.:: metadata = self.build_metadata("foo.example.com") - path = "/Cfg/etc/sysconfig/network-scripts/ifcfg-template/ifcfg-template.genshi" + source_path = "/Cfg/etc/sysconfig/network-scripts/ifcfg-template/ifcfg-template.genshi" name = "/etc/sysconfig/network-scripts/ifcfg-bond0" Error handling diff --git a/doc/server/plugins/generators/rules.txt b/doc/server/plugins/generators/rules.txt index 542b38f01..cdde65960 100644 --- a/doc/server/plugins/generators/rules.txt +++ b/doc/server/plugins/generators/rules.txt @@ -62,10 +62,10 @@ The Rules Tag may have the following attributes: | | Rules list.The higher value wins. | | +----------+-------------------------------------+--------+ -Rules Group Tag ---------------- +Group Tag +--------- -The Rules Group Tag may have the following attributes: +The Group Tag may have the following attributes: +--------+-------------------------+--------------+ | Name | Description | Values | @@ -76,6 +76,27 @@ The Rules Group Tag may have the following attributes: | | (is not a member of) | | +--------+-------------------------+--------------+ +Client Tag +---------- + +The Client Tag is used in Rules for selecting the package entries to +include in the clients literal configuration. Its function is similar +to the Group tag in this context. It can be thought of as:: + + if client is name then + assign to literal config + +The Client Tag may have the following attributes: + ++--------+-------------------------+--------------+ +| Name | Description | Values | ++========+=========================+==============+ +| name | Client Name | String | ++--------+-------------------------+--------------+ +| negate | Negate client selection | (true|false) | +| | (if not client name) | | ++--------+-------------------------+--------------+ + Package Tag ----------- @@ -84,8 +105,7 @@ The Package Tag may have the following attributes: +------------+----------------------------------------------+----------+ | Name | Description | Values | +============+==============================================+==========+ -| name | Package name or regular expression | String | -| | | or regex | +| name | Package name | String | +------------+----------------------------------------------+----------+ | version | Package Version or version='noverify' to | String | | | not do version checking in the Yum driver | | @@ -131,8 +151,7 @@ Service Tag | | service (new in 1.3; replaces | | | | "mode" attribute) | | +------------+-------------------------------+---------------------------------------------------------+ -| name | Service name or regular | String or regex | -| | expression | | +| name | Service name | String | +------------+-------------------------------+---------------------------------------------------------+ | status | Should the service be on or | (on | off | ignore) | | | off (default: off). | | @@ -193,27 +212,6 @@ Service status descriptions * Don't perform service status checks. -Client Tag ----------- - -The Client Tag is used in Rules for selecting the package entries to -include in the clients literal configuration. Its function is similar -to the Group tag in this context. It can be thought of as:: - - if client is name then - assign to literal config - -The Client Tag may have the following attributes: - -+--------+-------------------------+--------------+ -| Name | Description | Values | -+========+=========================+==============+ -| name | Client Name | String | -+--------+-------------------------+--------------+ -| negate | Negate client selection | (true|false) | -| | (if not client name) | | -+--------+-------------------------+--------------+ - Path Tag -------- @@ -229,11 +227,11 @@ the context of the file to the default set by policy. See Attributes common to all Path tags: -+----------+---------------------------------------------------+-----------------+ -| Name | Description | Values | -+==========+===================================================+=================+ -| name | Full path or regular expression matching the path | String or regex | -+----------+---------------------------------------------------+-----------------+ ++----------+-------------+--------+ +| Name | Description | Values | ++==========+=============+========+ +| name | Full path | String | ++----------+-------------+--------+ device @@ -394,14 +392,12 @@ the permissions to ``0674``. When this happens, Bcfg2 will change the permissions and set the ACLs on every run and the entry will be eternally marked as bad. -SELinux Tag ------------ +SELinux Entries +--------------- -The SELinux tag has different values depending on the *type* attribute -of the SELinux entry specified in your configuration. Below is a set -of tables which describe the attributes available for various SELinux -types. The types (except for ``module``) correspond to ``semanage`` -subcommands. +Below is a set of tables which describe the attributes available +for various SELinux types. The entry types (except for ``module``) +correspond to ``semanage`` subcommands. Note that the ``selinuxtype`` attribute takes only an SELinux type, not a full context; e.g., "``etc_t``", not @@ -411,18 +407,10 @@ As it can be very tedious to create a baseline of all existing SELinux entries, you can use ``selinux_baseline.py`` located in the ``tools/`` directory to do that for you. -In certain cases, it may be necessary to create multiple SELinux -entries with the same name. For instance, "root" is both an SELinux -user and an SELinux login record; or a given fcontext may need two -different SELinux types depending on whether it's a symlink or a plain -file. In these (few) cases, it is necessary to create BoundSELinux -entries directly in Bundler rather than using abstract SELinux entries -in Bundler and binding them with Rules. - See :ref:`server-selinux` for more information. -boolean -^^^^^^^ +SEBoolean Tag +^^^^^^^^^^^^^ +-------+----------------------+---------+----------+ | Name | Description | Values | Required | @@ -432,8 +420,8 @@ boolean | value | Value of the boolean | on|off | Yes | +-------+----------------------+---------+----------+ -port -^^^^ +SEPort Tag +^^^^^^^^^^ +-------------+------------------------+---------------------------+----------+ | Name | Description | Values | Required | @@ -445,8 +433,8 @@ port | | to this port | | | +-------------+------------------------+---------------------------+----------+ -fcontext -^^^^^^^^ +SEFcontext Tag +^^^^^^^^^^^^^^ +-------------+-------------------------+---------------------+----------+ | Name | Description | Values | Required | @@ -462,8 +450,8 @@ fcontext | | | socket|block|char) | | +-------------+-------------------------+---------------------+----------+ -node -^^^^ +SENode Tag +^^^^^^^^^^ +-------------+------------------------------------+------------------+----------+ | Name | Description | Values | Required | @@ -477,8 +465,8 @@ node | proto | Protocol | (ipv4|ipv6) | Yes | +-------------+------------------------------------+------------------+----------+ -login -^^^^^ +SELogin Tag +^^^^^^^^^^^ +-------------+-------------------------------+-----------+----------+ | Name | Description | Values | Required | @@ -488,8 +476,8 @@ login | selinuxuser | SELinux username | String | Yes | +-------------+-------------------------------+-----------+----------+ -user -^^^^ +SEUser Tag +^^^^^^^^^^ +-------------+-------------------------------+-----------+----------+ | Name | Description | Values | Required | @@ -501,8 +489,8 @@ user | prefix | Home directory context prefix | String | Yes | +-------------+-------------------------------+-----------+----------+ -interface -^^^^^^^^^ +SEInterface Tag +^^^^^^^^^^^^^^^ +-------------+-------------------------+-------------+----------+ | Name | Description | Values | Required | @@ -513,8 +501,8 @@ interface | | to this interface | | | +-------------+-------------------------+-------------+----------+ -permissive -^^^^^^^^^^ +SEPermissive Tag +^^^^^^^^^^^^^^^^ +-------------+------------------------------------+-------------+----------+ | Name | Description | Values | Required | @@ -522,11 +510,79 @@ permissive | name | SELinux type to make permissive | String | Yes | +-------------+------------------------------------+-------------+----------+ -module -^^^^^^ +SEModule Tag +^^^^^^^^^^^^ See :ref:`server-plugins-generators-semodules` +POSIXUser Tag +------------- + +The POSIXUser tag allows you to create users on client machines. It +takes the following attributes: + ++-------+-----------------------+---------+-------------------------------+ +| Name | Description | Values | Default | ++=======+=======================+=========+===============================+ +| name | Username | String | None | ++-------+-----------------------+---------+-------------------------------+ +| uid | User ID number | Integer | The client sets the uid | ++-------+-----------------------+---------+-------------------------------+ +| group | Name of the user's | String | The username | +| | primary group | | | ++-------+-----------------------+---------+-------------------------------+ +| gecos | Human-readable user | String | The username | +| | name or comment | | | ++-------+-----------------------+---------+-------------------------------+ +| home | User's home directory | String | /root (for "root"); | +| | | | /home/<username> otherwise | ++-------+-----------------------+---------+-------------------------------+ +| shell | User's shell | String | /bin/bash | ++-------+-----------------------+---------+-------------------------------+ + +The group specified will automatically be created if it does not +exist, even if there is no `POSIXGroup Tag`_ for it. If you need to +specify a particular GID for the group, you must specify that in a +``POSIXGroup`` tag. + +If you with to change the default shell, you can do so with :ref:`the +Defaults plugin <server-plugins-structures-defaults>`. + +Additionally, a user may be a member of supplementary groups. These +can be specified with the ``MemberOf`` child tag of the ``POSIXUser`` +tag. + +For example: + +.. code-block:: xml + + <POSIXUser name="daemon" home="/sbin" shell="/sbin/nologin" + gecos="daemon" uid="2" group="daemon"> + <MemberOf>lp</MemberOf> + <MemberOf>adm</MemberOf> + <MemberOf>bin</MemberOf> + </BoundPOSIXUser> + +See :ref:`client-tools-posixusers` for more information on managing +users and groups. + +POSIXGroup Tag +-------------- + +The POSIXGroup tag allows you to create groups on client machines. It +takes the following attributes: + ++-------+-------------------+---------+-------------------------+ +| Name | Description | Values | Default | ++=======+===================+=========+=========================+ +| name | Name of the group | String | None | ++-------+-------------------+---------+-------------------------+ +| gid | Group ID number | Integer | The client sets the gid | ++-------+-------------------+---------+-------------------------+ + +See :ref:`client-tools-posixusers` for more information on managing +users and groups. + Rules Directory =============== diff --git a/doc/server/plugins/generators/sslca.txt b/doc/server/plugins/generators/sslca.txt index 2b07f91fe..cabb4d730 100644 --- a/doc/server/plugins/generators/sslca.txt +++ b/doc/server/plugins/generators/sslca.txt @@ -146,9 +146,15 @@ cert.xml ``cert.xml`` is an XML document describing an SSL certificate generated from an SSL key that has also been generated by SSLCA. It -contains a top-level ``CertInfo`` tag that contains a single ``Cert`` -tag. (``Group`` and ``Client`` tags are not currently supported in -``cert.xml``.) The ``Cert`` tag may take the following attributes: +honors ``Group`` and ``Client`` tags much like Bundler. It must have +a top-level ``CertInfo`` tag and can contain two types of tags: + +Cert +^^^^ + +The ``Cert`` tag explains how the certificate should be generated. +There should be at least one ``Cert`` tag, and at most one ``Cert`` +tag should apply to any given client. +--------------+------------------------------------------+---------+---------+ | Attribute | Description | Values | Default | @@ -188,13 +194,42 @@ tag. (``Group`` and ``Client`` tags are not currently supported in | | the format required by Nginx) | | | +--------------+------------------------------------------+---------+---------+ +SubjectAltName +^^^^^^^^^^^^^^ + +The ``SubjectAltName`` tag contains text giving a subject alternative +name for the certificate. Any number of ``SubjectAltName`` tags may +be used. + +Example +^^^^^^^ + +.. code-block: xml + + <CertInfo> + <SubjectAltName>test.example.com</SubjectAltName> + <Group name="apache"> + <Cert key="/etc/pki/tls/private/foo.key" days="730"/> + </Group> + <Group name="nginx"> + <Cert key="/etc/pki/tls/private/foo.key" days="730" + append_chain="true"/> + </Group> + </CertInfo> + key.xml ------- -``key.xml`` is an XML document describing an SSL key. It contains a -top-level ``KeyInfo`` tag that contains a single ``Key`` tag. -(``Group`` and ``Client`` tags are not currently supported in -``key.xml``.) The ``Key`` tag may take the following attributes: +``key.xml`` is an XML document describing an SSL key. It also honors +``Group`` and ``Client`` tags. It contains a top-level ``KeyInfo`` +tag that contains at least one ``Key`` tag. + +Key +^^^ + +The ``Cert`` tag explains how the certificate should be generated. +There should be at least one ``Cert`` tag, and at most one ``Cert`` +tag should apply to any given client. +--------------+------------------------------------------+---------+---------+ | Attribute | Description | Values | Default | @@ -204,6 +239,20 @@ top-level ``KeyInfo`` tag that contains a single ``Key`` tag. | bits | The key length | Integer | 2048 | +--------------+------------------------------------------+---------+---------+ +Example +^^^^^^^ + +.. code-block: xml + + <KeyInfo> + <Group name="fast"> + <Key type="rsa" bits="1024"/> + </Group> + <Group name="secure"> + <Key type="rsa" bits="4096"/> + </Group> + </KeyInfo> + Automated Bcfg2 SSL Authentication ================================== diff --git a/doc/server/selinux.txt b/doc/server/selinux.txt index e61a09002..9f54b0d68 100644 --- a/doc/server/selinux.txt +++ b/doc/server/selinux.txt @@ -135,47 +135,16 @@ will be considered extra, making ``selinux_baseline.py`` quite necessary. ``selinux_baseline.py`` writes a bundle to stdout that contains -``BoundSELinux`` entries for the appropriate SELinux entities. It -does this rather than separate Bundle/Rules files because of the -:ref:`server-selinux-duplicate-entries` problem. +``BoundSELinux`` entries for the appropriate SELinux entities. .. _server-selinux-duplicate-entries: Duplicate Entries ----------------- -In certain cases, it may be necessary to create multiple SELinux -entries with the same name. For instance, "root" is both an SELinux -user and an SELinux login record, so to manage both, you would have -the following in Bundler: - -.. code-block:: xml - - <SELinux name="root"/> - <SELinux name="root"/> - -And in Rules: - -.. code-block:: xml - - <SELinux type="login" selinuxuser="root" name="root"/> - <SELinux type="user" prefix="user" name="root" - roles="system_r sysadm_r user_r"/> - -But Rules has no way to tell which "root" is which, and you will get -errors. In these cases, it is necessary to use ``BoundSELinux`` tags -directly in Bundler. (See :ref:`boundentries` for more details on -bound entries.) For instance: - -.. code-block:: xml - - <BoundSELinux type="login" selinuxuser="root" name="root"/> - <BoundSELinux type="user" prefix="user" name="root" - roles="system_r sysadm_r user_r"/> - -It may also be necessary to use ``BoundSELinux`` tags if a single -fcontext needs two different SELinux types depending on whether it's a -symlink or a plain file. For instance: +It may be necessary to use `BoundSELinux` tags if a single fcontext +needs two different SELinux types depending on whether it's a symlink +or a plain file. For instance: .. code-block:: xml diff --git a/schemas/bundle.xsd b/schemas/bundle.xsd index 6306b6da4..1fcf82c27 100644 --- a/schemas/bundle.xsd +++ b/schemas/bundle.xsd @@ -36,7 +36,7 @@ <xsd:documentation> Abstract implementation of a Path entry. The entry will either be handled by Cfg, TGenshi, or another - DirectoryBacked plugin; or handled by Rules, in which case + Generator plugin; or handled by Rules, in which case the full specification of this entry will be included in Rules. </xsd:documentation> @@ -66,6 +66,20 @@ </xsd:documentation> </xsd:annotation> </xsd:element> + <xsd:element name='POSIXUser' type='StructureEntry'> + <xsd:annotation> + <xsd:documentation> + Abstract description of a POSIXUser entry. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + <xsd:element name='POSIXGroup' type='StructureEntry'> + <xsd:annotation> + <xsd:documentation> + Abstract description of a POSIXGroup entry. + </xsd:documentation> + </xsd:annotation> + </xsd:element> <xsd:element name='PostInstall' type='StructureEntry'> <xsd:annotation> <xsd:documentation> @@ -111,6 +125,20 @@ </xsd:documentation> </xsd:annotation> </xsd:element> + <xsd:element name='BoundPOSIXUser' type='POSIXUserType'> + <xsd:annotation> + <xsd:documentation> + Fully bound description of a POSIXUser entry. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + <xsd:element name='BoundPOSIXGroup' type='POSIXGroupType'> + <xsd:annotation> + <xsd:documentation> + Fully bound description of a POSIXGroup entry. + </xsd:documentation> + </xsd:annotation> + </xsd:element> <xsd:element name='Group' type='GroupType'> <xsd:annotation> <xsd:documentation> diff --git a/schemas/rules.xsd b/schemas/rules.xsd index 2f4f805c0..241ffe5bf 100644 --- a/schemas/rules.xsd +++ b/schemas/rules.xsd @@ -57,6 +57,20 @@ </xsd:documentation> </xsd:annotation> </xsd:element> + <xsd:element name='POSIXUser' type='POSIXUserType'> + <xsd:annotation> + <xsd:documentation> + Fully bound description of a POSIXUser entry. + </xsd:documentation> + </xsd:annotation> + </xsd:element> + <xsd:element name='POSIXGroup' type='POSIXGroupType'> + <xsd:annotation> + <xsd:documentation> + Fully bound description of a POSIXGroup entry. + </xsd:documentation> + </xsd:annotation> + </xsd:element> <xsd:element name='PostInstall' type='PostInstallType'> <xsd:annotation> <xsd:documentation> diff --git a/schemas/sslca-cert.xsd b/schemas/sslca-cert.xsd index 921c1c7c6..9e0d031a2 100644 --- a/schemas/sslca-cert.xsd +++ b/schemas/sslca-cert.xsd @@ -6,42 +6,24 @@ </xsd:documentation> </xsd:annotation> - <!-- cert.xml does not support Group or Client tags, but it should - (and will, some day), so this is commented out for now --> - <!-- <xsd:complexType name="GroupType"> <xsd:choice minOccurs="1" maxOccurs="unbounded"> <xsd:element name="Cert" type="CertType"/> <xsd:element name="Group" type="GroupType"/> <xsd:element name="Client" type="GroupType"/> + <xsd:element name="subjectAltName" type="xsd:string"/> </xsd:choice> <xsd:attribute type="xsd:string" name="name" use="required"/> <xsd:attribute type="xsd:string" name="negate"/> </xsd:complexType> - --> - - <xsd:complexType name="CertType"> - <xsd:attribute type="xsd:string" name="key" use="required"/> - <xsd:attribute type="xsd:string" name="format"/> - <xsd:attribute type="xsd:string" name="ca"/> - <xsd:attribute type="xsd:integer" name="days"/> - <xsd:attribute type="xsd:string" name="c"/> - <xsd:attribute type="xsd:string" name="l"/> - <xsd:attribute type="xsd:string" name="st"/> - <xsd:attribute type="xsd:string" name="ou"/> - <xsd:attribute type="xsd:string" name="o"/> - <xsd:attribute type="xsd:string" name="emailaddress"/> - <xsd:attribute type="xsd:string" name="append_chain"/> - </xsd:complexType> <xsd:element name="CertInfo"> <xsd:complexType> <xsd:choice minOccurs="1" maxOccurs="unbounded"> <xsd:element name="Cert" type="CertType"/> - <!-- <xsd:element name="Group" type="GroupType"/> <xsd:element name="Client" type="GroupType"/> - --> + <xsd:element name="subjectAltName" type="xsd:string"/> </xsd:choice> </xsd:complexType> </xsd:element> diff --git a/schemas/sslca-key.xsd b/schemas/sslca-key.xsd index 2c931fa7d..e807ea037 100644 --- a/schemas/sslca-key.xsd +++ b/schemas/sslca-key.xsd @@ -6,9 +6,6 @@ </xsd:documentation> </xsd:annotation> - <!-- key.xml does not support Group or Client tags, but it should - (and will, some day), so this is commented out for now --> - <!-- <xsd:complexType name="GroupType"> <xsd:choice minOccurs="1" maxOccurs="unbounded"> <xsd:element name="Key" type="KeyType"/> @@ -18,7 +15,6 @@ <xsd:attribute type="xsd:string" name="name" use="required"/> <xsd:attribute type="xsd:string" name="negate"/> </xsd:complexType> - --> <xsd:complexType name="KeyType"> <xsd:attribute type="xsd:string" name="type"/> @@ -29,10 +25,8 @@ <xsd:complexType> <xsd:choice minOccurs="1" maxOccurs="unbounded"> <xsd:element name="Key" type="KeyType"/> - <!-- <xsd:element name="Group" type="GroupType"/> <xsd:element name="Client" type="GroupType"/> - --> </xsd:choice> </xsd:complexType> </xsd:element> diff --git a/schemas/types.xsd b/schemas/types.xsd index 1edde8754..a36693b2d 100644 --- a/schemas/types.xsd +++ b/schemas/types.xsd @@ -220,4 +220,21 @@ <xsd:attribute type="xsd:string" name="selinuxuser"/> <xsd:attributeGroup ref="py:genshiAttrs"/> </xsd:complexType> + + <xsd:complexType name="POSIXUserType"> + <xsd:choice minOccurs='0' maxOccurs='unbounded'> + <xsd:element name='MemberOf' type='xsd:string'/> + </xsd:choice> + <xsd:attribute type="xsd:string" name="name" use="required"/> + <xsd:attribute type="xsd:integer" name="uid"/> + <xsd:attribute type="xsd:string" name="group"/> + <xsd:attribute type="xsd:string" name="gecos"/> + <xsd:attribute type="xsd:string" name="home"/> + <xsd:attribute type="xsd:string" name="shell"/> + </xsd:complexType> + + <xsd:complexType name="POSIXGroupType"> + <xsd:attribute type="xsd:string" name="name" use="required"/> + <xsd:attribute type="xsd:integer" name="gid"/> + </xsd:complexType> </xsd:schema> diff --git a/src/lib/Bcfg2/Client/Client.py b/src/lib/Bcfg2/Client/Client.py index f197a9074..45e0b64e6 100644 --- a/src/lib/Bcfg2/Client/Client.py +++ b/src/lib/Bcfg2/Client/Client.py @@ -56,8 +56,8 @@ class Client(object): self.logger.error("Service removal is nonsensical; " "removed services will only be disabled") if (self.setup['remove'] and - self.setup['remove'].lower() not in ['all', 'services', - 'packages']): + self.setup['remove'].lower() not in ['all', 'services', 'packages', + 'users']): self.logger.error("Got unknown argument %s for -r" % self.setup['remove']) if self.setup["file"] and self.setup["cache"]: diff --git a/src/lib/Bcfg2/Client/Frame.py b/src/lib/Bcfg2/Client/Frame.py index 53180ab68..4f3ff1820 100644 --- a/src/lib/Bcfg2/Client/Frame.py +++ b/src/lib/Bcfg2/Client/Frame.py @@ -105,6 +105,10 @@ class Frame(object): if deprecated: self.logger.warning("Loaded deprecated tool drivers:") self.logger.warning(deprecated) + experimental = [tool.name for tool in self.tools if tool.experimental] + if experimental: + self.logger.warning("Loaded experimental tool drivers:") + self.logger.warning(experimental) # find entries not handled by any tools self.unhandled = [entry for struct in config @@ -281,12 +285,15 @@ class Frame(object): if self.setup['remove']: if self.setup['remove'] == 'all': self.removal = self.extra - elif self.setup['remove'] in ['services', 'Services']: + elif self.setup['remove'].lower() == 'services': self.removal = [entry for entry in self.extra if entry.tag == 'Service'] - elif self.setup['remove'] in ['packages', 'Packages']: + elif self.setup['remove'].lower() == 'packages': self.removal = [entry for entry in self.extra if entry.tag == 'Package'] + elif self.setup['remove'].lower() == 'users': + self.removal = [entry for entry in self.extra + if entry.tag in ['POSIXUser', 'POSIXGroup']] candidates = [entry for entry in self.states if not self.states[entry]] diff --git a/src/lib/Bcfg2/Client/Tools/POSIX/File.py b/src/lib/Bcfg2/Client/Tools/POSIX/File.py index 5842c4e1f..9b95d2234 100644 --- a/src/lib/Bcfg2/Client/Tools/POSIX/File.py +++ b/src/lib/Bcfg2/Client/Tools/POSIX/File.py @@ -188,6 +188,10 @@ class POSIXFile(POSIXTool): prompt.append(udiff) except UnicodeEncodeError: prompt.append("Could not encode diff") + elif entry.get("empty", "true"): + # the file doesn't exist on disk, but there's no + # expected content + prompt.append("%s does not exist" % entry.get("name")) else: prompt.append("Diff took too long to compute, no " "printable diff") diff --git a/src/lib/Bcfg2/Client/Tools/POSIX/base.py b/src/lib/Bcfg2/Client/Tools/POSIX/base.py index 6388f6731..b867fa3d8 100644 --- a/src/lib/Bcfg2/Client/Tools/POSIX/base.py +++ b/src/lib/Bcfg2/Client/Tools/POSIX/base.py @@ -9,6 +9,7 @@ import copy import shutil import Bcfg2.Client.Tools import Bcfg2.Client.XML +from Bcfg2.Compat import oct_mode try: import selinux @@ -128,7 +129,7 @@ class POSIXTool(Bcfg2.Client.Tools.Tool): wanted_mode |= device_map[entry.get('dev_type')] try: self.logger.debug("POSIX: Setting mode on %s to %s" % - (path, oct(wanted_mode))) + (path, oct_mode(wanted_mode))) os.chmod(path, wanted_mode) except (OSError, KeyError): self.logger.error('POSIX: Failed to change mode on %s' % @@ -436,7 +437,7 @@ class POSIXTool(Bcfg2.Client.Tools.Tool): group = None try: - mode = oct(ondisk[stat.ST_MODE])[-4:] + mode = oct_mode(ondisk[stat.ST_MODE])[-4:] except (OSError, KeyError, TypeError): err = sys.exc_info()[1] self.logger.debug("POSIX: Could not get current permissions of " @@ -507,7 +508,7 @@ class POSIXTool(Bcfg2.Client.Tools.Tool): (path, attrib['current_group'], entry.get('group'))) if (wanted_mode and - oct(int(attrib['current_mode'], 8)) != oct(wanted_mode)): + oct_mode(int(attrib['current_mode'], 8)) != oct_mode(wanted_mode)): errors.append("Permissions for path %s are incorrect. " "Current permissions are %s but should be %s" % (path, attrib['current_mode'], entry.get('mode'))) @@ -708,10 +709,11 @@ class POSIXTool(Bcfg2.Client.Tools.Tool): for i in range(0, 3): if newmode & (6 * pow(8, i)): newmode |= 1 * pow(8, i) - tmpentry.set('mode', oct(newmode)) + tmpentry.set('mode', oct_mode(newmode)) for acl in tmpentry.findall('ACL'): acl.set('perms', - oct(self._norm_acl_perms(acl.get('perms')) | ACL_MAP['x'])) + oct_mode(self._norm_acl_perms(acl.get('perms')) | \ + ACL_MAP['x'])) for cpath in created: rv &= self._set_perms(tmpentry, path=cpath) return rv diff --git a/src/lib/Bcfg2/Client/Tools/POSIXUsers.py b/src/lib/Bcfg2/Client/Tools/POSIXUsers.py new file mode 100644 index 000000000..78734f5c2 --- /dev/null +++ b/src/lib/Bcfg2/Client/Tools/POSIXUsers.py @@ -0,0 +1,300 @@ +""" A tool to handle creating users and groups with useradd/mod/del +and groupadd/mod/del """ + +import sys +import pwd +import grp +import Bcfg2.Client.XML +import subprocess +import Bcfg2.Client.Tools + + +class ExecutionError(Exception): + """ Raised when running an external command fails """ + + def __init__(self, msg, retval=None): + Exception.__init__(self, msg) + self.retval = retval + + def __str__(self): + return "%s (rv: %s)" % (Exception.__str__(self), + self.retval) + + +class Executor(object): + """ A better version of Bcfg2.Client.Tool.Executor, which captures + stderr, raises exceptions on error, and doesn't use the shell to + execute by default """ + + def __init__(self, logger): + self.logger = logger + self.stdout = None + self.stderr = None + self.retval = None + + def run(self, command, inputdata=None, shell=False): + """ Run a command, given as a list, optionally giving it the + specified input data """ + self.logger.debug("Running: %s" % " ".join(command)) + proc = subprocess.Popen(command, shell=shell, bufsize=16384, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, close_fds=True) + if inputdata: + for line in inputdata.splitlines(): + self.logger.debug('> %s' % line) + (self.stdout, self.stderr) = proc.communicate(inputdata) + else: + (self.stdout, self.stderr) = proc.communicate() + for line in self.stdout.splitlines(): # pylint: disable=E1103 + self.logger.debug('< %s' % line) + self.retval = proc.wait() + if self.retval == 0: + for line in self.stderr.splitlines(): # pylint: disable=E1103 + self.logger.warning(line) + return True + else: + raise ExecutionError(self.stderr, self.retval) + + +class POSIXUsers(Bcfg2.Client.Tools.Tool): + """ A tool to handle creating users and groups with + useradd/mod/del and groupadd/mod/del """ + __execs__ = ['/usr/sbin/useradd', '/usr/sbin/usermod', '/usr/sbin/userdel', + '/usr/sbin/groupadd', '/usr/sbin/groupmod', + '/usr/sbin/groupdel'] + __handles__ = [('POSIXUser', None), + ('POSIXGroup', None)] + __req__ = dict(POSIXUser=['name'], + POSIXGroup=['name']) + experimental = True + + # A mapping of XML entry attributes to the indexes of + # corresponding values in the get*ent data structures + attr_mapping = dict(POSIXUser=dict(name=0, uid=2, gecos=4, home=5, + shell=6), + POSIXGroup=dict(name=0, gid=2)) + + def __init__(self, logger, setup, config): + Bcfg2.Client.Tools.Tool.__init__(self, logger, setup, config) + self.set_defaults = dict(POSIXUser=self.populate_user_entry, + POSIXGroup=lambda g: g) + self.cmd = Executor(logger) + self._existing = None + + @property + def existing(self): + """ Get a dict of existing users and groups """ + if self._existing is None: + self._existing = dict(POSIXUser=dict([(u[0], u) + for u in pwd.getpwall()]), + POSIXGroup=dict([(g[0], g) + for g in grp.getgrall()])) + return self._existing + + def Inventory(self, states, structures=None): + if not structures: + structures = self.config.getchildren() + # we calculate a list of all POSIXUser and POSIXGroup entries, + # and then add POSIXGroup entries that are required to create + # the primary group for each user to the structures. this is + # sneaky and possibly evil, but it works great. + groups = [] + for struct in structures: + groups.extend([e.get("name") + for e in struct.findall("POSIXGroup")]) + for struct in structures: + for entry in struct.findall("POSIXUser"): + group = self.set_defaults[entry.tag](entry).get('group') + if group and group not in groups: + self.logger.debug("POSIXUsers: Adding POSIXGroup entry " + "'%s' for user '%s'" % + (group, entry.get("name"))) + struct.append(Bcfg2.Client.XML.Element("POSIXGroup", + name=group)) + return Bcfg2.Client.Tools.Tool.Inventory(self, states, structures) + + def FindExtra(self): + extra = [] + for handles in self.__handles__: + tag = handles[0] + specified = [] + for entry in self.getSupportedEntries(): + if entry.tag == tag: + specified.append(entry.get("name")) + extra.extend([Bcfg2.Client.XML.Element(tag, name=e) + for e in self.existing[tag].keys() + if e not in specified]) + return extra + + def populate_user_entry(self, entry): + """ Given a POSIXUser entry, set all of the 'missing' attributes + with their defaults """ + defaults = dict(group=entry.get('name'), + gecos=entry.get('name'), + shell='/bin/bash') + if entry.get('name') == 'root': + defaults['home'] = '/root' + else: + defaults['home'] = '/home/%s' % entry.get('name') + for key, val in defaults.items(): + if entry.get(key) is None: + entry.set(key, val) + if entry.get('group') in self.existing['POSIXGroup']: + entry.set('gid', + str(self.existing['POSIXGroup'][entry.get('group')][2])) + return entry + + def user_supplementary_groups(self, entry): + """ Get a list of supplmentary groups that the user in the + given entry is a member of """ + return [g for g in self.existing['POSIXGroup'].values() + if entry.get("name") in g[3] and g[0] != entry.get("group")] + + def VerifyPOSIXUser(self, entry, _): + """ Verify a POSIXUser entry """ + rv = self._verify(self.populate_user_entry(entry)) + if entry.get("current_exists", "true") == "true": + # verify supplemental groups + actual = [g[0] for g in self.user_supplementary_groups(entry)] + expected = [e.text for e in entry.findall("MemberOf")] + if set(expected) != set(actual): + entry.set('qtext', + "\n".join([entry.get('qtext', '')] + + ["%s %s has incorrect supplemental group " + "membership. Currently: %s. Should be: %s" + % (entry.tag, entry.get("name"), + actual, expected)])) + rv = False + if self.setup['interactive'] and not rv: + entry.set('qtext', + '%s\nInstall %s %s: (y/N) ' % + (entry.get('qtext', ''), entry.tag, entry.get('name'))) + return rv + + def VerifyPOSIXGroup(self, entry, _): + """ Verify a POSIXGroup entry """ + rv = self._verify(entry) + if self.setup['interactive'] and not rv: + entry.set('qtext', + '%s\nInstall %s %s: (y/N) ' % + (entry.get('qtext', ''), entry.tag, entry.get('name'))) + return rv + + def _verify(self, entry): + """ Perform most of the actual work of verification """ + errors = [] + if entry.get("name") not in self.existing[entry.tag]: + entry.set('current_exists', 'false') + errors.append("%s %s does not exist" % (entry.tag, + entry.get("name"))) + else: + for attr, idx in self.attr_mapping[entry.tag].items(): + val = str(self.existing[entry.tag][entry.get("name")][idx]) + entry.set("current_%s" % attr, val) + if attr in ["uid", "gid"]: + if entry.get(attr) is None: + # no uid/gid specified, so we let the tool + # automatically determine one -- i.e., it always + # verifies + continue + if val != entry.get(attr): + errors.append("%s for %s %s is incorrect. Current %s is " + "%s, but should be %s" % + (attr.title(), entry.tag, entry.get("name"), + attr, entry.get(attr), val)) + + if errors: + for error in errors: + self.logger.debug("%s: %s" % (self.name, error)) + entry.set('qtext', "\n".join([entry.get('qtext', '')] + errors)) + return len(errors) == 0 + + def Install(self, entries, states): + for entry in entries: + # install groups first, so that all groups exist for + # users that might need them + if entry.tag == 'POSIXGroup': + states[entry] = self._install(entry) + for entry in entries: + if entry.tag == 'POSIXUser': + states[entry] = self._install(entry) + self._existing = None + + def _install(self, entry): + """ add or modify a user or group using the appropriate command """ + if entry.get("name") not in self.existing[entry.tag]: + action = "add" + else: + action = "mod" + try: + self.cmd.run(self._get_cmd(action, + self.set_defaults[entry.tag](entry))) + self.modified.append(entry) + return True + except ExecutionError: + self.logger.error("POSIXUsers: Error creating %s %s: %s" % + (entry.tag, entry.get("name"), + sys.exc_info()[1])) + return False + + def _get_cmd(self, action, entry): + """ Get a command to perform the appropriate action (add, mod, + del) on the given entry. The command is always the same; we + set all attributes on a given user or group when modifying it + rather than checking which ones need to be changed. This + makes things fail as a unit (e.g., if a user is logged in, you + can't change its home dir, but you could change its GECOS, but + the whole operation fails), but it also makes this function a + lot, lot easier and simpler.""" + cmd = ["/usr/sbin/%s%s" % (entry.tag[5:].lower(), action)] + if action != 'del': + if entry.tag == 'POSIXGroup': + if entry.get('gid'): + cmd.extend(['-g', entry.get('gid')]) + elif entry.tag == 'POSIXUser': + cmd.append('-m') + if entry.get('uid'): + cmd.extend(['-u', entry.get('uid')]) + cmd.extend(['-g', entry.get('group')]) + extras = [e.text for e in entry.findall("MemberOf")] + if extras: + cmd.extend(['-G', ",".join(extras)]) + cmd.extend(['-d', entry.get('home')]) + cmd.extend(['-s', entry.get('shell')]) + cmd.extend(['-c', entry.get('gecos')]) + cmd.append(entry.get('name')) + return cmd + + def Remove(self, entries): + for entry in entries: + # remove users first, so that all users have been removed + # from groups before we remove them + if entry.tag == 'POSIXUser': + self._remove(entry) + for entry in entries: + if entry.tag == 'POSIXGroup': + try: + grp.getgrnam(entry.get("name")) + self._remove(entry) + except KeyError: + # at least some versions of userdel automatically + # remove the primary group for a user if the group + # name is the same as the username, and no other + # users are in the group + self.logger.info("POSIXUsers: Group %s does not exist. " + "It may have already been removed when " + "its users were deleted" % + entry.get("name")) + self._existing = None + self.extra = self.FindExtra() + + def _remove(self, entry): + """ Remove an entry """ + try: + self.cmd.run(self._get_cmd("del", entry)) + return True + except ExecutionError: + self.logger.error("POSIXUsers: Error deleting %s %s: %s" % + (entry.tag, entry.get("name"), + sys.exc_info()[1])) + return False diff --git a/src/lib/Bcfg2/Client/Tools/SELinux.py b/src/lib/Bcfg2/Client/Tools/SELinux.py index fc47883c9..6bd728114 100644 --- a/src/lib/Bcfg2/Client/Tools/SELinux.py +++ b/src/lib/Bcfg2/Client/Tools/SELinux.py @@ -58,36 +58,48 @@ def netmask_itoa(netmask, proto="ipv4"): class SELinux(Bcfg2.Client.Tools.Tool): """ SELinux entry support """ name = 'SELinux' - __handles__ = [('SELinux', 'boolean'), - ('SELinux', 'port'), - ('SELinux', 'fcontext'), - ('SELinux', 'node'), - ('SELinux', 'login'), - ('SELinux', 'user'), - ('SELinux', 'interface'), - ('SELinux', 'permissive'), - ('SELinux', 'module')] - __req__ = dict(SELinux=dict(boolean=['name', 'value'], - module=['name'], - port=['name', 'selinuxtype'], - fcontext=['name', 'selinuxtype'], - node=['name', 'selinuxtype', 'proto'], - login=['name', 'selinuxuser'], - user=['name', 'roles', 'prefix'], - interface=['name', 'selinuxtype'], - permissive=['name'])) + __handles__ = [('SEBoolean', None), + ('SEFcontext', None), + ('SEInterface', None), + ('SELogin', None), + ('SEModule', None), + ('SENode', None), + ('SEPermissive', None), + ('SEPort', None), + ('SEUser', None)] + __req__ = dict(SEBoolean=['name', 'value'], + SEFcontext=['name', 'selinuxtype'], + SEInterface=['name', 'selinuxtype'], + SELogin=['name', 'selinuxuser'], + SEModule=['name'], + SENode=['name', 'selinuxtype', 'proto'], + SEPermissive=['name'], + SEPort=['name', 'selinuxtype'], + SEUser=['name', 'roles', 'prefix']) def __init__(self, logger, setup, config): Bcfg2.Client.Tools.Tool.__init__(self, logger, setup, config) self.handlers = {} - for handles in self.__handles__: - etype = handles[1] + for handler in self.__handles__: + etype = handler[0] self.handlers[etype] = \ globals()["SELinux%sHandler" % etype.title()](self, logger, setup, config) self.txn = False self.post_txn_queue = [] + def __getattr__(self, attr): + if attr.startswith("VerifySE"): + return self.GenericSEVerify + elif attr.startswith("InstallSE"): + return self.GenericSEInstall + # there's no need for an else here, because python checks for + # an attribute in the "normal" ways first. i.e., if self.txn + # is used, __getattr__() is never called because txn exists as + # a "normal" attribute of this object. See + # http://docs.python.org/2/reference/datamodel.html#object.__getattr__ + # for details + def BundleUpdated(self, _, states): for handler in self.handlers.values(): handler.BundleUpdated(states) @@ -100,12 +112,12 @@ class SELinux(Bcfg2.Client.Tools.Tool): def canInstall(self, entry): return (Bcfg2.Client.Tools.Tool.canInstall(self, entry) and - self.handlers[entry.get('type')].canInstall(entry)) + self.handlers[entry.tag].canInstall(entry)) def primarykey(self, entry): """ return a string that should be unique amongst all entries in the specification """ - return self.handlers[entry.get('type')].primarykey(entry) + return self.handlers[entry.tag].primarykey(entry) def Install(self, entries, states): # start a transaction @@ -125,32 +137,32 @@ class SELinux(Bcfg2.Client.Tools.Tool): for func, arg, kwargs in self.post_txn_queue: states[arg] = func(*arg, **kwargs) - def InstallSELinux(self, entry): - """Dispatch install to the proper method according to type""" - return self.handlers[entry.get('type')].Install(entry) + def GenericSEInstall(self, entry): + """Dispatch install to the proper method according to entry tag""" + return self.handlers[entry.tag].Install(entry) - def VerifySELinux(self, entry, _): - """Dispatch verify to the proper method according to type""" - rv = self.handlers[entry.get('type')].Verify(entry) + def GenericSEVerify(self, entry, _): + """Dispatch verify to the proper method according to entry tag""" + rv = self.handlers[entry.tag].Verify(entry) if entry.get('qtext') and self.setup['interactive']: entry.set('qtext', - '%s\nInstall SELinux %s %s: (y/N) ' % + '%s\nInstall %s: (y/N) ' % (entry.get('qtext'), - entry.get('type'), - self.handlers[entry.get('type')].tostring(entry))) + self.handlers[entry.tag].tostring(entry))) return rv def Remove(self, entries): - """Dispatch verify to the proper removal method according to type""" + """Dispatch verify to the proper removal + method according to entry tag""" # sort by type types = list() for entry in entries: - if entry.get('type') not in types: - types.append(entry.get('type')) + if entry.tag not in types: + types.append(entry.tag) for etype in types: self.handlers[etype].Remove([e for e in entries - if e.get('type') == etype]) + if e.tag == etype]) class SELinuxEntryHandler(object): @@ -253,8 +265,7 @@ class SELinuxEntryHandler(object): def key2entry(self, key): """ Generate an XML entry from an SELinux record key """ attrs = self._key2attrs(key) - attrs["type"] = self.etype - return Bcfg2.Client.XML.Element("SELinux", **attrs) + return Bcfg2.Client.XML.Element(self.etype, **attrs) def _args(self, entry, method): """ Get the argument list for invoking _modify or _add, or @@ -279,7 +290,7 @@ class SELinuxEntryHandler(object): """ return a string that should be unique amongst all entries in the specification. some entry types are not universally disambiguated by tag:type:name alone """ - return ":".join([entry.tag, entry.get("type"), entry.get("name")]) + return ":".join([entry.tag, entry.get("name")]) def exists(self, entry): """ return True if the entry already exists in the record list """ @@ -303,8 +314,8 @@ class SELinuxEntryHandler(object): continue if current_attrs[attr] != desired_attrs[attr]: entry.set('current_%s' % attr, current_attrs[attr]) - errors.append("SELinux %s %s has wrong %s: %s, should be %s" % - (self.etype, self.tostring(entry), attr, + errors.append("%s %s has wrong %s: %s, should be %s" % + (entry.tag, entry.get('name'), attr, current_attrs[attr], desired_attrs[attr])) if errors: @@ -331,8 +342,8 @@ class SELinuxEntryHandler(object): return True except ValueError: err = sys.exc_info()[1] - self.logger.debug("Failed to %s SELinux %s %s: %s" % - (method, self.etype, self.tostring(entry), err)) + self.logger.info("Failed to %s SELinux %s %s: %s" % + (method, self.etype, self.tostring(entry), err)) return False def Remove(self, entries): @@ -365,7 +376,7 @@ class SELinuxEntryHandler(object): pass -class SELinuxBooleanHandler(SELinuxEntryHandler): +class SELinuxSebooleanHandler(SELinuxEntryHandler): """ handle SELinux boolean entries """ etype = "boolean" value_format = ("value",) @@ -414,7 +425,7 @@ class SELinuxBooleanHandler(SELinuxEntryHandler): SELinuxEntryHandler.canInstall(self, entry)) -class SELinuxPortHandler(SELinuxEntryHandler): +class SELinuxSeportHandler(SELinuxEntryHandler): """ handle SELinux port entries """ etype = "port" value_format = ('selinuxtype', None) @@ -486,7 +497,7 @@ class SELinuxPortHandler(SELinuxEntryHandler): return tuple(entry.get("name").split("/")) -class SELinuxFcontextHandler(SELinuxEntryHandler): +class SELinuxSefcontextHandler(SELinuxEntryHandler): """ handle SELinux file context entries """ etype = "fcontext" @@ -556,11 +567,11 @@ class SELinuxFcontextHandler(SELinuxEntryHandler): '', '') def primarykey(self, entry): - return ":".join([entry.tag, entry.get("type"), entry.get("name"), + return ":".join([entry.tag, entry.get("name"), entry.get("filetype", "all")]) -class SELinuxNodeHandler(SELinuxEntryHandler): +class SELinuxSenodeHandler(SELinuxEntryHandler): """ handle SELinux node entries """ etype = "node" @@ -592,7 +603,7 @@ class SELinuxNodeHandler(SELinuxEntryHandler): entry.get("selinuxtype")) -class SELinuxLoginHandler(SELinuxEntryHandler): +class SELinuxSeloginHandler(SELinuxEntryHandler): """ handle SELinux login entries """ etype = "login" @@ -603,7 +614,7 @@ class SELinuxLoginHandler(SELinuxEntryHandler): return (entry.get("name"), entry.get("selinuxuser"), "") -class SELinuxUserHandler(SELinuxEntryHandler): +class SELinuxSeuserHandler(SELinuxEntryHandler): """ handle SELinux user entries """ etype = "user" @@ -652,7 +663,7 @@ class SELinuxUserHandler(SELinuxEntryHandler): return tuple(rv) -class SELinuxInterfaceHandler(SELinuxEntryHandler): +class SELinuxSeinterfaceHandler(SELinuxEntryHandler): """ handle SELinux interface entries """ etype = "interface" @@ -663,7 +674,7 @@ class SELinuxInterfaceHandler(SELinuxEntryHandler): return (entry.get("name"), '', entry.get("selinuxtype")) -class SELinuxPermissiveHandler(SELinuxEntryHandler): +class SELinuxSepermissiveHandler(SELinuxEntryHandler): """ handle SELinux permissive domain entries """ etype = "permissive" @@ -695,7 +706,7 @@ class SELinuxPermissiveHandler(SELinuxEntryHandler): return (entry.get("name"),) -class SELinuxModuleHandler(SELinuxEntryHandler): +class SELinuxSemoduleHandler(SELinuxEntryHandler): """ handle SELinux module entries """ etype = "module" @@ -808,10 +819,9 @@ class SELinuxModuleHandler(SELinuxEntryHandler): def Install(self, entry, _=None): if not self.filetool.install(self._pathentry(entry)): return False - if hasattr(self, 'records'): + if hasattr(seobject, 'moduleRecords'): # if seobject has the moduleRecords attribute, install the # module using the seobject library - self.records # pylint: disable=W0104 return self._install_seobject(entry) else: # seobject doesn't have the moduleRecords attribute, so @@ -891,8 +901,7 @@ class SELinuxModuleHandler(SELinuxEntryHandler): def FindExtra(self): specified = [self._key(e) - for e in self.tool.getSupportedEntries() - if e.get("type") == self.etype] + for e in self.tool.getSupportedEntries()] rv = [] for module in self._all_records_from_filesystem().keys(): if module not in specified: diff --git a/src/lib/Bcfg2/Client/Tools/__init__.py b/src/lib/Bcfg2/Client/Tools/__init__.py index 927b25ba8..d5f55759f 100644 --- a/src/lib/Bcfg2/Client/Tools/__init__.py +++ b/src/lib/Bcfg2/Client/Tools/__init__.py @@ -61,6 +61,7 @@ class Tool(object): __req__ = {} __important__ = [] deprecated = False + experimental = False def __init__(self, logger, setup, config): self.setup = setup diff --git a/src/lib/Bcfg2/Compat.py b/src/lib/Bcfg2/Compat.py index 23f7ef784..b0f0ef5cf 100644 --- a/src/lib/Bcfg2/Compat.py +++ b/src/lib/Bcfg2/Compat.py @@ -245,3 +245,18 @@ except ImportError: def wraps(wrapped): # pylint: disable=W0613 """ implementation of functools.wraps() for python 2.4 """ return lambda f: f + + +def oct_mode(mode): + """ Convert a decimal number describing a POSIX permissions mode + to a string giving the octal mode. In Python 2, this is a synonym + for :func:`oct`, but in Python 3 the octal format has changed to + ``0o000``, which cannot be used as an octal permissions mode, so + we need to strip the 'o' from the output. I.e., this function + acts like the Python 2 :func:`oct` regardless of what version of + Python is in use. + + :param mode: The decimal mode to convert to octal + :type mode: int + :returns: string """ + return oct(mode).replace('o', '') diff --git a/src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html b/src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html index 06c99d899..fd9a545ce 100644 --- a/src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html +++ b/src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html @@ -5,8 +5,8 @@ {% block pagebanner %}Clients - Detailed View{% endblock %} {% block content %} -<div class='client_list_box'> {% filter_navigator %} +<div class='client_list_box'> {% if entry_list %} <table cellpadding="3"> <tr id='table_list_header' class='listview'> diff --git a/src/lib/Bcfg2/Reporting/views.py b/src/lib/Bcfg2/Reporting/views.py index 7dc216bd4..8ab3f8e59 100644 --- a/src/lib/Bcfg2/Reporting/views.py +++ b/src/lib/Bcfg2/Reporting/views.py @@ -276,7 +276,7 @@ def client_index(request, timestamp=None, **kwargs): """ list = _handle_filters(Interaction.objects.recent(timestamp), **kwargs).\ - select_related().order_by("client__name").all() + select_related('client').order_by("client__name").all() return render_to_response('clients/index.html', {'inter_list': list, diff --git a/src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py index 3a78b4847..73550cd9d 100644 --- a/src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py +++ b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py @@ -11,8 +11,39 @@ from Bcfg2.Server.Plugins.Cfg import CfgGenerator, SETUP try: import genshi.core from genshi.template import TemplateLoader, NewTextTemplate - from genshi.template.eval import UndefinedError + from genshi.template.eval import UndefinedError, Suite + #: True if Genshi libraries are available HAS_GENSHI = True + + def _genshi_removes_blank_lines(): + """ Genshi 0.5 uses the Python :mod:`compiler` package to + compile genshi snippets to AST. Genshi 0.6 uses some bespoke + magic, because compiler has been deprecated. + :func:`compiler.parse` produces an AST that removes all excess + whitespace (e.g., blank lines), while + :func:`genshi.template.astutil.parse` does not. In order to + determine which actual line of code an error occurs on, we + need to know which is in use and how it treats blank lines. + I've beat my head against this for hours and the best/only way + I can find is to compile some genshi code with an error and + see which line it's on.""" + code = """d = dict() + +d['a']""" + try: + Suite(code).execute(dict()) + except KeyError: + line = traceback.extract_tb(sys.exc_info()[2])[-1][1] + if line == 2: + return True + else: + return False + + #: True if Genshi removes all blank lines from a code block before + #: executing it; False indicates that Genshi only removes leading + #: and trailing blank lines. See + #: :func:`_genshi_removes_blank_lines` for an explanation of this. + GENSHI_REMOVES_BLANK_LINES = _genshi_removes_blank_lines() except ImportError: TemplateLoader = None # pylint: disable=C0103 HAS_GENSHI = False @@ -111,7 +142,17 @@ class CfgGenshiGenerator(CfgGenerator): # the traceback is just the beginning of the block. err = exc[1] stack = traceback.extract_tb(exc[2]) - lineno, func = stack[-1][1:3] + + # find the right frame of the stack + for frame in reversed(stack): + if frame[0] == self.name: + lineno, func = frame[1:3] + break + else: + # couldn't even find the stack frame, wtf. + raise PluginExecutionError("%s: %s" % + (err.__class__.__name__, err)) + execs = [contents for etype, contents, _ in self.template.stream if etype == self.template.EXEC] @@ -129,18 +170,20 @@ class CfgGenshiGenerator(CfgGenerator): # else, no EXEC blocks -- WTF? if contents: # we now have the bogus block, but we need to get the - # offending line. To get there, we do (line number - # given in the exception) - (firstlineno from the - # internal genshi code object of the snippet) + 1 = - # (line number of the line with an error within the - # block, with all multiple line breaks elided to a - # single line break) - real_lineno = lineno - contents.code.co_firstlineno - src = re.sub(r'\n\n+', '\n', contents.source).splitlines() + # offending line. To get there, we do (line number given + # in the exception) - (firstlineno from the internal + # genshi code object of the snippet) = (line number of the + # line with an error within the block, with blank lines + # removed as appropriate for + # :attr:`GENSHI_REMOVES_BLANK_LINES`) + code = contents.source.strip().splitlines() + if GENSHI_REMOVES_BLANK_LINES: + code = [l for l in code if l.strip()] try: + line = code[lineno - contents.code.co_firstlineno] raise PluginExecutionError("%s: %s at '%s'" % (err.__class__.__name__, err, - src[real_lineno])) + line)) except IndexError: raise PluginExecutionError("%s: %s" % (err.__class__.__name__, err)) diff --git a/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py b/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py index db6810e7c..f8712213e 100644 --- a/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py +++ b/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py @@ -11,7 +11,8 @@ import Bcfg2.Options import Bcfg2.Server.Plugin import Bcfg2.Server.Lint # pylint: disable=W0622 -from Bcfg2.Compat import u_str, unicode, b64encode, walk_packages, any +from Bcfg2.Compat import u_str, unicode, b64encode, walk_packages, any, \ + oct_mode # pylint: enable=W0622 LOGGER = logging.getLogger(__name__) @@ -538,7 +539,7 @@ class CfgEntrySet(Bcfg2.Server.Plugin.EntrySet): entry.get("name")) fname = os.path.join(self.path, generator.name) entry.set('mode', - str(oct(stat.S_IMODE(os.stat(fname).st_mode)))) + oct_mode(stat.S_IMODE(os.stat(fname).st_mode))) try: return generator.get_data(entry, metadata) except: diff --git a/src/lib/Bcfg2/Server/Plugins/FileProbes.py b/src/lib/Bcfg2/Server/Plugins/FileProbes.py index 8bd1d3504..5ec0d7280 100644 --- a/src/lib/Bcfg2/Server/Plugins/FileProbes.py +++ b/src/lib/Bcfg2/Server/Plugins/FileProbes.py @@ -24,7 +24,7 @@ import sys import pwd import grp import Bcfg2.Client.XML -from Bcfg2.Compat import b64encode +from Bcfg2.Compat import b64encode, oct_mode path = "%s" @@ -41,7 +41,7 @@ data = Bcfg2.Client.XML.Element("ProbedFileData", name=path, owner=pwd.getpwuid(stat[4])[0], group=grp.getgrgid(stat[5])[0], - mode=oct(stat[0] & 4095)) + mode=oct_mode(stat[0] & 4095)) try: data.text = b64encode(open(path).read()) except: @@ -101,7 +101,7 @@ class FileProbes(Bcfg2.Server.Plugin.Plugin, for data in datalist: if data.text is None: - self.logger.error("Got null response to %s file probe from %s" + self.logger.error("Got null response to %s file probe from %s" % (data.get('name'), metadata.hostname)) else: try: diff --git a/src/lib/Bcfg2/Server/Plugins/SEModules.py b/src/lib/Bcfg2/Server/Plugins/SEModules.py index 3edfb72a3..fa47f9496 100644 --- a/src/lib/Bcfg2/Server/Plugins/SEModules.py +++ b/src/lib/Bcfg2/Server/Plugins/SEModules.py @@ -40,8 +40,8 @@ class SEModules(Bcfg2.Server.Plugin.GroupSpool): #: objects as its EntrySet children. es_child_cls = SEModuleData - #: SEModules manages ``SELinux`` entries - entry_type = 'SELinux' + #: SEModules manages ``SEModule`` entries + entry_type = 'SEModule' #: The SEModules plugin is experimental experimental = True @@ -68,7 +68,7 @@ class SEModules(Bcfg2.Server.Plugin.GroupSpool): return name.lstrip("/") def HandlesEntry(self, entry, metadata): - if entry.tag in self.Entries and entry.get('type') == 'module': + if entry.tag in self.Entries: return self._get_module_filename(entry) in self.Entries[entry.tag] return Bcfg2.Server.Plugin.GroupSpool.HandlesEntry(self, entry, metadata) diff --git a/src/lib/Bcfg2/Server/Plugins/SSLCA.py b/src/lib/Bcfg2/Server/Plugins/SSLCA.py index b3a49c047..f83c04e87 100644 --- a/src/lib/Bcfg2/Server/Plugins/SSLCA.py +++ b/src/lib/Bcfg2/Server/Plugins/SSLCA.py @@ -3,253 +3,164 @@ certificates and their keys. """ import os import sys -import Bcfg2.Server.Plugin -import Bcfg2.Options -import lxml.etree +import logging import tempfile +import lxml.etree from subprocess import Popen, PIPE, STDOUT -from Bcfg2.Compat import ConfigParser, md5 +import Bcfg2.Options +import Bcfg2.Server.Plugin +from Bcfg2.Compat import ConfigParser from Bcfg2.Server.Plugin import PluginExecutionError +LOGGER = logging.getLogger(__name__) -class SSLCA(Bcfg2.Server.Plugin.GroupSpool): - """ The SSLCA generator handles the creation and management of ssl - certificates and their keys. """ - __author__ = 'g.hagger@gmail.com' - __child__ = Bcfg2.Server.Plugin.FileBacked - key_specs = {} - cert_specs = {} - CAs = {} - def __init__(self, core, datastore): - Bcfg2.Server.Plugin.GroupSpool.__init__(self, core, datastore) - self.infoxml = dict() +class SSLCAXMLSpec(Bcfg2.Server.Plugin.StructFile): + """ Base class to handle key.xml and cert.xml """ + attrs = dict() + tag = None + + def get_spec(self, metadata): + """ Get a specification for the type of object described by + this SSLCA XML file for the given client metadata object """ + entries = [e for e in self.Match(metadata) if e.tag == self.tag] + if len(entries) == 0: + raise PluginExecutionError("No matching %s entry found for %s " + "in %s" % (self.tag, + metadata.hostname, + self.name)) + elif len(entries) > 1: + LOGGER.warning("More than one matching %s entry found for %s in " + "%s; using first match" % (self.tag, + metadata.hostname, + self.name)) + rv = dict() + for attr, default in self.attrs.items(): + val = entries[0].get(attr.lower(), default) + if default in ['true', 'false']: + rv[attr] = val == 'true' + else: + rv[attr] = val + return rv + + +class SSLCAKeySpec(SSLCAXMLSpec): + """ Handle key.xml files """ + attrs = dict(bits='2048', type='rsa') + tag = 'Key' - def HandleEvent(self, event=None): - """ - Updates which files this plugin handles based upon filesystem events. - Allows configuration items to be added/removed without server restarts. - """ - action = event.code2str() - if event.filename[0] == '/': - return - epath = "".join([self.data, self.handles[event.requestID], - event.filename]) - if os.path.isdir(epath): - ident = self.handles[event.requestID] + event.filename - else: - ident = self.handles[event.requestID][:-1] - fname = os.path.join(ident, event.filename) +class SSLCACertSpec(SSLCAXMLSpec): + """ Handle cert.xml files """ + attrs = dict(ca='default', + format='pem', + key=None, + days='365', + C=None, + L=None, + ST=None, + OU=None, + O=None, + emailAddress=None, + append_chain='false') + tag = 'Cert' - if event.filename.endswith('.xml'): + def get_spec(self, metadata): + rv = SSLCAXMLSpec.get_spec(self, metadata) + rv['subjectaltname'] = [e.text for e in self.Match(metadata) + if e.tag == "SubjectAltName"] + return rv + + +class SSLCADataFile(Bcfg2.Server.Plugin.SpecificData): + """ Handle key and cert files """ + def bind_entry(self, entry, _): + """ Bind the data in the file to the given abstract entry """ + entry.text = self.data + return entry + + +class SSLCAEntrySet(Bcfg2.Server.Plugin.EntrySet): + """ Entry set to handle SSLCA entries and XML files """ + def __init__(self, _, path, entry_type, encoding, parent=None): + Bcfg2.Server.Plugin.EntrySet.__init__(self, os.path.basename(path), + path, entry_type, encoding) + self.parent = parent + self.key = None + self.cert = None + + def handle_event(self, event): + action = event.code2str() + fpath = os.path.join(self.path, event.filename) + + if event.filename == 'key.xml': + if action in ['exists', 'created', 'changed']: + self.key = SSLCAKeySpec(fpath) + self.key.HandleEvent(event) + elif event.filename == 'cert.xml': if action in ['exists', 'created', 'changed']: - if event.filename.endswith('key.xml'): - key_spec = lxml.etree.parse(epath, - parser=Bcfg2.Server.XMLParser - ).find('Key') - self.key_specs[ident] = { - 'bits': key_spec.get('bits', '2048'), - 'type': key_spec.get('type', 'rsa') - } - self.Entries['Path'][ident] = self.get_key - elif event.filename.endswith('cert.xml'): - cert_spec = lxml.etree.parse(epath, - parser=Bcfg2.Server.XMLParser - ).find('Cert') - ca = cert_spec.get('ca', 'default') - self.cert_specs[ident] = { - 'ca': ca, - 'format': cert_spec.get('format', 'pem'), - 'key': cert_spec.get('key'), - 'days': cert_spec.get('days', '365'), - 'C': cert_spec.get('c'), - 'L': cert_spec.get('l'), - 'ST': cert_spec.get('st'), - 'OU': cert_spec.get('ou'), - 'O': cert_spec.get('o'), - 'emailAddress': cert_spec.get('emailaddress'), - 'append_chain': - cert_spec.get('append_chain', - 'false').lower() == 'true', - } - self.CAs[ca] = dict(self.core.setup.cfp.items('sslca_%s' % - ca)) - self.Entries['Path'][ident] = self.get_cert - elif event.filename.endswith("info.xml"): - self.infoxml[ident] = Bcfg2.Server.Plugin.InfoXML(epath) - self.infoxml[ident].HandleEvent(event) - if action == 'deleted': - if ident in self.Entries['Path']: - del self.Entries['Path'][ident] + self.cert = SSLCACertSpec(fpath) + self.cert.HandleEvent(event) else: - if action in ['exists', 'created']: - if os.path.isdir(epath): - self.AddDirectoryMonitor(epath[len(self.data):]) - if ident not in self.entries and os.path.isfile(epath): - self.entries[fname] = self.__child__(epath) - self.entries[fname].HandleEvent(event) - if action == 'changed': - self.entries[fname].HandleEvent(event) - elif action == 'deleted': - if fname in self.entries: - del self.entries[fname] - else: - self.entries[fname].HandleEvent(event) - - def get_key(self, entry, metadata): + Bcfg2.Server.Plugin.EntrySet.handle_event(self, event) + + def build_key(self, entry, metadata): """ either grabs a prexisting key hostfile, or triggers the generation of a new key if one doesn't exist. """ - # check if we already have a hostfile, or need to generate a new key # TODO: verify key fits the specs - path = entry.get('name') - filename = os.path.join(path, "%s.H_%s" % (os.path.basename(path), - metadata.hostname)) - if filename not in list(self.entries.keys()): - self.logger.info("SSLCA: Generating new key %s" % filename) - key = self.build_key(entry) - open(self.data + filename, 'w').write(key) - entry.text = key - self.entries[filename] = self.__child__(self.data + filename) - self.entries[filename].HandleEvent() - else: - entry.text = self.entries[filename].data - - entry.set("type", "file") - if path in self.infoxml: - Bcfg2.Server.Plugin.bind_info(entry, metadata, - infoxml=self.infoxml[path]) - else: - Bcfg2.Server.Plugin.bind_info(entry, metadata) - - def build_key(self, entry): - """ generates a new key according the the specification """ - ktype = self.key_specs[entry.get('name')]['type'] - bits = self.key_specs[entry.get('name')]['bits'] + filename = "%s.H_%s" % (os.path.basename(entry.get('name')), + metadata.hostname) + self.logger.info("SSLCA: Generating new key %s" % filename) + key_spec = self.key.get_spec(metadata) + ktype = key_spec['type'] + bits = key_spec['bits'] if ktype == 'rsa': cmd = ["openssl", "genrsa", bits] elif ktype == 'dsa': cmd = ["openssl", "dsaparam", "-noout", "-genkey", bits] self.debug_log("SSLCA: Generating new key: %s" % " ".join(cmd)) - return Popen(cmd, stdout=PIPE).stdout.read() - - def get_cert(self, entry, metadata): - """ - either grabs a prexisting cert hostfile, or triggers the generation - of a new cert if one doesn't exist. - """ - path = entry.get('name') - filename = os.path.join(path, "%s.H_%s" % (os.path.basename(path), - metadata.hostname)) - - # first - ensure we have a key to work with - key = self.cert_specs[entry.get('name')].get('key') - key_filename = os.path.join(key, "%s.H_%s" % (os.path.basename(key), - metadata.hostname)) - if key_filename not in self.entries: - el = lxml.etree.Element('Path') - el.set('name', key) - self.core.Bind(el, metadata) - - # check if we have a valid hostfile - if (filename in self.entries.keys() and - self.verify_cert(filename, key_filename, entry)): - entry.text = self.entries[filename].data - else: - self.logger.info("SSLCA: Generating new cert %s" % filename) - cert = self.build_cert(key_filename, entry, metadata) - open(self.data + filename, 'w').write(cert) - self.entries[filename] = self.__child__(self.data + filename) - self.entries[filename].HandleEvent() - entry.text = cert - - entry.set("type", "file") - if path in self.infoxml: - Bcfg2.Server.Plugin.bind_info(entry, metadata, - infoxml=self.infoxml[path]) - else: - Bcfg2.Server.Plugin.bind_info(entry, metadata) - - def verify_cert(self, filename, key_filename, entry): - """ Perform certification verification against the CA and - against the key """ - ca = self.CAs[self.cert_specs[entry.get('name')]['ca']] - do_verify = ca.get('chaincert') - if do_verify: - return (self.verify_cert_against_ca(filename, entry) and - self.verify_cert_against_key(filename, key_filename)) - return True - - def verify_cert_against_ca(self, filename, entry): - """ - check that a certificate validates against the ca cert, - and that it has not expired. - """ - ca = self.CAs[self.cert_specs[entry.get('name')]['ca']] - chaincert = ca.get('chaincert') - cert = self.data + filename - cmd = ["openssl", "verify"] - is_root = ca.get('root_ca', "false").lower() == 'true' - if is_root: - cmd.append("-CAfile") - else: - # verifying based on an intermediate cert - cmd.extend(["-purpose", "sslserver", "-untrusted"]) - cmd.extend([chaincert, cert]) - self.debug_log("SSLCA: Verifying %s against CA: %s" % - (entry.get("name"), " ".join(cmd))) - res = Popen(cmd, stdout=PIPE, stderr=STDOUT).stdout.read() - if res == cert + ": OK\n": - self.debug_log("SSLCA: %s verified successfully against CA" % - entry.get("name")) - return True - self.logger.warning("SSLCA: %s failed verification against CA: %s" % - (entry.get("name"), res)) - return False - - def verify_cert_against_key(self, filename, key_filename): - """ - check that a certificate validates against its private key. - """ - cert = self.data + filename - key = self.data + key_filename - cert_md5 = \ - md5(Popen(["openssl", "x509", "-noout", "-modulus", "-in", cert], - stdout=PIPE, - stderr=STDOUT).stdout.read().strip()).hexdigest() - key_md5 = \ - md5(Popen(["openssl", "rsa", "-noout", "-modulus", "-in", key], - stdout=PIPE, - stderr=STDOUT).stdout.read().strip()).hexdigest() - if cert_md5 == key_md5: - self.debug_log("SSLCA: %s verified successfully against key %s" % - (filename, key_filename)) - return True - self.logger.warning("SSLCA: %s failed verification against key %s" % - (filename, key_filename)) - return False + proc = Popen(cmd, stdout=PIPE, stderr=PIPE) + key, err = proc.communicate() + if proc.wait(): + raise PluginExecutionError("SSLCA: Failed to generate key %s for " + "%s: %s" % (entry.get("name"), + metadata.hostname, err)) + open(os.path.join(self.path, filename), 'w').write(key) + return key - def build_cert(self, key_filename, entry, metadata): - """ - creates a new certificate according to the specification - """ + def build_cert(self, entry, metadata, keyfile): + """ generate a new cert """ + filename = "%s.H_%s" % (os.path.basename(entry.get('name')), + metadata.hostname) + self.logger.info("SSLCA: Generating new cert %s" % filename) + cert_spec = self.cert.get_spec(metadata) + ca = self.parent.get_ca(cert_spec['ca']) req_config = None req = None try: - req_config = self.build_req_config(entry, metadata) - req = self.build_request(key_filename, req_config, entry) - ca = self.cert_specs[entry.get('name')]['ca'] - ca_config = self.CAs[ca]['config'] - days = self.cert_specs[entry.get('name')]['days'] - passphrase = self.CAs[ca].get('passphrase') - cmd = ["openssl", "ca", "-config", ca_config, "-in", req, + req_config = self.build_req_config(metadata) + req = self.build_request(keyfile, req_config, metadata) + days = cert_spec['days'] + cmd = ["openssl", "ca", "-config", ca['config'], "-in", req, "-days", days, "-batch"] + passphrase = ca.get('passphrase') if passphrase: cmd.extend(["-passin", "pass:%s" % passphrase]) + + def _scrub_pass(arg): + """ helper to scrub the passphrase from the + argument list """ + if arg.startswith("pass:"): + return "pass:******" + else: + return arg + else: + _scrub_pass = lambda a: a + self.debug_log("SSLCA: Generating new certificate: %s" % - " ".join(cmd)) + " ".join(_scrub_pass(a) for a in cmd)) proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) (cert, err) = proc.communicate() if proc.wait(): @@ -266,12 +177,13 @@ class SSLCA(Bcfg2.Server.Plugin.GroupSpool): except OSError: self.logger.error("SSLCA: Failed to unlink temporary files: %s" % sys.exc_info()[1]) - if (self.cert_specs[entry.get('name')]['append_chain'] and - self.CAs[ca]['chaincert']): - cert += open(self.CAs[ca]['chaincert']).read() + if cert_spec['append_chain'] and 'chaincert' in ca: + cert += open(self.parent.get_ca(ca)['chaincert']).read() + + open(os.path.join(self.path, filename), 'w').write(cert) return cert - def build_req_config(self, entry, metadata): + def build_req_config(self, metadata): """ generates a temporary openssl configuration file that is used to generate the required certificate request @@ -298,16 +210,17 @@ class SSLCA(Bcfg2.Server.Plugin.GroupSpool): cfp.add_section(section) for key in defaults[section]: cfp.set(section, key, defaults[section][key]) + cert_spec = self.cert.get_spec(metadata) altnamenum = 1 - altnames = list(metadata.aliases) + altnames = cert_spec['subjectaltname'] + altnames.extend(list(metadata.aliases)) altnames.append(metadata.hostname) for altname in altnames: cfp.set('alt_names', 'DNS.' + str(altnamenum), altname) altnamenum += 1 for item in ['C', 'L', 'ST', 'O', 'OU', 'emailAddress']: - if self.cert_specs[entry.get('name')][item]: - cfp.set('req_distinguished_name', item, - self.cert_specs[entry.get('name')][item]) + if cert_spec[item]: + cfp.set('req_distinguished_name', item, cert_spec[item]) cfp.set('req_distinguished_name', 'CN', metadata.hostname) self.debug_log("SSLCA: Writing temporary request config to %s" % fname) try: @@ -317,16 +230,15 @@ class SSLCA(Bcfg2.Server.Plugin.GroupSpool): "config file: %s" % sys.exc_info()[1]) return fname - def build_request(self, key_filename, req_config, entry): + def build_request(self, keyfile, req_config, metadata): """ creates the certificate request """ fd, req = tempfile.mkstemp() os.close(fd) - days = self.cert_specs[entry.get('name')]['days'] - key = self.data + key_filename + days = self.cert.get_spec(metadata)['days'] cmd = ["openssl", "req", "-new", "-config", req_config, - "-days", days, "-key", key, "-text", "-out", req] + "-days", days, "-key", keyfile, "-text", "-out", req] self.debug_log("SSLCA: Generating new CSR: %s" % " ".join(cmd)) proc = Popen(cmd, stdout=PIPE, stderr=PIPE) err = proc.communicate()[1] @@ -334,3 +246,122 @@ class SSLCA(Bcfg2.Server.Plugin.GroupSpool): raise PluginExecutionError("SSLCA: Failed to generate CSR: %s" % err) return req + + def verify_cert(self, filename, keyfile, entry, metadata): + """ Perform certification verification against the CA and + against the key """ + ca = self.parent.get_ca(self.cert.get_spec(metadata)['ca']) + do_verify = ca.get('chaincert') + if do_verify: + return (self.verify_cert_against_ca(filename, entry, metadata) and + self.verify_cert_against_key(filename, keyfile)) + return True + + def verify_cert_against_ca(self, filename, entry, metadata): + """ + check that a certificate validates against the ca cert, + and that it has not expired. + """ + ca = self.parent.get_ca(self.cert.get_spec(metadata)['ca']) + chaincert = ca.get('chaincert') + cert = os.path.join(self.path, filename) + cmd = ["openssl", "verify"] + is_root = ca.get('root_ca', "false").lower() == 'true' + if is_root: + cmd.append("-CAfile") + else: + # verifying based on an intermediate cert + cmd.extend(["-purpose", "sslserver", "-untrusted"]) + cmd.extend([chaincert, cert]) + self.debug_log("SSLCA: Verifying %s against CA: %s" % + (entry.get("name"), " ".join(cmd))) + res = Popen(cmd, stdout=PIPE, stderr=STDOUT).stdout.read() + if res == cert + ": OK\n": + self.debug_log("SSLCA: %s verified successfully against CA" % + entry.get("name")) + return True + self.logger.warning("SSLCA: %s failed verification against CA: %s" % + (entry.get("name"), res)) + return False + + def verify_cert_against_key(self, filename, keyfile): + """ + check that a certificate validates against its private key. + """ + def _modulus(fname, ftype="x509"): + """ get the modulus from the given file """ + cmd = ["openssl", ftype, "-noout", "-modulus", "-in", fname] + self.debug_log("SSLCA: Getting modulus of %s for verification: %s" + % (fname, " ".join(cmd))) + proc = Popen(cmd, stdout=PIPE, stderr=PIPE) + rv, err = proc.communicate() + if proc.wait(): + self.logger.warning("SSLCA: Failed to get modulus of %s: %s" % + (fname, err)) + return rv.strip() # pylint: disable=E1103 + + certfile = os.path.join(self.path, filename) + cert = _modulus(certfile) + key = _modulus(keyfile, ftype="rsa") + if cert == key: + self.debug_log("SSLCA: %s verified successfully against key %s" % + (filename, keyfile)) + return True + self.logger.warning("SSLCA: %s failed verification against key %s" % + (filename, keyfile)) + return False + + def bind_entry(self, entry, metadata): + if self.key: + self.bind_info_to_entry(entry, metadata) + try: + return self.best_matching(metadata).bind_entry(entry, metadata) + except PluginExecutionError: + entry.text = self.build_key(entry, metadata) + return entry + elif self.cert: + key = self.cert.get_spec(metadata)['key'] + cleanup_keyfile = False + try: + keyfile = self.parent.entries[key].best_matching(metadata).name + except PluginExecutionError: + cleanup_keyfile = True + # create a temp file with the key in it + fd, keyfile = tempfile.mkstemp() + os.chmod(keyfile, 384) # 0600 + el = lxml.etree.Element('Path', name=key) + self.parent.core.Bind(el, metadata) + os.fdopen(fd, 'w').write(el.text) + + try: + self.bind_info_to_entry(entry, metadata) + try: + best = self.best_matching(metadata) + if self.verify_cert(best.name, keyfile, entry, metadata): + return best.bind_entry(entry, metadata) + except PluginExecutionError: + pass + # if we get here, it's because either a) there was no best + # matching entry; or b) the existing cert did not verify + entry.text = self.build_cert(entry, metadata, keyfile) + return entry + finally: + if cleanup_keyfile: + try: + os.unlink(keyfile) + except OSError: + err = sys.exc_info()[1] + self.logger.error("SSLCA: Failed to unlink temporary " + "key %s: %s" % (keyfile, err)) + + +class SSLCA(Bcfg2.Server.Plugin.GroupSpool): + """ The SSLCA generator handles the creation and management of ssl + certificates and their keys. """ + __author__ = 'g.hagger@gmail.com' + es_cls = lambda self, *args: SSLCAEntrySet(*args, parent=self) + es_child_cls = SSLCADataFile + + def get_ca(self, name): + """ get a dict describing a CA from the config file """ + return dict(self.core.setup.cfp.items("sslca_%s" % name)) diff --git a/src/lib/Bcfg2/version.py b/src/lib/Bcfg2/version.py index b4ac47769..f83863cce 100644 --- a/src/lib/Bcfg2/version.py +++ b/src/lib/Bcfg2/version.py @@ -29,8 +29,8 @@ class Bcfg2VersionInfo(tuple): tuple(self) def __repr__(self): - return "(major=%s, minor=%s, micro=%s, releaselevel=%s, serial=%s)" % \ - tuple(self) + return "%s(major=%s, minor=%s, micro=%s, releaselevel=%s, serial=%s)" \ + % ((self.__class__.__name__,) + tuple(self)) def _release_cmp(self, rel1, rel2): # pylint: disable=R0911 """ compare two release numbers """ diff --git a/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIX/Test__init.py b/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIX/Test__init.py index e503ebd38..4048be7ca 100644 --- a/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIX/Test__init.py +++ b/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIX/Test__init.py @@ -16,12 +16,14 @@ while path != "/": path = os.path.dirname(path) from common import * + def get_config(entries): config = lxml.etree.Element("Configuration") bundle = lxml.etree.SubElement(config, "Bundle", name="test") bundle.extend(entries) return config + def get_posix_object(logger=None, setup=None, config=None): if config is None: config = lxml.etree.Element("Configuration") @@ -36,7 +38,7 @@ def get_posix_object(logger=None, setup=None, config=None): if not setup: setup = MagicMock() return Bcfg2.Client.Tools.POSIX.POSIX(logger, setup, config) - + class TestPOSIX(Bcfg2TestCase): def setUp(self): @@ -55,7 +57,7 @@ class TestPOSIX(Bcfg2TestCase): self.assertGreater(len(posix.__req__['Path']), 0) self.assertGreater(len(posix.__handles__), 0) self.assertItemsEqual(posix.handled, entries) - + @patch("Bcfg2.Client.Tools.Tool.canVerify") def test_canVerify(self, mock_canVerify): entry = lxml.etree.Element("Path", name="test", type="file") @@ -64,7 +66,7 @@ class TestPOSIX(Bcfg2TestCase): mock_canVerify.return_value = False self.assertFalse(self.posix.canVerify(entry)) mock_canVerify.assert_called_with(self.posix, entry) - + # next, test fully_specified failure self.posix.logger.error.reset_mock() mock_canVerify.reset_mock() @@ -77,7 +79,7 @@ class TestPOSIX(Bcfg2TestCase): mock_canVerify.assert_called_with(self.posix, entry) mock_fully_spec.assert_called_with(entry) self.assertTrue(self.posix.logger.error.called) - + # finally, test success self.posix.logger.error.reset_mock() mock_canVerify.reset_mock() @@ -96,7 +98,7 @@ class TestPOSIX(Bcfg2TestCase): mock_canInstall.return_value = False self.assertFalse(self.posix.canInstall(entry)) mock_canInstall.assert_called_with(self.posix, entry) - + # next, test fully_specified failure self.posix.logger.error.reset_mock() mock_canInstall.reset_mock() @@ -109,7 +111,7 @@ class TestPOSIX(Bcfg2TestCase): mock_canInstall.assert_called_with(self.posix, entry) mock_fully_spec.assert_called_with(entry) self.assertTrue(self.posix.logger.error.called) - + # finally, test success self.posix.logger.error.reset_mock() mock_canInstall.reset_mock() @@ -177,7 +179,7 @@ class TestPOSIX(Bcfg2TestCase): posix._prune_old_backups(entry) mock_listdir.assert_called_with(setup['ppath']) - self.assertItemsEqual(mock_remove.call_args_list, + self.assertItemsEqual(mock_remove.call_args_list, [call(os.path.join(setup['ppath'], p)) for p in remove]) @@ -189,7 +191,7 @@ class TestPOSIX(Bcfg2TestCase): # need to be removed even if we get an error posix._prune_old_backups(entry) mock_listdir.assert_called_with(setup['ppath']) - self.assertItemsEqual(mock_remove.call_args_list, + self.assertItemsEqual(mock_remove.call_args_list, [call(os.path.join(setup['ppath'], p)) for p in remove]) self.assertTrue(posix.logger.error.called) @@ -203,7 +205,7 @@ class TestPOSIX(Bcfg2TestCase): entry = lxml.etree.Element("Path", name="/etc/foo", type="file") setup = dict(ppath='/', max_copies=5, paranoid=False) posix = get_posix_object(setup=setup) - + # paranoid false globally posix._paranoid_backup(entry) self.assertFalse(mock_prune.called) diff --git a/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIXUsers.py b/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIXUsers.py new file mode 100644 index 000000000..46ae4e47b --- /dev/null +++ b/testsuite/Testsrc/Testlib/TestClient/TestTools/TestPOSIXUsers.py @@ -0,0 +1,489 @@ +import os +import sys +import copy +import lxml.etree +import subprocess +from mock import Mock, MagicMock, patch +import Bcfg2.Client.Tools +from Bcfg2.Client.Tools.POSIXUsers import * + +# 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 * + + +class TestExecutor(Bcfg2TestCase): + test_obj = Executor + + def get_obj(self, logger=None): + if not logger: + def print_msg(msg): + print(msg) + logger = Mock() + logger.error = Mock(side_effect=print_msg) + logger.warning = Mock(side_effect=print_msg) + logger.info = Mock(side_effect=print_msg) + logger.debug = Mock(side_effect=print_msg) + return self.test_obj(logger) + + @patch("subprocess.Popen") + def test_run(self, mock_Popen): + exc = self.get_obj() + cmd = ["/bin/test", "-a", "foo"] + proc = Mock() + proc.wait = Mock() + proc.wait.return_value = 0 + proc.communicate = Mock() + proc.communicate.return_value = (MagicMock(), MagicMock()) + mock_Popen.return_value = proc + + self.assertTrue(exc.run(cmd)) + args = mock_Popen.call_args + self.assertEqual(args[0][0], cmd) + self.assertEqual(args[1]['shell'], False) + self.assertEqual(args[1]['stdin'], subprocess.PIPE) + self.assertEqual(args[1]['stdout'], subprocess.PIPE) + self.assertEqual(args[1]['stderr'], subprocess.PIPE) + proc.communicate.assert_called_with() + proc.wait.assert_called_with() + self.assertEqual(proc.communicate.return_value, + (exc.stdout, exc.stderr)) + self.assertEqual(proc.wait.return_value, + exc.retval) + + mock_Popen.reset_mock() + inputdata = "foo\n\nbar" + self.assertTrue(exc.run(cmd, inputdata=inputdata, shell=True)) + args = mock_Popen.call_args + self.assertEqual(args[0][0], cmd) + self.assertEqual(args[1]['shell'], True) + self.assertEqual(args[1]['stdin'], subprocess.PIPE) + self.assertEqual(args[1]['stdout'], subprocess.PIPE) + self.assertEqual(args[1]['stderr'], subprocess.PIPE) + proc.communicate.assert_called_with(inputdata) + proc.wait.assert_called_with() + self.assertEqual(proc.communicate.return_value, + (exc.stdout, exc.stderr)) + self.assertEqual(proc.wait.return_value, + exc.retval) + + mock_Popen.reset_mock() + proc.wait.return_value = 1 + self.assertRaises(ExecutionError, exc.run, cmd) + args = mock_Popen.call_args + self.assertEqual(args[0][0], cmd) + self.assertEqual(args[1]['shell'], False) + self.assertEqual(args[1]['stdin'], subprocess.PIPE) + self.assertEqual(args[1]['stdout'], subprocess.PIPE) + self.assertEqual(args[1]['stderr'], subprocess.PIPE) + proc.communicate.assert_called_with() + proc.wait.assert_called_with() + self.assertEqual(proc.communicate.return_value, + (exc.stdout, exc.stderr)) + self.assertEqual(proc.wait.return_value, + exc.retval) + + +class TestPOSIXUsers(Bcfg2TestCase): + test_obj = POSIXUsers + + def get_obj(self, logger=None, setup=None, config=None): + if config is None: + config = lxml.etree.Element("Configuration") + if not logger: + def print_msg(msg): + print(msg) + logger = Mock() + logger.error = Mock(side_effect=print_msg) + logger.warning = Mock(side_effect=print_msg) + logger.info = Mock(side_effect=print_msg) + logger.debug = Mock(side_effect=print_msg) + if not setup: + setup = MagicMock() + return self.test_obj(logger, setup, config) + + @patch("pwd.getpwall") + @patch("grp.getgrall") + def test_existing(self, mock_getgrall, mock_getpwall): + users = self.get_obj() + mock_getgrall.return_value = MagicMock() + mock_getpwall.return_value = MagicMock() + + def reset(): + mock_getgrall.reset_mock() + mock_getpwall.reset_mock() + + # make sure we start clean + self.assertIsNone(users._existing) + self.assertIsInstance(users.existing, dict) + self.assertIn("POSIXUser", users.existing) + self.assertIn("POSIXGroup", users.existing) + mock_getgrall.assert_called_with() + mock_getpwall.assert_called_with() + + reset() + self.assertIsInstance(users._existing, dict) + self.assertIsInstance(users.existing, dict) + self.assertEqual(users.existing, users._existing) + self.assertIn("POSIXUser", users.existing) + self.assertIn("POSIXGroup", users.existing) + self.assertFalse(mock_getgrall.called) + self.assertFalse(mock_getpwall.called) + + reset() + users._existing = None + self.assertIsInstance(users.existing, dict) + self.assertIn("POSIXUser", users.existing) + self.assertIn("POSIXGroup", users.existing) + mock_getgrall.assert_called_with() + mock_getpwall.assert_called_with() + + @patch("Bcfg2.Client.Tools.Tool.Inventory") + def test_Inventory(self, mock_Inventory): + config = lxml.etree.Element("Configuration") + bundle = lxml.etree.SubElement(config, "Bundle", name="test") + lxml.etree.SubElement(bundle, "POSIXUser", name="test", group="test") + lxml.etree.SubElement(bundle, "POSIXUser", name="test2", group="test2") + lxml.etree.SubElement(bundle, "POSIXGroup", name="test2") + + orig_bundle = copy.deepcopy(bundle) + + users = self.get_obj(config=config) + users.set_defaults['POSIXUser'] = Mock() + users.set_defaults['POSIXUser'].side_effect = lambda e: e + + states = dict() + self.assertEqual(users.Inventory(states), + mock_Inventory.return_value) + mock_Inventory.assert_called_with(users, states, config.getchildren()) + lxml.etree.SubElement(orig_bundle, "POSIXGroup", name="test") + self.assertXMLEqual(orig_bundle, bundle) + + def test_FindExtra(self): + users = self.get_obj() + + def getSupportedEntries(): + return [lxml.etree.Element("POSIXUser", name="test1"), + lxml.etree.Element("POSIXGroup", name="test1")] + + users.getSupportedEntries = Mock() + users.getSupportedEntries.side_effect = getSupportedEntries + + users._existing = dict(POSIXUser=dict(test1=(), + test2=()), + POSIXGroup=dict(test2=())) + extra = users.FindExtra() + self.assertEqual(len(extra), 2) + self.assertItemsEqual([e.tag for e in extra], + ["POSIXUser", "POSIXGroup"]) + self.assertItemsEqual([e.get("name") for e in extra], + ["test2", "test2"]) + + def test_populate_user_entry(self): + users = self.get_obj() + users._existing = dict(POSIXUser=dict(), + POSIXGroup=dict(root=('root', 'x', 0, []))) + + cases = [(lxml.etree.Element("POSIXUser", name="test"), + lxml.etree.Element("POSIXUser", name="test", group="test", + gecos="test", shell="/bin/bash", + home="/home/test")), + (lxml.etree.Element("POSIXUser", name="root", gecos="Root", + shell="/bin/zsh"), + lxml.etree.Element("POSIXUser", name="root", group='root', + gid='0', gecos="Root", shell="/bin/zsh", + home='/root')), + (lxml.etree.Element("POSIXUser", name="test2", gecos="", + shell="/bin/zsh"), + lxml.etree.Element("POSIXUser", name="test2", group='test2', + gecos="", shell="/bin/zsh", + home='/home/test2'))] + + for initial, expected in cases: + actual = users.populate_user_entry(initial) + self.assertXMLEqual(actual, expected) + + def test_user_supplementary_groups(self): + users = self.get_obj() + users._existing = \ + dict(POSIXUser=dict(), + POSIXGroup=dict(root=('root', 'x', 0, []), + wheel=('wheel', 'x', 10, ['test']), + users=('users', 'x', 100, ['test']))) + entry = lxml.etree.Element("POSIXUser", name="test") + self.assertItemsEqual(users.user_supplementary_groups(entry), + [users.existing['POSIXGroup']['wheel'], + users.existing['POSIXGroup']['users']]) + entry.set('name', 'test2') + self.assertItemsEqual(users.user_supplementary_groups(entry), []) + + def test_VerifyPOSIXUser(self): + users = self.get_obj() + users._verify = Mock() + users._verify.return_value = True + users.populate_user_entry = Mock() + users.user_supplementary_groups = Mock() + users.user_supplementary_groups.return_value = \ + [('wheel', 'x', 10, ['test']), ('users', 'x', 100, ['test'])] + + def reset(): + users._verify.reset_mock() + users.populate_user_entry.reset_mock() + users.user_supplementary_groups.reset_mock() + + entry = lxml.etree.Element("POSIXUser", name="test") + self.assertFalse(users.VerifyPOSIXUser(entry, [])) + users.populate_user_entry.assert_called_with(entry) + users._verify.assert_called_with(users.populate_user_entry.return_value) + users.user_supplementary_groups.assert_called_with(entry) + + reset() + m1 = lxml.etree.SubElement(entry, "MemberOf") + m1.text = "wheel" + m2 = lxml.etree.SubElement(entry, "MemberOf") + m2.text = "users" + self.assertTrue(users.VerifyPOSIXUser(entry, [])) + users.populate_user_entry.assert_called_with(entry) + users._verify.assert_called_with(users.populate_user_entry.return_value) + users.user_supplementary_groups.assert_called_with(entry) + + reset() + m3 = lxml.etree.SubElement(entry, "MemberOf") + m3.text = "extra" + self.assertFalse(users.VerifyPOSIXUser(entry, [])) + users.populate_user_entry.assert_called_with(entry) + users._verify.assert_called_with(users.populate_user_entry.return_value) + users.user_supplementary_groups.assert_called_with(entry) + + reset() + def _verify(entry): + entry.set("current_exists", "false") + return False + + users._verify.side_effect = _verify + self.assertFalse(users.VerifyPOSIXUser(entry, [])) + users.populate_user_entry.assert_called_with(entry) + users._verify.assert_called_with(users.populate_user_entry.return_value) + + def test_VerifyPOSIXGroup(self): + users = self.get_obj() + users._verify = Mock() + entry = lxml.etree.Element("POSIXGroup", name="test") + self.assertEqual(users._verify.return_value, + users.VerifyPOSIXGroup(entry, [])) + + def test__verify(self): + users = self.get_obj() + users._existing = \ + dict(POSIXUser=dict(test=('test', 'x', 1000, 1000, 'Test McTest', + '/home/test', '/bin/zsh')), + POSIXGroup=dict(test=('test', 'x', 1000, []))) + + entry = lxml.etree.Element("POSIXUser", name="nonexistent") + self.assertFalse(users._verify(entry)) + self.assertEqual(entry.get("current_exists"), "false") + + entry = lxml.etree.Element("POSIXUser", name="test", group="test", + gecos="Bogus", shell="/bin/bash", + home="/home/test") + self.assertFalse(users._verify(entry)) + + entry = lxml.etree.Element("POSIXUser", name="test", group="test", + gecos="Test McTest", shell="/bin/zsh", + home="/home/test") + self.assertTrue(users._verify(entry)) + + entry = lxml.etree.Element("POSIXUser", name="test", group="test", + gecos="Test McTest", shell="/bin/zsh", + home="/home/test", uid="1000", gid="1000") + self.assertTrue(users._verify(entry)) + + entry = lxml.etree.Element("POSIXUser", name="test", group="test", + gecos="Test McTest", shell="/bin/zsh", + home="/home/test", uid="1001") + self.assertFalse(users._verify(entry)) + + def test_Install(self): + users = self.get_obj() + users._install = Mock() + users._existing = MagicMock() + + + entries = [lxml.etree.Element("POSIXUser", name="test"), + lxml.etree.Element("POSIXGroup", name="test"), + lxml.etree.Element("POSIXUser", name="test2")] + states = dict() + + users.Install(entries, states) + self.assertItemsEqual(entries, states.keys()) + for state in states.values(): + self.assertEqual(state, users._install.return_value) + # need to verify two things about _install calls: + # 1) _install was called for each entry; + # 2) _install was called for all groups before any users + self.assertItemsEqual(users._install.call_args_list, + [call(e) for e in entries]) + users_started = False + for args in users._install.call_args_list: + if args[0][0].tag == "POSIXUser": + users_started = True + elif users_started: + assert False, "_install() called on POSIXGroup after installing one or more POSIXUsers" + + def test__install(self): + users = self.get_obj() + users._get_cmd = Mock() + users.cmd = Mock() + users.set_defaults = dict(POSIXUser=Mock(), POSIXGroup=Mock()) + users._existing = \ + dict(POSIXUser=dict(test=('test', 'x', 1000, 1000, 'Test McTest', + '/home/test', '/bin/zsh')), + POSIXGroup=dict(test=('test', 'x', 1000, []))) + + def reset(): + users._get_cmd.reset_mock() + users.cmd.reset_mock() + for setter in users.set_defaults.values(): + setter.reset_mock() + users.modified = [] + + reset() + entry = lxml.etree.Element("POSIXUser", name="test2") + self.assertTrue(users._install(entry)) + users.set_defaults[entry.tag].assert_called_with(entry) + users._get_cmd.assert_called_with("add", + users.set_defaults[entry.tag].return_value) + users.cmd.run.assert_called_with(users._get_cmd.return_value) + self.assertIn(entry, users.modified) + + reset() + entry = lxml.etree.Element("POSIXUser", name="test") + self.assertTrue(users._install(entry)) + users.set_defaults[entry.tag].assert_called_with(entry) + users._get_cmd.assert_called_with("mod", + users.set_defaults[entry.tag].return_value) + users.cmd.run.assert_called_with(users._get_cmd.return_value) + self.assertIn(entry, users.modified) + + reset() + users.cmd.run.side_effect = ExecutionError(None) + self.assertFalse(users._install(entry)) + users.set_defaults[entry.tag].assert_called_with(entry) + users._get_cmd.assert_called_with("mod", + users.set_defaults[entry.tag].return_value) + users.cmd.run.assert_called_with(users._get_cmd.return_value) + self.assertNotIn(entry, users.modified) + + def test__get_cmd(self): + users = self.get_obj() + + entry = lxml.etree.Element("POSIXUser", name="test", group="test", + home="/home/test", shell="/bin/zsh", + gecos="Test McTest") + m1 = lxml.etree.SubElement(entry, "MemberOf") + m1.text = "wheel" + m2 = lxml.etree.SubElement(entry, "MemberOf") + m2.text = "users" + + cases = [(lxml.etree.Element("POSIXGroup", name="test"), []), + (lxml.etree.Element("POSIXGroup", name="test", gid="1001"), + ["-g", "1001"]), + (lxml.etree.Element("POSIXUser", name="test", group="test", + home="/home/test", shell="/bin/zsh", + gecos="Test McTest"), + ["-m", "-g", "test", "-d", "/home/test", "-s", "/bin/zsh", + "-c", "Test McTest"]), + (lxml.etree.Element("POSIXUser", name="test", group="test", + home="/home/test", shell="/bin/zsh", + gecos="Test McTest", uid="1001"), + ["-m", "-u", "1001", "-g", "test", "-d", "/home/test", + "-s", "/bin/zsh", "-c", "Test McTest"]), + (entry, + ["-m", "-g", "test", "-G", "wheel,users", "-d", "/home/test", + "-s", "/bin/zsh", "-c", "Test McTest"])] + for entry, expected in cases: + for action in ["add", "mod", "del"]: + actual = users._get_cmd(action, entry) + if entry.tag == "POSIXGroup": + etype = "group" + else: + etype = "user" + self.assertEqual(actual[0], "/usr/sbin/%s%s" % (etype, action)) + self.assertEqual(actual[-1], entry.get("name")) + if action != "del": + self.assertItemsEqual(actual[1:-1], expected) + + @patch("grp.getgrnam") + def test_Remove(self, mock_getgrnam): + users = self.get_obj() + users._remove = Mock() + users.FindExtra = Mock() + users._existing = MagicMock() + users.extra = MagicMock() + + def reset(): + users._remove.reset_mock() + users.FindExtra.reset_mock() + users._existing = MagicMock() + users.extra = MagicMock() + mock_getgrnam.reset_mock() + + entries = [lxml.etree.Element("POSIXUser", name="test"), + lxml.etree.Element("POSIXGroup", name="test"), + lxml.etree.Element("POSIXUser", name="test2")] + + users.Remove(entries) + self.assertIsNone(users._existing) + users.FindExtra.assert_called_with() + self.assertEqual(users.extra, users.FindExtra.return_value) + mock_getgrnam.assert_called_with("test") + # need to verify two things about _remove calls: + # 1) _remove was called for each entry; + # 2) _remove was called for all users before any groups + self.assertItemsEqual(users._remove.call_args_list, + [call(e) for e in entries]) + groups_started = False + for args in users._remove.call_args_list: + if args[0][0].tag == "POSIXGroup": + groups_started = True + elif groups_started: + assert False, "_remove() called on POSIXUser after removing one or more POSIXGroups" + + reset() + mock_getgrnam.side_effect = KeyError + users.Remove(entries) + self.assertIsNone(users._existing) + users.FindExtra.assert_called_with() + self.assertEqual(users.extra, users.FindExtra.return_value) + mock_getgrnam.assert_called_with("test") + self.assertItemsEqual(users._remove.call_args_list, + [call(e) for e in entries + if e.tag == "POSIXUser"]) + + def test__remove(self): + users = self.get_obj() + users._get_cmd = Mock() + users.cmd = Mock() + + def reset(): + users._get_cmd.reset_mock() + users.cmd.reset_mock() + + + entry = lxml.etree.Element("POSIXUser", name="test2") + self.assertTrue(users._remove(entry)) + users._get_cmd.assert_called_with("del", entry) + users.cmd.run.assert_called_with(users._get_cmd.return_value) + + reset() + users.cmd.run.side_effect = ExecutionError(None) + self.assertFalse(users._remove(entry)) + users._get_cmd.assert_called_with("del", entry) + users.cmd.run.assert_called_with(users._get_cmd.return_value) diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py index 58f844b3b..d6313b073 100644 --- a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py +++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestCfg/Test_init.py @@ -88,7 +88,7 @@ class TestCfgBaseFileMatcher(TestSpecificData): mock_get_regex.reset_mock() match.reset_mock() match.return_value = True - self.assertTrue(self.test_obj.handles(evt)) + self.assertTrue(self.test_obj.handles(evt)) match.assert_called_with(evt.filename) else: match.return_value = False @@ -389,7 +389,7 @@ class TestCfgEntrySet(TestEntrySet): eset.bind_info_to_entry.assert_called_with(entry, metadata) eset._generate_data.assert_called_with(entry, metadata) self.assertFalse(eset._validate_data.called) - expected = lxml.etree.Element("Path", name="/text.txt") + expected = lxml.etree.Element("Path", name="/test.txt") expected.text = "data" self.assertXMLEqual(bound, expected) self.assertEqual(bound, entry) @@ -401,7 +401,7 @@ class TestCfgEntrySet(TestEntrySet): eset.bind_info_to_entry.assert_called_with(entry, metadata) eset._generate_data.assert_called_with(entry, metadata) self.assertFalse(eset._validate_data.called) - expected = lxml.etree.Element("Path", name="/text.txt", empty="true") + expected = lxml.etree.Element("Path", name="/test.txt", empty="true") self.assertXMLEqual(bound, expected) self.assertEqual(bound, entry) @@ -420,7 +420,7 @@ class TestCfgEntrySet(TestEntrySet): filters[1].modify_data.assert_called_with(entry, metadata, "modified data") self.assertFalse(eset._validate_data.called) - expected = lxml.etree.Element("Path", name="/text.txt") + expected = lxml.etree.Element("Path", name="/test.txt") expected.text = "final data" self.assertXMLEqual(bound, expected) @@ -436,7 +436,7 @@ class TestCfgEntrySet(TestEntrySet): self.assertFalse(eset._validate_data.called) mock_b64encode.assert_called_with("data") self.assertFalse(mock_u_str.called) - expected = lxml.etree.Element("Path", name="/text.txt", + expected = lxml.etree.Element("Path", name="/test.txt", encoding="base64") expected.text = "base64 data" self.assertXMLEqual(bound, expected) @@ -449,7 +449,7 @@ class TestCfgEntrySet(TestEntrySet): eset.bind_info_to_entry.assert_called_with(entry, metadata) eset._generate_data.assert_called_with(entry, metadata) eset._validate_data.assert_called_with(entry, metadata, "data") - expected = lxml.etree.Element("Path", name="/text.txt") + expected = lxml.etree.Element("Path", name="/test.txt") expected.text = "data" self.assertXMLEqual(bound, expected) self.assertEqual(bound, entry) diff --git a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestSEModules.py b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestSEModules.py index 8a148b353..66492f8b2 100644 --- a/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestSEModules.py +++ b/testsuite/Testsrc/Testlib/TestServer/TestPlugins/TestSEModules.py @@ -38,34 +38,29 @@ class TestSEModules(TestGroupSpool): def test__get_module_name(self): modules = self.get_obj() for mname in ["foo", "foo.pp"]: - entry = lxml.etree.Element("SELinux", type="module", name=mname) + entry = lxml.etree.Element("SEModule", type="module", name=mname) self.assertEqual(modules._get_module_name(entry), "foo") def test__get_module_filename(self): modules = self.get_obj() for mname in ["foo", "foo.pp"]: - entry = lxml.etree.Element("SELinux", type="module", name=mname) + entry = lxml.etree.Element("SEModule", type="module", name=mname) self.assertEqual(modules._get_module_filename(entry), "/foo.pp") def test_HandlesEntry(self): modules = self.get_obj() modules._get_module_filename = Mock() - modules.Entries['SELinux']['/foo.pp'] = Mock() - modules.Entries['SELinux']['/bar.pp'] = Mock() + modules.Entries['SEModule']['/foo.pp'] = Mock() + modules.Entries['SEModule']['/bar.pp'] = Mock() for el in [lxml.etree.Element("Path", name="foo.pp"), - lxml.etree.Element("SELinux", type="fcontext", - name="foo.pp"), - lxml.etree.Element("SELinux", type="module", - name="baz.pp")]: + lxml.etree.Element("SEModule", name="baz.pp")]: modules._get_module_filename.return_value = "/" + el.get("name") self.assertFalse(modules.HandlesEntry(el, Mock())) - if el.get("type") == "module": + if el.tag == "SEModule": modules._get_module_filename.assert_called_with(el) - for el in [lxml.etree.Element("SELinux", type="module", - name="foo.pp"), - lxml.etree.Element("SELinux", type="module", - name="bar.pp")]: + for el in [lxml.etree.Element("SEModule", name="foo.pp"), + lxml.etree.Element("SEModule", name="bar.pp")]: modules._get_module_filename.return_value = "/" + el.get("name") self.assertTrue(modules.HandlesEntry(el, Mock()), msg="SEModules fails to handle %s" % el.get("name")) @@ -77,10 +72,10 @@ class TestSEModules(TestGroupSpool): modules = self.get_obj() modules._get_module_name = Mock() handler = Mock() - modules.Entries['SELinux']['/foo.pp'] = handler + modules.Entries['SEModule']['/foo.pp'] = handler modules._get_module_name.return_value = "foo" - entry = lxml.etree.Element("SELinux", type="module", name="foo") + entry = lxml.etree.Element("SEModule", type="module", name="foo") metadata = Mock() self.assertEqual(modules.HandleEntry(entry, metadata), handler.return_value) diff --git a/testsuite/common.py b/testsuite/common.py index 0cb457461..e26d0be61 100644 --- a/testsuite/common.py +++ b/testsuite/common.py @@ -267,7 +267,7 @@ class Bcfg2TestCase(unittest.TestCase): attributes. """ self.assertEqual(el1.tag, el2.tag, msg=msg) self.assertEqual(el1.text, el2.text, msg=msg) - self.assertItemsEqual(el1.attrib, el2.attrib, msg=msg) + self.assertItemsEqual(el1.attrib.items(), el2.attrib.items(), msg=msg) self.assertEqual(len(el1.getchildren()), len(el2.getchildren())) for child1 in el1.getchildren(): @@ -275,10 +275,11 @@ class Bcfg2TestCase(unittest.TestCase): self.assertIsNotNone(cname, msg="Element %s has no 'name' attribute" % child1.tag) - children2 = el2.xpath("*[@name='%s']" % cname) + children2 = el2.xpath("%s[@name='%s']" % (child1.tag, cname)) self.assertEqual(len(children2), 1, - msg="More than one element named %s" % cname) - self.assertXMLEqual(child1, children2[0], msg=msg) + msg="More than one %s element named %s" % \ + (child1.tag, cname)) + self.assertXMLEqual(child1, children2[0], msg=msg) class DBModelTestCase(Bcfg2TestCase): diff --git a/tools/README b/tools/README index 400cfc55c..335363898 100644 --- a/tools/README +++ b/tools/README @@ -82,6 +82,10 @@ pkgmgr_update.py - Update Pkgmgr XML files from a list of directories that contain RPMS +posixusers_baseline.py + - Create a Bundle with all base POSIXUser/POSIXGroup entries on a + client. + rpmlisting.py - Generate Pkgmgr XML files for RPM packages diff --git a/tools/posixusers_baseline.py b/tools/posixusers_baseline.py new file mode 100755 index 000000000..a4abca42d --- /dev/null +++ b/tools/posixusers_baseline.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python + +import grp +import sys +import logging +import lxml.etree +import Bcfg2.Logger +from Bcfg2.Client.Tools.POSIXUsers import POSIXUsers +from Bcfg2.Options import OptionParser, Option, get_bool, CLIENT_COMMON_OPTIONS + + +def get_setup(): + optinfo = CLIENT_COMMON_OPTIONS + optinfo['nouids'] = Option("Do not include UID numbers for users", + default=False, + cmd='--no-uids', + long_arg=True, + cook=get_bool) + optinfo['nogids'] = Option("Do not include GID numbers for groups", + default=False, + cmd='--no-gids', + long_arg=True, + cook=get_bool) + setup = OptionParser(optinfo) + setup.parse(sys.argv[1:]) + + if setup['args']: + print("posixuser_[baseline.py takes no arguments, only options") + print(setup.buildHelpMessage()) + raise SystemExit(1) + level = 30 + if setup['verbose']: + level = 20 + if setup['debug']: + level = 0 + Bcfg2.Logger.setup_logging('posixusers_baseline.py', + to_syslog=False, + level=level, + to_file=setup['logging']) + return setup + + +def main(): + setup = get_setup() + if setup['file']: + config = lxml.etree.parse(setup['file']).getroot() + else: + config = lxml.etree.Element("Configuration") + users = POSIXUsers(logging.getLogger('posixusers_baseline.py'), + setup, config) + + baseline = lxml.etree.Element("Bundle", name="posixusers_baseline") + for entry in users.FindExtra(): + data = users.existing[entry.tag][entry.get("name")] + for attr, idx in users.attr_mapping[entry.tag].items(): + if (entry.get(attr) or + (attr == 'uid' and setup['nouids']) or + (attr == 'gid' and setup['nogids'])): + continue + entry.set(attr, str(data[idx])) + if entry.tag == 'POSIXUser': + entry.set("group", grp.getgrgid(data[3])[0]) + for group in users.user_supplementary_groups(entry): + memberof = lxml.etree.SubElement(entry, "MemberOf") + memberof.text = group[0] + + entry.tag = "Bound" + entry.tag + baseline.append(entry) + + print(lxml.etree.tostring(baseline, pretty_print=True)) + +if __name__ == "__main__": + sys.exit(main()) |