diff --git a/README.md b/README.md index 5ab96bb4..d409c894 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,7 @@ not implemented => development => [testing](https://github.com/ansibleguy/collec | **IDS/IPS** | ansibleguy.opnsense.ids_policy_rule | [Docs](https://opnsense.ansibleguy.net/en/latest/modules/ids.html) | development | | **IDS/IPS** | ansibleguy.opnsense.ids_rule | [Docs](https://opnsense.ansibleguy.net/en/latest/modules/ids.html) | unstable | | **IDS/IPS** | ansibleguy.opnsense.ids_ruleset | [Docs](https://opnsense.ansibleguy.net/en/latest/modules/ids.html) | unstable | -| **IDS/IPS** | ansibleguy.opnsense.ids_ruleset_properties | [Docs](https://opnsense.ansibleguy.net/en/latest/modules/ids.html) | development | -| **IDS/IPS** | ansibleguy.opnsense.ids_user_rule | [Docs](https://opnsense.ansibleguy.net/en/latest/modules/ids.html) | development | +| **IDS/IPS** | ansibleguy.opnsense.ids_user_rule | [Docs](https://opnsense.ansibleguy.net/en/latest/modules/ids.html) | unstable | ### Roadmap diff --git a/docs/source/modules/ids.rst b/docs/source/modules/ids.rst index 062b5196..5166e5f4 100644 --- a/docs/source/modules/ids.rst +++ b/docs/source/modules/ids.rst @@ -8,14 +8,13 @@ Intrusion Prevention System **STATE**: unstable -**TESTS**: `ansibleguy.opnsense.ids_general `_, -`ansibleguy.opnsense.ids_action `_, -`ansibleguy.opnsense.ids_policy `_, -`ansibleguy.opnsense.ids_policy_rule `_, -`ansibleguy.opnsense.ids_rule `_, -`ansibleguy.opnsense.ids_ruleset `_, -`ansibleguy.opnsense.ids_ruleset_properties `_, -`ansibleguy.opnsense.ids_user_rule `_, +**TESTS**: `ansibleguy.opnsense.ids_general `_ | +`ansibleguy.opnsense.ids_action `_ | +`ansibleguy.opnsense.ids_policy `_ | +`ansibleguy.opnsense.ids_policy_rule `_ | +`ansibleguy.opnsense.ids_rule `_ | +`ansibleguy.opnsense.ids_ruleset `_ | +`ansibleguy.opnsense.ids_user_rule `_ **API Docs**: `IDS `_ @@ -92,6 +91,22 @@ ansibleguy.opnsense.ids_rule "reload","boolean","false","true","\-", .. include:: ../_include/param_reload.rst +ansibleguy.opnsense.ids_user_rule +================================= + +.. csv-table:: Definition + :header: "Parameter", "Type", "Required", "Default", "Aliases", "Comment" + :widths: 15 10 10 10 10 45 + + "name","string","true","\-","description, desc","Unique rule name" + "source_ip","string","false","\-","source, src_ip, src","Set the source IP or network to match. Leave this field empty for using 'any'" + "destination_ip","string","false","\-","destination, dst_ip, dst","Set the destination IP or network to match. Leave this field empty for using 'any'" + "ssl_fingerprint","string","false","\-","fingerprint, ssl_fp","The SSL fingerprint, for example: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E'" + "action","string","false","alert",a","One of 'alert', 'drop', 'pass'. Set action to perform here, only used when in IPS mode" + "bypass","boolean","false","false","bp","Set bypass keyword. Increases traffic throughput. Suricata reads a packet, decodes it, checks it in the flow table. If the corresponding flow is local bypassed then it simply skips all streaming, detection and output and the packet goes directly out in IDS mode and to verdict in IPS mode" + "enabled","boolean","false","true","\-","En- or disable the rule" + "reload","boolean","false","true","\-", .. include:: ../_include/param_reload.rst + Usage ***** @@ -295,11 +310,71 @@ ansibleguy.opnsense.ids_rule sid: 2400011 enabled: false - - name: Listing Settings + - name: Listing Rules ansibleguy.opnsense.list: # target: 'ids_rule' - register: existing_settings + register: existing_rules - - name: Printing Settings + - name: Printing Rules ansible.builtin.debug: - var: existing_settings.data + var: existing_rules.data + + +ansibleguy.opnsense.ids_user_rule +================================= + +.. code-block:: yaml + + - hosts: localhost + gather_facts: false + module_defaults: + group/ansibleguy.opnsense.all: + firewall: 'opnsense.template.ansibleguy.net' + api_credential_file: '/home/guy/.secret/opn.key' + + ansibleguy.opnsense.list: + target: 'ids_user_rule' + + tasks: + - name: Example + ansibleguy.opnsense.ids_user_rule: + name: 'Example' + # source_ip: '' + # destination_ip: '' + # ssl_fingerprint: '' + # action: 'alert' + # bypass: false + # enabled: true + # reload: true + # debug: false + + - name: Adding + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.10.1' + destination_ip: '1.1.1.1' + action: 'alert' + bypass: false + + - name: Disabling + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.10.1' + destination_ip: '1.1.1.1' + action: 'alert' + bypass: false + enabled: false + + - name: Removing + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + state: 'absent' + + - name: Listing Rules + ansibleguy.opnsense.list: + # target: 'ids_user_rule' + register: existing_rules + + - name: Printing Rules + ansible.builtin.debug: + var: existing_rules.data diff --git a/plugins/module_utils/main/ids_rule.py b/plugins/module_utils/main/ids_rule.py index f2b2e2dd..8ff549e4 100644 --- a/plugins/module_utils/main/ids_rule.py +++ b/plugins/module_utils/main/ids_rule.py @@ -18,7 +18,7 @@ class Rule(BaseModule): API_MOD = 'ids' API_CONT = 'settings' API_CONT_REL = 'service' - API_CMD_REL = 'reconfigure' + API_CMD_REL = 'reloadRules' FIELDS_CHANGE = ['action'] FIELDS_ALL = ['enabled'] FIELDS_ALL.extend(FIELDS_CHANGE) @@ -51,6 +51,8 @@ def process(self) -> None: self.toggle() def _search_call(self) -> list: + # NOTE: workaround for issue with incomplete response-data from 'get' endpoint: + # https://github.com/opnsense/core/issues/7094 existing = self.s.post(cnf={ **self.call_cnf, 'command': self.CMDS['search'], diff --git a/plugins/module_utils/main/ids_ruleset.py b/plugins/module_utils/main/ids_ruleset.py index d861f208..6faf3862 100644 --- a/plugins/module_utils/main/ids_ruleset.py +++ b/plugins/module_utils/main/ids_ruleset.py @@ -53,6 +53,8 @@ def process(self) -> None: self.toggle() def _search_call(self) -> list: + # NOTE: workaround for issue with incomplete response-data from 'get' endpoint: + # https://github.com/opnsense/core/issues/7094 existing = self.s.post(cnf={ **self.call_cnf, 'command': self.CMDS['search'], diff --git a/plugins/module_utils/main/ids_user_rule.py b/plugins/module_utils/main/ids_user_rule.py new file mode 100644 index 00000000..4903aa89 --- /dev/null +++ b/plugins/module_utils/main/ids_user_rule.py @@ -0,0 +1,75 @@ +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.api import \ + Session +from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.cls import BaseModule + + +class Rule(BaseModule): + FIELD_ID = 'description' + CMDS = { + 'add': 'addUserRule', + 'set': 'setUserRule', + 'del': 'delUserRule', + 'search': 'searchUserRule', + 'detail': 'getUserRule', + 'toggle': 'toggleUserRule', + } + API_KEY = 'rule' + API_KEY_PATH = f'userDefinedRules.{API_KEY}' + API_MOD = 'ids' + API_CONT = 'settings' + API_CONT_REL = 'service' + API_CMD_REL = 'reloadRules' + FIELDS_CHANGE = ['source_ip', 'destination_ip', 'ssl_fingerprint', 'action', 'bypass'] + FIELDS_ALL = ['enabled', FIELD_ID] + FIELDS_ALL.extend(FIELDS_CHANGE) + FIELDS_TRANSLATE = { + 'source_ip': 'source', + 'destination_ip': 'destination', + 'ssl_fingerprint': 'fingerprint', + } + FIELDS_TYPING = { + 'bool': ['enabled', 'bypass'], + 'select': ['action'], + } + EXIST_ATTR = 'rule' + QUERY_MAX_RULES = 5000 + + def __init__(self, module: AnsibleModule, result: dict, session: Session = None): + BaseModule.__init__(self=self, m=module, r=result, s=session) + self.rule = {} + self.exists = False + + def check(self): + self._search_call() + self.r['diff']['after'] = self.b.build_diff(data=self.p) + + def get_existing(self) -> list: + return self._search_call() + + def _search_call(self) -> list: + # NOTE: workaround for issue with incomplete response-data from 'get' endpoint: + # https://github.com/opnsense/core/issues/7094 + existing = self.s.post(cnf={ + **self.call_cnf, + 'command': self.CMDS['search'], + 'data': {'current': 1, 'rowCount': self.QUERY_MAX_RULES, 'sort': self.FIELD_ID}, + })['rows'] + + if self.FIELD_ID in self.p: # list module + for rule in existing: + if rule[self.FIELD_ID] == self.p[self.FIELD_ID]: + self.exists = True + self.call_cnf['params'] = [rule['uuid']] + # pylint: disable=W0212 + self.rule = self.b._simplify_existing( + self.s.get(cnf={ + **self.call_cnf, + 'command': self.CMDS['detail'], + })[self.API_KEY] + ) + self.rule['uuid'] = rule['uuid'] + self.r['diff']['before'] = self.rule + + return existing diff --git a/plugins/modules/ids_user_rule.py b/plugins/modules/ids_user_rule.py new file mode 100644 index 00000000..97e2cd90 --- /dev/null +++ b/plugins/modules/ids_user_rule.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +# Copyright: (C) 2023, AnsibleGuy +# GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt) + +# see: https://docs.opnsense.org/development/api/core/ids.html + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.handler import \ + module_dependency_error, MODULE_EXCEPTIONS + +try: + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.helper.wrapper import module_wrapper + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.helper.main import \ + diff_remove_empty + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.defaults.main import \ + OPN_MOD_ARGS, STATE_MOD_ARG, RELOAD_MOD_ARG + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.ids_user_rule import Rule + +except MODULE_EXCEPTIONS: + module_dependency_error() + + +# DOCUMENTATION = 'https://opnsense.ansibleguy.net/en/latest/modules/ids.html' +# EXAMPLES = 'https://opnsense.ansibleguy.net/en/latest/modules/ids.html' + + +def run_module(): + module_args = dict( + description=dict( + type='str', required=True, aliases=['name', 'desc'], + description='Unique rule name', + ), + source_ip=dict( + type='str', required=False, aliases=['source', 'src_ip', 'src'], default='', + description="Set the source IP or network to match. Leave this field empty for using 'any'", + ), + destination_ip=dict( + type='str', required=False, aliases=['destination', 'dst_ip', 'dst'], default='', + description="Set the destination IP or network to match. Leave this field empty for using 'any'", + ), + ssl_fingerprint=dict( + type='str', required=False, aliases=['fingerprint', 'ssl_fp'], default='', + description="The SSL fingerprint, for example: " + "'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E'", + ), + action=dict( + type='str', required=False, aliases=['a'], default='alert', + choices=['alert', 'drop', 'pass'], + description='Set action to perform here, only used when in IPS mode', + ), + bypass=dict( + type='bool', required=False, aliases=['bp'], default=False, + description='Set bypass keyword. Increases traffic throughput. Suricata reads a packet, ' + 'decodes it, checks it in the flow table. If the corresponding flow is local ' + 'bypassed then it simply skips all streaming, detection and output and the packet ' + 'goes directly out in IDS mode and to verdict in IPS mode', + ), + **STATE_MOD_ARG, + **RELOAD_MOD_ARG, + **OPN_MOD_ARGS, + ) + + result = dict( + changed=False, + diff={ + 'before': {}, + 'after': {}, + } + ) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + module_wrapper(Rule(module=module, result=result)) + + result['diff'] = diff_remove_empty(result['diff']) + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/list.py b/plugins/modules/list.py index 75a8ffe5..681dbb4f 100644 --- a/plugins/modules/list.py +++ b/plugins/modules/list.py @@ -33,7 +33,7 @@ 'frr_bgp_as_path', 'frr_bgp_route_map', 'frr_ospf_prefix_list', 'frr_ospf_route_map', 'webproxy_forward', 'webproxy_acl', 'webproxy_icap', 'webproxy_auth', 'ipsec_connection', 'ipsec_pool', 'ipsec_child', 'ipsec_vti', 'ipsec_auth_local', 'ipsec_auth_remote', 'frr_general', 'unbound_general', - 'unbound_acl', 'ids_general', 'ids_policy', 'ids_rule', 'ids_ruleset', + 'unbound_acl', 'ids_general', 'ids_policy', 'ids_rule', 'ids_ruleset', 'ids_user_rule', ] @@ -340,6 +340,10 @@ def run_module(): from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.ids_ruleset import \ Ruleset as Target_Obj + elif target == 'ids_user_rule': + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.ids_user_rule import \ + Rule as Target_Obj + except AttributeError: module_dependency_error() diff --git a/scripts/test.sh b/scripts/test.sh index 5d889c0a..d2d01253 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -132,6 +132,7 @@ run_test 'ids_action' 1 run_test 'ids_general' 1 run_test 'ids_ruleset' 1 run_test 'ids_rule' 1 +run_test 'ids_user_rule' 1 # run_test 'ids_policy' 1 run_test 'system' 1 run_test 'package' 1 diff --git a/tests/cleanup.yml b/tests/cleanup.yml index 7deed185..503b72cf 100644 --- a/tests/cleanup.yml +++ b/tests/cleanup.yml @@ -562,3 +562,8 @@ - name: Cleanup IDS Rule ansibleguy.opnsense.ids_rule: sid: 2400000 + + - name: Cleanup IDS User-Rule + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + state: 'absent' diff --git a/tests/ids_user_rule.yml b/tests/ids_user_rule.yml new file mode 100644 index 00000000..90551f9d --- /dev/null +++ b/tests/ids_user_rule.yml @@ -0,0 +1,159 @@ +--- + +- name: Testing IDS User-Rule + hosts: localhost + gather_facts: no + module_defaults: + group/ansibleguy.opnsense.all: + firewall: "{{ lookup('ansible.builtin.env', 'TEST_FIREWALL') }}" + api_credential_file: "{{ lookup('ansible.builtin.env', 'TEST_API_KEY') }}" + ssl_verify: false + + ansibleguy.opnsense.list: + target: 'ids_user_rule' + + tasks: + - name: Listing + ansibleguy.opnsense.list: + register: opn_pre1 + failed_when: > + opn_pre1.failed or + 'data' not in opn_pre1 + + - name: Removing 1 - does not exist + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + state: 'absent' + register: opn_pre2 + failed_when: > + opn_pre2.failed or + opn_pre2.changed + + - name: Adding 1 + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + register: opn1 + failed_when: > + opn1.failed or + not opn1.changed + + - name: Changing 1 + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.10.1' + destination_ip: '1.1.1.1' + ssl_fingerprint: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E' + action: 'alert' + bypass: false + register: opn6 + failed_when: > + opn6.failed or + not opn6.changed + when: not ansible_check_mode + + - name: Changing 1 - nothing changed + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.10.1' + destination_ip: '1.1.1.1' + ssl_fingerprint: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E' + action: 'alert' + bypass: false + register: opn7 + failed_when: > + opn7.failed or + opn7.changed + when: not ansible_check_mode + + - name: Changing 1 - more + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.20.1' + destination_ip: '1.1.0.0' + ssl_fingerprint: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E' + action: 'pass' + bypass: true + register: opn8 + failed_when: > + opn8.failed or + not opn8.changed + when: not ansible_check_mode + + - name: Changing 1 - nothing changed + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.20.1' + destination_ip: '1.1.0.0' + ssl_fingerprint: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E' + action: 'pass' + bypass: true + register: opn9 + failed_when: > + opn9.failed or + opn9.changed + when: not ansible_check_mode + + - name: Disabling 1 + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.20.1' + destination_ip: '1.1.0.0' + ssl_fingerprint: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E' + action: 'pass' + bypass: true + enabled: false + register: opn2 + failed_when: > + opn2.failed or + not opn2.changed + when: not ansible_check_mode + + - name: Disabling 1 - nothing changed + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.20.1' + destination_ip: '1.1.0.0' + ssl_fingerprint: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E' + action: 'pass' + bypass: true + enabled: false + register: opn3 + failed_when: > + opn3.failed or + opn3.changed + when: not ansible_check_mode + + - name: Enabling 1 + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.20.1' + destination_ip: '1.1.0.0' + ssl_fingerprint: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E' + action: 'pass' + bypass: true + enabled: true + register: opn4 + failed_when: > + opn4.failed or + not opn4.changed + when: not ansible_check_mode + + - name: Enabling 1 - nothing changed + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + source_ip: '192.168.20.1' + destination_ip: '1.1.0.0' + ssl_fingerprint: 'B5:E1:B3:70:5E:7C:FF:EB:92:C4:29:E5:5B:AC:2F:AE:70:17:E9:9E' + action: 'pass' + bypass: true + register: opn5 + failed_when: > + opn5.failed or + opn5.changed + when: not ansible_check_mode + + - name: Cleanup + ansibleguy.opnsense.ids_user_rule: + name: 'ANSIBLE_TEST_1_1' + state: 'absent' + when: not ansible_check_mode