diff --git a/meta/runtime.yml b/meta/runtime.yml index 8509981f..7faabad2 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1,4 +1,8 @@ +requires_ansible: ">=2.12" plugin_routing: + action: + pfsense_system_patch: + redirect: pfsensible.core.src_file_to_content modules: pfsense_haproxy_backend: deprecation: @@ -8,4 +12,3 @@ plugin_routing: deprecation: removal_version: 0.8.0 warning_text: Use pfsensible.haproxy.pfsense_haproxy_backend_server instead. -requires_ansible: ">=2.12" diff --git a/plugins/action/src_file_to_content.py b/plugins/action/src_file_to_content.py new file mode 100644 index 00000000..8b8be9dc --- /dev/null +++ b/plugins/action/src_file_to_content.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, genofire +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import os + +from ansible.errors import AnsibleError, AnsibleActionFail +from ansible.module_utils.common.text.converters import to_native, to_text +from ansible.plugins.action import ActionBase + + +class ActionModule(ActionBase): + + def run(self, tmp=None, task_vars=None): + ''' handler for file transfer operations ''' + if task_vars is None: + task_vars = dict() + + result = super(ActionModule, self).run(tmp, task_vars) + del tmp # tmp no longer has any effect + + source = self._task.args.get('src', None) + new_module_args = self._task.args.copy() + if source is not None: + del new_module_args['src'] + try: + # find in expected paths + source = self._find_needle('files', source) + except AnsibleError as e: + result['failed'] = True + result['msg'] = to_text(e) + # result['exception'] = traceback.format_exc() + return result + + if not os.path.isfile(source): + raise AnsibleActionFail(u"Source (%s) is not a file" % source) + + try: + with open(source, 'rb') as src: + content = src.read() + new_module_args['content'] = content.decode('utf-8') + except Exception as e: + raise AnsibleError("Unexpected error while reading source (%s) for diff: %s " % (source, to_native(e))) + module_return = self._execute_module(module_args=new_module_args, task_vars=task_vars) + result.update(module_return) + return result diff --git a/plugins/module_utils/system_patch.py b/plugins/module_utils/system_patch.py new file mode 100644 index 00000000..5b66ab47 --- /dev/null +++ b/plugins/module_utils/system_patch.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, genofire +# 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 + +from base64 import b64encode +from ansible_collections.pfsensible.core.plugins.module_utils.module_base import PFSenseModuleBase + +SYSTEMPATCH_ARGUMENT_SPEC = dict( + state=dict(default='present', choices=['present', 'absent']), + run=dict(default='no', choices=['apply', 'revert', 'no']), + # attributes + id=dict(type='str', required=True), + description=dict(type='str'), + content=dict(type='str'), + # patch or patch_file + src=dict(type='path'), + location=dict(type='str', default=""), + pathstrip=dict(type='int', default=2), + basedir=dict(type='str', default="/"), + ignore_whitespace=dict(type='bool', default=True), + auto_apply=dict(type='bool', default=False), +) + +SYSTEMPATCH_MUTUALLY_EXCLUSIVE = [ + ['content', 'path'], +] + +SYSTEMPATCH_REQUIRED_IF = [ + ['state', 'present', ['description']], + ['state', 'present', ['content', 'src'], True], + ['run', 'apply', ['content', 'src'], True], + ['run', 'revert', ['content', 'src'], True], +] + + +class PFSenseSystemPatchModule(PFSenseModuleBase): + """ module managing pfsense system patches """ + + @staticmethod + def get_argument_spec(): + """ return argument spec """ + return SYSTEMPATCH_ARGUMENT_SPEC + + ############################## + # init + # + def __init__(self, module, pfsense=None): + super(PFSenseSystemPatchModule, self).__init__(module, pfsense) + self.name = "pfsense_systempatch" + + self.root_elt = None + installedpackages_elt = self.pfsense.get_element('installedpackages') + if installedpackages_elt is not None: + self.root_elt = self.pfsense.get_element('patches', root_elt=installedpackages_elt, create_node=True) + + self.target_elt = None # unknown + self.obj = dict() # The object to work on + + ############################## + # params processing + # + + def _validate_params(self): + """ do some extra checks on input parameters """ + pass + + def _create_target(self): + """ create the XML target_elt """ + return self.pfsense.new_element('item') + + def _find_target(self): + """ find the XML target_elt """ + return self.pfsense.find_elt('item', self.params['id'], 'uniqid', root_elt=self.root_elt) + + def _get_obj_name(self): + return "'{0}'".format(self.obj['uniqid']) + + def _log_fields(self, before=None): + """ generate pseudo-CLI command fields parameters to create an obj """ + values = '' + fields = [ + 'uniqid', + 'descr', + 'location', + 'pathstrip', + 'basedir', + 'ignorewhitespace', + 'autoapply', + 'patch', + ] + if before is None: + for field in fields: + values += self.format_cli_field(self.obj, field) + else: + for field in fields: + values += self.format_updated_cli_field(self.obj, before, field, add_comma=(values)) + return values + + @staticmethod + def _get_params_to_remove(): + """ returns the list of params to remove if they are not set """ + return ['ignorewhitespace', 'autoapply'] + + def _params_to_obj(self): + """ return a dict from module params """ + obj = dict() + + self._get_ansible_param(obj, 'id', 'uniqid') + self._get_ansible_param(obj, 'description', 'descr') + self._get_ansible_param(obj, 'location') + self._get_ansible_param(obj, 'pathstrip') + self._get_ansible_param(obj, 'basedir') + + if self.params['ignore_whitespace']: + obj['ignorewhitespace'] = "" + if self.params['auto_apply']: + obj['autoapply'] = "" + + # src copied to content by action + if self.params['content'] is not None: + obj['patch'] = b64encode(bytes(self.params['content'], 'utf-8')).decode('ascii') + + if self.params['run'] != 'no': + # want to run _update so change manipulate + self.result['changed'] = True + + return obj + + ############################## + # run + # + + def _update(self): + run = self.params['run'] + if run == "no": + return ('0', 'Patch is stored but not installed', '') + + other_direction = 'revert' if run == 'apply' else 'apply' + + cmd = ''' +require_once('functions.inc'); +require_once('patches.inc'); + + ''' + cmd += self.pfsense.dict_to_php(self.obj, 'thispatch') + cmd += ''' + +$retval = 0; +$test = patch_test_''' + run + '''($thispatch); +$retval |= $test; +$retval = $retval << 1; + +if ($test) { + $retval |= patch_''' + run + '''($thispatch); +} else { + $rerun = patch_test_''' + other_direction + '''($thispatch); + if($rerun) { + patch_''' + other_direction + '''($thispatch); + $retval |= patch_''' + run + '''($thispatch); + } +} +exit($retval);''' + (code, out, err) = self.pfsense.phpshell(cmd) + self.result['rc_merged'] = code + + # patch_'''+ run + rc_run = (code % 2) == 1 + self.result['rc_run'] = rc_run + + # patch_test_'''+ other_direction + # restore test code, so if revert (other direction) not works - patch was already applyied + rc_test = ((code >> 1) % 2) == 1 + self.result['rc_test'] = rc_test + + # recalc changed after overwritten to run _update + self.result['changed'] = (rc_run and rc_test) + if not rc_run: + self.result['failed'] = True + self.result['msg'] = "Patch was not possible to run (even after try other direction previously)" + return ('', out, err) diff --git a/plugins/modules/pfsense_system_patch.py b/plugins/modules/pfsense_system_patch.py new file mode 100644 index 00000000..6021f57c --- /dev/null +++ b/plugins/modules/pfsense_system_patch.py @@ -0,0 +1,119 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, genofire +# 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: pfsense_system_patch +version_added: 0.6.0 +author: Geno (@genofire) +short_description: System Patch +description: + - Manage System Patch +notes: +options: + id: + description: ID of Patch - for update / delete the correct + type: str + required: True + description: + description: The name of the patch in the "System Patch" menu. + type: str + required: False + content: + description: The contents of the patch. + type: str + required: False + src: + description: Path to a patch file. + type: path + required: False + location: + description: Location. + type: str + required: False + default: "" + pathstrip: + description: The number of levels to strip from the front of the path in the patch header. + type: int + required: False + default: 2 + basedir: + description: | + Enter the base directory for the patch, default is /. + Patches from github are all based in /. + Custom patches may need a full path here such as /usr/local/www/. + type: str + required: False + default: "/" + ignore_whitespace: + description: Ignore whitespace in the patch. + type: bool + required: False + default: true + auto_apply: + description: Apply the patch automatically when possible, useful for patches to survive after updates. + type: bool + required: False + default: false + state: + description: State in which to leave the interface group. + choices: [ "present", "absent" ] + default: present + type: str + run: + description: State in which to leave the interface group. + choices: [ "no", "apply", "revert" ] + default: "no" + type: str +""" + +EXAMPLES = """ +- name: Try Systempatch + pfsense_system_patch: + id: "3f60a103a613" + description: "Hello Welt Patch" + content: > + --- b/tmp/test.txt + +++ a/tmp/test.txt + @@ -0,0 +1 @@ + +Hello Welt + location: "" + pathstrip: 1 + basedir: "/" + ignore_whitespace: true + auto_apply: true +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.pfsensible.core.plugins.module_utils.system_patch import ( + PFSenseSystemPatchModule, + SYSTEMPATCH_ARGUMENT_SPEC, + SYSTEMPATCH_MUTUALLY_EXCLUSIVE, + SYSTEMPATCH_REQUIRED_IF +) + + +def main(): + module = AnsibleModule( + argument_spec=SYSTEMPATCH_ARGUMENT_SPEC, + mutually_exclusive=SYSTEMPATCH_MUTUALLY_EXCLUSIVE, + required_if=SYSTEMPATCH_REQUIRED_IF, + supports_check_mode=True) + + pfmodule = PFSenseSystemPatchModule(module) + pfmodule.run(module.params) + pfmodule.commit_changes() + + +if __name__ == '__main__': + main()