#!/usr/bin/python
# -*- coding: utf-8 -*-
# (c) 2017, Kenneth D. Evensen <kevensen@redhat.com>

# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type


ANSIBLE_METADATA = {'metadata_version': '1.1',
                    'status': ['preview'],
                    'supported_by': 'community'}


DOCUMENTATION = """
module: pamd
author:
    - "Kenneth D. Evensen (@kevensen)"
short_description: Manage PAM Modules
description:
  - Edit PAM service's type, control, module path and module arguments.
    In order for a PAM rule to be modified, the type, control and
    module_path must match an existing rule.  See man(5) pam.d for details.
version_added: "2.3"
options:
  name:
    required: true
    description:
      - The name generally refers to the PAM service file to
        change, for example system-auth.
  type:
    required: true
    description:
      - The type of the PAM rule being modified.  The type, control
        and module_path all must match a rule to be modified.
  control:
    required: true
    description:
      - The control of the PAM rule being modified.  This may be a
        complicated control with brackets.  If this is the case, be
        sure to put "[bracketed controls]" in quotes.  The type,
        control and module_path all must match a rule to be modified.
  module_path:
    required: true
    description:
      - The module path of the PAM rule being modified.  The type,
        control and module_path all must match a rule to be modified.
  new_type:
    description:
    - The new type to assign to the new rule.
  new_control:
    description:
    - The new control to assign to the new rule.
  new_module_path:
    description:
    - The new module path to be assigned to the new rule.
  module_arguments:
    description:
    - When state is 'updated', the module_arguments will replace existing
      module_arguments.  When state is 'args_absent' args matching those
      listed in module_arguments will be removed.  When state is
      'args_present' any args listed in module_arguments are added if
      missing from the existing rule.  Furthermore, if the module argument
      takes a value denoted by '=', the value will be changed to that specified
      in module_arguments.  Note that module_arguments is a list.  Please see
      the examples for usage.
  state:
    default: updated
    choices:
    - updated
    - before
    - after
    - args_present
    - args_absent
    - absent
    description:
    - The default of 'updated' will modify an existing rule if type,
      control and module_path all match an existing rule.  With 'before',
      the new rule will be inserted before a rule matching type, control
      and module_path.  Similarly, with 'after', the new rule will be inserted
      after an existing rule matching type, control and module_path.  With
      either 'before' or 'after' new_type, new_control, and new_module_path
      must all be specified.  If state is 'args_absent' or 'args_present',
      new_type, new_control, and new_module_path will be ignored.  State
      'absent' will remove the rule.  The 'absent' state was added in version
      2.4 and is only available in Ansible versions >= 2.4.
  path:
    default: /etc/pam.d/
    description:
    - This is the path to the PAM service files
"""

EXAMPLES = """
- name: Update pamd rule's control in /etc/pam.d/system-auth
  pamd:
    name: system-auth
    type: auth
    control: required
    module_path: pam_faillock.so
    new_control: sufficient

- name: Update pamd rule's complex control in /etc/pam.d/system-auth
  pamd:
    name: system-auth
    type: session
    control: '[success=1 default=ignore]'
    module_path: pam_succeed_if.so
    new_control: '[success=2 default=ignore]'

- name: Insert a new rule before an existing rule
  pamd:
    name: system-auth
    type: auth
    control: required
    module_path: pam_faillock.so
    new_type: auth
    new_control: sufficient
    new_module_path: pam_faillock.so
    state: before

- name: Insert a new rule pam_wheel.so with argument 'use_uid' after an \
        existing rule pam_rootok.so
  pamd:
    name: su
    type: auth
    control: sufficient
    module_path: pam_rootok.so
    new_type: auth
    new_control: required
    new_module_path: pam_wheel.so
    module_arguments: 'use_uid'
    state: after

- name: Remove module arguments from an existing rule
  pamd:
    name: system-auth
    type: auth
    control: required
    module_path: pam_faillock.so
    module_arguments: ''
    state: updated

- name: Replace all module arguments in an existing rule
  pamd:
    name: system-auth
    type: auth
    control: required
    module_path: pam_faillock.so
    module_arguments: 'preauth
        silent
        deny=3
        unlock_time=604800
        fail_interval=900'
    state: updated

- name: Remove specific arguments from a rule
  pamd:
    name: system-auth
    type: session control='[success=1 default=ignore]'
    module_path: pam_succeed_if.so
    module_arguments: crond,quiet
    state: args_absent

- name: Ensure specific arguments are present in a rule
  pamd:
    name: system-auth
    type: session
    control: '[success=1 default=ignore]'
    module_path: pam_succeed_if.so
    module_arguments: crond,quiet
    state: args_present

- name: Ensure specific arguments are present in a rule (alternative)
  pamd:
    name: system-auth
    type: session
    control: '[success=1 default=ignore]'
    module_path: pam_succeed_if.so
    module_arguments:
    - crond
    - quiet
    state: args_present

- name: Module arguments requiring commas must be listed as a Yaml list
  pamd:
    name: special-module
    type: account
    control: required
    module_path: pam_access.so
    module_arguments:
    - listsep=,
    state: args_present

- name: Update specific argument value in a rule
  pamd:
    name: system-auth
    type: auth
    control: required
    module_path: pam_faillock.so
    module_arguments: 'fail_interval=300'
    state: args_present

- name: Add pam common-auth rule for duo
  pamd:
    name: common-auth
    new_type: auth
    new_control: '[success=1 default=ignore]'
    new_module_path: '/lib64/security/pam_duo.so'
    state: after
    type: auth
    module_path: pam_sss.so
    control: 'requisite'
"""

RETURN = '''
change_count:
    description: How many rules were changed
    type: int
    sample: 1
    returned: success
    version_added: 2.4
new_rule:
    description: The changes to the rule
    type: string
    sample: None      None None sha512 shadow try_first_pass use_authtok
    returned: success
    version_added: 2.4
updated_rule_(n):
    description: The rule(s) that was/were changed
    type: string
    sample:
    - password      sufficient  pam_unix.so sha512 shadow try_first_pass
      use_authtok
    returned: success
    version_added: 2.4
action:
    description:
    - "That action that was taken and is one of: update_rule,
      insert_before_rule, insert_after_rule, args_present, args_absent,
      absent."
    returned: always
    type: string
    sample: "update_rule"
    version_added: 2.4
dest:
    description:
    - "Path to pam.d service that was changed.  This is only available in
      Ansible version 2.3 and was removed in 2.4."
    returned: success
    type: string
    sample: "/etc/pam.d/system-auth"
...
'''


from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native
import os
import re
import time


# The PamdRule class encapsulates a rule in a pam.d service
class PamdRule(object):

    def __init__(self, rule_type,
                 rule_control, rule_module_path,
                 rule_module_args=None):

        self.rule_type = rule_type
        self.rule_control = rule_control
        self.rule_module_path = rule_module_path

        try:
            if (rule_module_args is not None and
                    type(rule_module_args) is list):
                self.rule_module_args = rule_module_args
            elif (rule_module_args is not None and
                    type(rule_module_args) is str):
                self.rule_module_args = rule_module_args.split()
        except AttributeError:
            self.rule_module_args = []

    @classmethod
    def rulefromstring(cls, stringline):
        pattern = None

        rule_type = ''
        rule_control = ''
        rule_module_path = ''
        rule_module_args = ''
        complicated = False

        if '[' in stringline:
            pattern = re.compile(
                r"""([\-A-Za-z0-9_]+)\s*         # Rule Type
                    \[([A-Za-z0-9_=\s]+)\]\s*    # Rule Control
                    ([A-Za-z0-9/_\-\.]+)\s*         # Rule Path
                    ([A-Za-z0-9,_=<>\-\s\./]*)""",  # Rule Args
                re.X)
            complicated = True
        else:
            pattern = re.compile(
                r"""([@\-A-Za-z0-9_]+)\s*        # Rule Type
                    ([A-Za-z0-9_\-]+)\s*          # Rule Control
                    ([A-Za-z0-9/_\-\.]*)\s*        # Rule Path
                    ([A-Za-z0-9,_=<>\-\s\./]*)""",  # Rule Args
                re.X)

        result = pattern.match(stringline)
        rule_type = result.group(1)
        if complicated:
            rule_control = '[' + result.group(2) + ']'
        else:
            rule_control = result.group(2)
        rule_module_path = result.group(3)
        if result.group(4) is not None:
            rule_module_args = result.group(4)

        return cls(rule_type, rule_control, rule_module_path, rule_module_args)

    def get_module_args_as_string(self):
        try:
            if self.rule_module_args is not None:
                return ' '.join(self.rule_module_args)
        except AttributeError:
            pass
        return ''

    def __str__(self):
        return "%-10s\t%s\t%s %s" % (self.rule_type,
                                     self.rule_control,
                                     self.rule_module_path,
                                     self.get_module_args_as_string())


# PamdService encapsulates an entire service and contains one or more rules
class PamdService(object):

    def __init__(self, ansible=None):

        if ansible is not None:
            self.check = ansible.check_mode
        self.check = False
        self.ansible = ansible
        self.preamble = []
        self.rules = []
        self.fname = None
        if ansible is not None:
            self.path = self.ansible.params["path"]
            self.name = self.ansible.params["name"]

    def load_rules_from_file(self):
        self.fname = os.path.join(self.path, self.name)
        stringline = ''
        try:
            for line in open(self.fname, 'r'):
                stringline += line.rstrip().lstrip()
                stringline += '\n'
            self.load_rules_from_string(stringline.replace("\\\n", ""))

        except IOError as e:
            self.ansible.fail_json(msg='Unable to open/read PAM module \
                                   file %s with error %s.  And line %s' %
                                   (self.fname, to_native(e), stringline))

    def load_rules_from_string(self, stringvalue):
        for line in stringvalue.splitlines():
            stringline = line.rstrip()
            if line.startswith('#') and not line.isspace():
                self.preamble.append(line.rstrip())
            elif (not line.startswith('#') and
                  not line.isspace() and
                  len(line) != 0):
                try:
                    self.ansible.log(msg="Creating rule from string %s" % stringline)
                except AttributeError:
                    pass
                self.rules.append(PamdRule.rulefromstring(stringline))

    def write(self):
        if self.fname is None:
            self.fname = self.path + "/" + self.name
        # If the file is a symbollic link, we'll write to the source.
        pamd_file = os.path.realpath(self.fname)
        temp_file = "/tmp/" + self.name + "_" + time.strftime("%y%m%d%H%M%S")
        try:
            f = open(temp_file, 'w')
            f.write(str(self))
            f.close()
        except IOError:
            self.ansible.fail_json(msg='Unable to create temporary \
                                   file %s' % self.temp_file)

        self.ansible.atomic_move(temp_file, pamd_file)

    def __str__(self):
        stringvalue = ''
        previous_rule = None
        for amble in self.preamble:
            stringvalue += amble
            stringvalue += '\n'

        for rule in self.rules:
            if (previous_rule is not None and
                    (previous_rule.rule_type.replace('-', '') !=
                     rule.rule_type.replace('-', ''))):
                stringvalue += '\n'
            stringvalue += str(rule).rstrip()
            stringvalue += '\n'
            previous_rule = rule

        if stringvalue.endswith('\n'):
            stringvalue = stringvalue[:-1]

        return stringvalue


def update_rule(service, old_rule, new_rule):

    changed = False
    change_count = 0
    result = {'action': 'update_rule'}

    for rule in service.rules:
        if (old_rule.rule_type == rule.rule_type and
                old_rule.rule_control == rule.rule_control and
                old_rule.rule_module_path == rule.rule_module_path):

            if (new_rule.rule_type is not None and
                    new_rule.rule_type != rule.rule_type):
                rule.rule_type = new_rule.rule_type
                changed = True
            if (new_rule.rule_control is not None and
                    new_rule.rule_control != rule.rule_control):
                rule.rule_control = new_rule.rule_control
                changed = True
            if (new_rule.rule_module_path is not None and
                    new_rule.rule_module_path != rule.rule_module_path):
                rule.rule_module_path = new_rule.rule_module_path
                changed = True
            try:
                if (new_rule.rule_module_args is not None and
                        new_rule.get_module_args_as_string() !=
                        rule.get_module_args_as_string()):
                    rule.rule_module_args = new_rule.rule_module_args
                    changed = True
            except AttributeError:
                pass
            if changed:
                result['updated_rule_' + str(change_count)] = str(rule)
                result['new_rule'] = str(new_rule)

                change_count += 1

    result['change_count'] = change_count
    return changed, result


def insert_before_rule(service, old_rule, new_rule):
    index = 0
    change_count = 0
    result = {'action':
              'insert_before_rule'}
    changed = False
    for rule in service.rules:
        if (old_rule.rule_type == rule.rule_type and
                old_rule.rule_control == rule.rule_control and
                old_rule.rule_module_path == rule.rule_module_path):
            if index == 0:
                service.rules.insert(0, new_rule)
                changed = True
            elif (new_rule.rule_type != service.rules[index - 1].rule_type or
                    new_rule.rule_control !=
                    service.rules[index - 1].rule_control or
                    new_rule.rule_module_path !=
                    service.rules[index - 1].rule_module_path):
                service.rules.insert(index, new_rule)
                changed = True
            if changed:
                result['new_rule'] = str(new_rule)
                result['before_rule_' + str(change_count)] = str(rule)
                change_count += 1
        index += 1
    result['change_count'] = change_count
    return changed, result


def insert_after_rule(service, old_rule, new_rule):
    index = 0
    change_count = 0
    result = {'action': 'insert_after_rule'}
    changed = False
    for rule in service.rules:
        if (old_rule.rule_type == rule.rule_type and
                old_rule.rule_control == rule.rule_control and
                old_rule.rule_module_path == rule.rule_module_path):
            if (index == len(service.rules) - 1):
                service.rules.insert(len(service.rules), new_rule)
                changed = True
            elif (new_rule.rule_type != service.rules[index + 1].rule_type or
                    new_rule.rule_control !=
                    service.rules[index + 1].rule_control or
                    new_rule.rule_module_path !=
                    service.rules[index + 1].rule_module_path):
                service.rules.insert(index + 1, new_rule)
                changed = True
            if changed:
                result['new_rule'] = str(new_rule)
                result['after_rule_' + str(change_count)] = str(rule)
                change_count += 1
        index += 1

    result['change_count'] = change_count
    return changed, result


def remove_module_arguments(service, old_rule, module_args):
    result = {'action': 'args_absent'}
    changed = False
    change_count = 0

    for rule in service.rules:
        if (old_rule.rule_type == rule.rule_type and
                old_rule.rule_control == rule.rule_control and
                old_rule.rule_module_path == rule.rule_module_path):
            for arg_to_remove in module_args:
                for arg in rule.rule_module_args:
                    if arg == arg_to_remove:
                        rule.rule_module_args.remove(arg)
                        changed = True
                        result['removed_arg_' + str(change_count)] = arg
                        result['from_rule_' + str(change_count)] = str(rule)
                        change_count += 1

    result['change_count'] = change_count
    return changed, result


def add_module_arguments(service, old_rule, module_args):
    result = {'action': 'args_present'}
    changed = False
    change_count = 0

    for rule in service.rules:
        if (old_rule.rule_type == rule.rule_type and
                old_rule.rule_control == rule.rule_control and
                old_rule.rule_module_path == rule.rule_module_path):
            for arg_to_add in module_args:
                if "=" in arg_to_add:
                    pre_string = arg_to_add[:arg_to_add.index('=') + 1]
                    indicies = [i for i, arg
                                in enumerate(rule.rule_module_args)
                                if arg.startswith(pre_string)]
                    if len(indicies) == 0:
                        rule.rule_module_args.append(arg_to_add)
                        changed = True
                        result['added_arg_' + str(change_count)] = arg_to_add
                        result['to_rule_' + str(change_count)] = str(rule)
                        change_count += 1
                    else:
                        for i in indicies:
                            if rule.rule_module_args[i] != arg_to_add:
                                rule.rule_module_args[i] = arg_to_add
                                changed = True
                                result['updated_arg_' +
                                       str(change_count)] = arg_to_add
                                result['in_rule_' +
                                       str(change_count)] = str(rule)
                                change_count += 1
                elif arg_to_add not in rule.rule_module_args:
                    rule.rule_module_args.append(arg_to_add)
                    changed = True
                    result['added_arg_' + str(change_count)] = arg_to_add
                    result['to_rule_' + str(change_count)] = str(rule)
                    change_count += 1
    result['change_count'] = change_count
    return changed, result


def remove_rule(service, old_rule):
    result = {'action': 'absent'}
    changed = False
    change_count = 0
    for rule in service.rules:
        if (old_rule.rule_type == rule.rule_type and
                old_rule.rule_control == rule.rule_control and
                old_rule.rule_module_path == rule.rule_module_path):
            service.rules.remove(rule)
            changed = True
    return changed, result


def main():

    module = AnsibleModule(
        argument_spec=dict(
            name=dict(required=True, type='str'),
            type=dict(required=True,
                      choices=['account', 'auth',
                               'password', 'session']),
            control=dict(required=True, type='str'),
            module_path=dict(required=True, type='str'),
            new_type=dict(required=False,
                          choices=['account', 'auth',
                                   'password', 'session']),
            new_control=dict(required=False, type='str'),
            new_module_path=dict(required=False, type='str'),
            module_arguments=dict(required=False, type='list'),
            state=dict(required=False, default="updated",
                       choices=['before', 'after', 'updated',
                                'args_absent', 'args_present', 'absent']),
            path=dict(required=False, default='/etc/pam.d', type='str')
        ),
        supports_check_mode=True,
        required_if=[
            ("state", "args_present", ["module_arguments"]),
            ("state", "args_absent", ["module_arguments"]),
            ("state", "before", ["new_control"]),
            ("state", "before", ["new_type"]),
            ("state", "before", ["new_module_path"]),
            ("state", "after", ["new_control"]),
            ("state", "after", ["new_type"]),
            ("state", "after", ["new_module_path"])

        ]
    )

    service = module.params['name']
    old_type = module.params['type']
    old_control = module.params['control']
    old_module_path = module.params['module_path']

    new_type = module.params['new_type']
    new_control = module.params['new_control']
    new_module_path = module.params['new_module_path']

    module_arguments = module.params['module_arguments']
    state = module.params['state']

    path = module.params['path']

    pamd = PamdService(module)
    pamd.load_rules_from_file()

    old_rule = PamdRule(old_type,
                        old_control,
                        old_module_path)
    new_rule = PamdRule(new_type,
                        new_control,
                        new_module_path,
                        module_arguments)

    if state == 'updated':
        change, result = update_rule(pamd,
                                     old_rule,
                                     new_rule)
    elif state == 'before':
        change, result = insert_before_rule(pamd,
                                            old_rule,
                                            new_rule)
    elif state == 'after':
        change, result = insert_after_rule(pamd,
                                           old_rule,
                                           new_rule)
    elif state == 'args_absent':
        change, result = remove_module_arguments(pamd,
                                                 old_rule,
                                                 module_arguments)
    elif state == 'args_present':
        change, result = add_module_arguments(pamd,
                                              old_rule,
                                              module_arguments)
    elif state == 'absent':
        change, result = remove_rule(pamd,
                                     old_rule)

    if not module.check_mode and change:
        pamd.write()

    facts = {}
    facts['pamd'] = {'changed': change, 'result': result}

    module.params['dest'] = pamd.fname

    module.exit_json(changed=change, ansible_facts=facts)


if __name__ == '__main__':
    main()
