diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt index 0ac3b24..1eb0fc0 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/requirements.txt @@ -3,4 +3,5 @@ pytest pytest-md pytest-cov pytest-emoji -requests-mock \ No newline at end of file +requests-mock +pytest-freezer \ No newline at end of file diff --git a/agent_based/opnsense_firmware.py b/agent_based/opnsense_firmware.py new file mode 100644 index 0000000..fa5f692 --- /dev/null +++ b/agent_based/opnsense_firmware.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8; py-indent-offset: 4 -*- +# +# checkmk_opnsense - Checkmk extension for OPNsense +# +# Copyright (C) 2024 Marius Rieder +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import json +from datetime import datetime +from cmk.agent_based.v2 import ( + AgentSection, + check_levels, + CheckPlugin, + CheckResult, + DiscoveryResult, + render, + Result, + Service, + State, + Metric, +) + + +def parse_opnsense_firmware(string_table: list[list[str]]) -> dict: + if string_table: + return json.loads(string_table[0][0]) + return None + + +agent_section_opnsense_firmware = AgentSection( + name='opnsense_firmware', + parse_function=parse_opnsense_firmware, +) + + +def discovery_opnsense_firmware(section: dict | None) -> DiscoveryResult: + if section: + yield Service() + + +def check_opnsense_firmware(params: dict, section: dict) -> CheckResult: + yield Result(state=State.OK, summary=f"{section['product']['product_series']} ({section['product']['product_nickname']})") + + if 'last_check' not in section: + yield Result(state=State.OK, summary=section['status_msg']) + return + + last_check = datetime.strptime(section['last_check'], "%a %b %d %X %Z %Y") + last_check_age = (datetime.now() - last_check).seconds + yield from check_levels( + value=last_check_age, + levels_upper=params.get('last_check', None), + metric_name='last_check', + render_func=render.timespan, + label='Last update check', + notice_only=True + ) + + if section['status'] == 'update': + yield Result(state=State.OK, summary=section['status_msg']) + yield Metric(name='updates', value=len(section['product']['product_check']['upgrade_packages'])) + + +check_plugin_opnsense_firmware = CheckPlugin( + name='opnsense_firmware', + service_name='OPNsense Firmware', + discovery_function=discovery_opnsense_firmware, + check_function=check_opnsense_firmware, + check_default_parameters={}, + check_ruleset_name='opnsense_firmware', +) + + +def discovery_opnsense_business(section: dict | None) -> DiscoveryResult: + if section and section.get('product_id', None) == 'opnsense-business': + yield Service() + + +def check_opnsense_business(params: dict, section: dict) -> CheckResult: + valid_to = datetime.fromisoformat(section['product']['product_license']['valid_to']) + valid_to_days = (valid_to - datetime.now()).days + yield from check_levels( + value=valid_to_days, + levels_lower=params.get('expiredays', ('fixed', (60, 30))), + metric_name='expiredays', + render_func=lambda x: f"{x} days", + label='License expires in', + ) + + +check_plugin_opnsense_business = CheckPlugin( + name='opnsense_business', + sections=['opnsense_firmware'], + service_name='OPNsense Business', + discovery_function=discovery_opnsense_business, + check_function=check_opnsense_business, + check_default_parameters={}, + check_ruleset_name='opnsense_business', +) diff --git a/agent_based/opnsense_vip.py b/agent_based/opnsense_vip.py index 6eae9dc..44af28c 100644 --- a/agent_based/opnsense_vip.py +++ b/agent_based/opnsense_vip.py @@ -72,7 +72,7 @@ def discovery_opnsense_carp( def check_opnsense_carp( - params, + params: dict, section_opnsense_carp: _Section | None, section_opnsense_vip: _Section | None ) -> CheckResult: diff --git a/lib/agent.py b/lib/agent.py index bc74616..52fc2d8 100644 --- a/lib/agent.py +++ b/lib/agent.py @@ -116,6 +116,9 @@ def api(self): def main(self, args: Args): self.args = args + with SectionWriter('opnsense_firmware') as section: + section.append_json(self.api.get('core', 'firmware', 'status')) + with SectionWriter('opnsense_carp') as section: section.append_json(self.api.getVipStatus['carp']) with SectionWriter('opnsense_vip') as section: diff --git a/package b/package index d262beb..2a4d7bf 100644 --- a/package +++ b/package @@ -7,9 +7,11 @@ 'opnsense/graphing/opnsense_vip.py', 'opnsense/lib/agent.py', 'opnsense/libexec/agent_opnsense', + 'opnsense/agent_based/opnsense_firmware.py', 'opnsense/agent_based/opnsense_gateway.py', 'opnsense/agent_based/opnsense_vip.py', 'opnsense/rulesets/datasource.py', + 'opnsense/rulesets/opnsense_firmware.py', 'opnsense/rulesets/opnsense_gateway.py', 'opnsense/rulesets/opnsense_vip.py', 'opnsense/server_side_calls/agent_opnsense.py', diff --git a/rulesets/opnsense_firmware.py b/rulesets/opnsense_firmware.py new file mode 100644 index 0000000..2c4b3a7 --- /dev/null +++ b/rulesets/opnsense_firmware.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8; py-indent-offset: 4 -*- +# +# checkmk_opnsense - Checkmk extension for OPNsense +# +# Copyright (C) 2024 Marius Rieder +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from cmk.rulesets.v1 import Help, Title +from cmk.rulesets.v1.form_specs import ( + DefaultValue, + DictElement, + Dictionary, + InputHint, + Integer, + LevelDirection, + LevelsType, + SimpleLevels, + TimeMagnitude, + TimeSpan, +) +from cmk.rulesets.v1.rule_specs import CheckParameters, Topic, HostCondition + + +def _parameter_form_opnsense_firmware(): + return Dictionary( + elements={ + 'last_check': DictElement( + parameter_form=SimpleLevels( + title=Title('Last check age'), + level_direction=LevelDirection.UPPER, + form_spec_template=TimeSpan(displayed_magnitudes=[TimeMagnitude.DAY, TimeMagnitude.HOUR]), + prefill_levels_type=DefaultValue(LevelsType.FIXED), + prefill_fixed_levels=InputHint(value=(7 * 24 * 3600, 10 * 24 * 3600)), + ), + required=False, + ), + } + ) + + +rule_spec_opnsense_firmware = CheckParameters( + name='opnsense_firmware', + topic=Topic.NETWORKING, + parameter_form=_parameter_form_opnsense_firmware, + title=Title('OPNsense Firmware Update check'), + help_text=Help('This rule configures thresholds for OPNsense Firmware check.'), + condition=HostCondition(), +) + + +def _parameter_form_opnsense_business(): + return Dictionary( + elements={ + 'expiredays': DictElement( + parameter_form=SimpleLevels( + title=Title('Expiry in days'), + level_direction=LevelDirection.LOWER, + form_spec_template=Integer( + unit_symbol='days' + ), + prefill_levels_type=DefaultValue(LevelsType.FIXED), + prefill_fixed_levels=InputHint(value=(60, 30)), + ), + required=False, + ), + } + ) + + +rule_spec_opnsense_business = CheckParameters( + name='opnsense_business', + topic=Topic.NETWORKING, + parameter_form=_parameter_form_opnsense_business, + title=Title('OPNsense Businiess License'), + help_text=Help('This rule configures thresholds for OPNsense license validity.'), + condition=HostCondition(), +) diff --git a/tests/unit/agent_based/test_data/firmware_info/nostatus.json b/tests/unit/agent_based/test_data/firmware_info/nostatus.json new file mode 100644 index 0000000..ae858aa --- /dev/null +++ b/tests/unit/agent_based/test_data/firmware_info/nostatus.json @@ -0,0 +1,28 @@ +{ + "product": { + "product_abi": "24.7", + "product_arch": "amd64", + "product_check": null, + "product_conflicts": "os-firewall os-firewall-devel os-wireguard os-wireguard-devel os-wireguard-go os-wireguard-go-devel", + "product_copyright_owner": "Deciso B.V.", + "product_copyright_url": "https:\/\/www.deciso.com\/", + "product_copyright_years": "2014-2024", + "product_email": "project@opnsense.org", + "product_hash": "f20b6eaa5", + "product_id": "opnsense", + "product_latest": "24.7.7", + "product_license": [], + "product_log": 0, + "product_mirror": "https:\/\/pkg.opnsense.org\/FreeBSD:14:amd64\/24.7", + "product_name": "OPNsense", + "product_nickname": "Thriving Tiger", + "product_repos": "OPNsense (Priority: 11)", + "product_series": "24.7", + "product_tier": "1", + "product_time": "Fri Oct 25 16:58:04 UTC 2024", + "product_version": "24.7.7", + "product_website": "https:\/\/opnsense.org\/" + }, + "status_msg": "Firmware status requires to check for update first to provide more information.", + "status": "none" +} \ No newline at end of file diff --git a/tests/unit/agent_based/test_data/firmware_info/outdated.json b/tests/unit/agent_based/test_data/firmware_info/outdated.json new file mode 100644 index 0000000..c25ba18 --- /dev/null +++ b/tests/unit/agent_based/test_data/firmware_info/outdated.json @@ -0,0 +1,178 @@ +{ + "api_version": "2", + "connection": "ok", + "downgrade_packages": [], + "download_size": "130MiB", + "last_check": "Fri Oct 25 16:32:25 UTC 2024", + "needs_reboot": "1", + "new_packages": [ + { + "name": "py311-ldap3", + "repository": "OPNsense", + "version": "2.9.1" + } + ], + "os_version": "FreeBSD 14.1-RELEASE-p2", + "product_id": "opnsense", + "product_target": "opnsense", + "product_version": "24.7", + "product_abi": "24.7", + "reinstall_packages": [ + { + "name": "py311-pandas", + "version": "2.0.3_2,1", + "repository": "OPNsense" + } + ], + "remove_packages": [], + "repository": "ok", + "upgrade_major_message": "", + "upgrade_major_version": "", + "upgrade_needs_reboot": "0", + "upgrade_packages": [ + { + "name": "curl", + "repository": "OPNsense", + "current_version": "8.8.0", + "new_version": "8.10.1" + }, + { + "name": "dhcrelay", + "repository": "OPNsense", + "current_version": "0.5", + "new_version": "1.0" + }, + { + "name": "base", + "size": "133485560", + "repository": "OPNsense", + "current_version": "24.7", + "new_version": "24.7.6" + }, + { + "name": "kernel", + "size": "34087700", + "repository": "OPNsense", + "current_version": "24.7", + "new_version": "24.7.6" + } + ], + "upgrade_sets": [], + "product": { + "product_abi": "24.7", + "product_arch": "amd64", + "product_check": { + "api_version": "2", + "connection": "ok", + "downgrade_packages": [], + "download_size": "130MiB", + "last_check": "Fri Oct 25 16:32:25 UTC 2024", + "needs_reboot": "1", + "new_packages": [ + { + "name": "py311-ldap3", + "repository": "OPNsense", + "version": "2.9.1" + } + ], + "os_version": "FreeBSD 14.1-RELEASE-p2", + "product_id": "opnsense", + "product_target": "opnsense", + "product_version": "24.7", + "product_abi": "24.7", + "reinstall_packages": [ + { + "name": "py311-pandas", + "version": "2.0.3_2,1", + "repository": "OPNsense" + } + ], + "remove_packages": [], + "repository": "ok", + "upgrade_major_message": "", + "upgrade_major_version": "", + "upgrade_needs_reboot": "0", + "upgrade_packages": [ + { + "name": "curl", + "repository": "OPNsense", + "current_version": "8.8.0", + "new_version": "8.10.1" + }, + { + "name": "dhcrelay", + "repository": "OPNsense", + "current_version": "0.5", + "new_version": "1.0" + }, + { + "name": "base", + "size": "133485560", + "repository": "OPNsense", + "current_version": "24.7", + "new_version": "24.7.6" + }, + { + "name": "kernel", + "size": "34087700", + "repository": "OPNsense", + "current_version": "24.7", + "new_version": "24.7.6" + } + ], + "upgrade_sets": [] + }, + "product_copyright_owner": "Deciso B.V.", + "product_copyright_url": "https:\/\/www.deciso.com\/", + "product_copyright_years": "2014-2024", + "product_email": "project@opnsense.org", + "product_hash": "f29196d01", + "product_id": "opnsense", + "product_latest": "24.7.7", + "product_license": [], + "product_log": 0, + "product_mirror": "https:\/\/pkg.opnsense.org\/FreeBSD:14:amd64\/24.7", + "product_name": "OPNsense", + "product_nickname": "Thriving Tiger", + "product_repos": "OPNsense", + "product_series": "24.7", + "product_tier": "1", + "product_time": "Tue Jul 23 11:51:02 UTC 2024", + "product_version": "24.7", + "product_website": "https:\/\/opnsense.org\/" + }, + "all_packages": { + "base": { + "reason": "upgrade", + "old": "24.7", + "new": "24.7.6", + "repository": "OPNsense", + "name": "base" + }, + "curl": { + "reason": "upgrade", + "old": "8.8.0", + "new": "8.10.1", + "repository": "OPNsense", + "name": "curl" + }, + "dhcrelay": { + "reason": "upgrade", + "old": "0.5", + "new": "1.0", + "repository": "OPNsense", + "name": "dhcrelay" + }, + "kernel": { + "reason": "upgrade", + "old": "24.7", + "new": "24.7.6", + "repository": "OPNsense", + "name": "kernel" + } + }, + "all_sets": [], + "status_msg": "There are 75 updates available, total download size is 289.8MiB. This update requires a reboot.", + "status_reboot": "1", + "status": "update" +} \ No newline at end of file diff --git a/tests/unit/agent_based/test_data/firmware_info/uptodate.json b/tests/unit/agent_based/test_data/firmware_info/uptodate.json new file mode 100644 index 0000000..a34aa3f --- /dev/null +++ b/tests/unit/agent_based/test_data/firmware_info/uptodate.json @@ -0,0 +1,71 @@ +{ + "api_version": "2", + "connection": "ok", + "downgrade_packages": [], + "download_size": "", + "last_check": "Fri Oct 25 17:02:31 UTC 2024", + "needs_reboot": "0", + "new_packages": [], + "os_version": "FreeBSD 14.1-RELEASE-p5", + "product_id": "opnsense", + "product_target": "opnsense", + "product_version": "24.7.7", + "product_abi": "24.7", + "reinstall_packages": [], + "remove_packages": [], + "repository": "ok", + "upgrade_major_message": "", + "upgrade_major_version": "", + "upgrade_needs_reboot": "0", + "upgrade_packages": [], + "upgrade_sets": [], + "product": { + "product_abi": "24.7", + "product_arch": "amd64", + "product_check": { + "api_version": "2", + "connection": "ok", + "downgrade_packages": [], + "download_size": "", + "last_check": "Fri Oct 25 17:02:31 UTC 2024", + "needs_reboot": "0", + "new_packages": [], + "os_version": "FreeBSD 14.1-RELEASE-p5", + "product_id": "opnsense", + "product_target": "opnsense", + "product_version": "24.7.7", + "product_abi": "24.7", + "reinstall_packages": [], + "remove_packages": [], + "repository": "ok", + "upgrade_major_message": "", + "upgrade_major_version": "", + "upgrade_needs_reboot": "0", + "upgrade_packages": [], + "upgrade_sets": [] + }, + "product_conflicts": "os-firewall os-firewall-devel os-wireguard os-wireguard-devel os-wireguard-go os-wireguard-go-devel", + "product_copyright_owner": "Deciso B.V.", + "product_copyright_url": "https:\/\/www.deciso.com\/", + "product_copyright_years": "2014-2024", + "product_email": "project@opnsense.org", + "product_hash": "f20b6eaa5", + "product_id": "opnsense", + "product_latest": "24.7.7", + "product_license": [], + "product_log": 0, + "product_mirror": "https:\/\/pkg.opnsense.org\/FreeBSD:14:amd64\/24.7", + "product_name": "OPNsense", + "product_nickname": "Thriving Tiger", + "product_repos": "OPNsense (Priority: 11)", + "product_series": "24.7", + "product_tier": "1", + "product_time": "Fri Oct 25 16:58:04 UTC 2024", + "product_version": "24.7.7", + "product_website": "https:\/\/opnsense.org\/" + }, + "all_packages": [], + "all_sets": [], + "status_msg": "There are no updates available on the selected mirror.", + "status": "none" +} \ No newline at end of file diff --git a/tests/unit/agent_based/test_opnsense_firmware.py b/tests/unit/agent_based/test_opnsense_firmware.py new file mode 100644 index 0000000..532cc99 --- /dev/null +++ b/tests/unit/agent_based/test_opnsense_firmware.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8; py-indent-offset: 4 -*- +# +# checkmk_opnsense - Checkmk extension for OPNsense +# +# Copyright (C) 2024 Marius Rieder +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import pytest # type: ignore[import] +import json +from pathlib import Path +from cmk.agent_based.v2 import ( + Result, + Service, + State, + Metric, +) +from cmk_addons.plugins.opnsense.agent_based import opnsense_firmware + +EXAMPLE_STRINGTABLE = [ + [Path('tests/unit/agent_based/test_data/firmware_info/uptodate.json').read_text()] +] + +EXAMPLE_SECTION = json.load(Path('tests/unit/agent_based/test_data/firmware_info/uptodate.json').open()) +EXAMPLE_SECTION_OUTDATED = json.load(Path('tests/unit/agent_based/test_data/firmware_info/outdated.json').open()) +EXAMPLE_SECTION_NOSTATUS = json.load(Path('tests/unit/agent_based/test_data/firmware_info/nostatus.json').open()) +EXAMPLE_SECTION_BUSINESS = { + "product_id": "opnsense-business", + "product": {"product_license": {"valid_to": "2025-07-31"}}, +} + + +@pytest.mark.parametrize('string_table, result', [ + ([], None), + (EXAMPLE_STRINGTABLE, EXAMPLE_SECTION), +]) +def test_parse_opnsense_gateway(string_table, result): + assert opnsense_firmware.parse_opnsense_firmware(string_table) == result + + +@pytest.mark.parametrize('section, result', [ + ({}, []), + (EXAMPLE_SECTION, [Service()]), +]) +def test_discovery_opnsense_gateway(section, result): + assert list(opnsense_firmware.discovery_opnsense_firmware(section)) == result + + +@pytest.mark.parametrize('params, section, result', [ + ({}, EXAMPLE_SECTION, [ + Result(state=State.OK, summary='24.7 (Thriving Tiger)'), + Result(state=State.OK, notice='Last update check: 2 hours 57 minutes'), + Metric('last_check', 10649.0), + Metric('updates', 0.0), + ]), + ({'last_check': ('fixed', (7200, 14400))}, EXAMPLE_SECTION, [ + Result(state=State.OK, summary='24.7 (Thriving Tiger)'), + Result(state=State.WARN, summary='Last update check: 2 hours 57 minutes (warn/crit at 2 hours 0 minutes/4 hours 0 minutes)'), + Metric('last_check', 10649.0, levels=(7200.0, 14400.0)), + Metric('updates', 0.0), + ]), + ({}, EXAMPLE_SECTION_NOSTATUS, [ + Result(state=State.OK, summary='24.7 (Thriving Tiger)'), + Result(state=State.OK, summary='Firmware status requires to check for update first to provide more information.'), + ]), + ({}, EXAMPLE_SECTION_OUTDATED, [ + Result(state=State.OK, summary='24.7 (Thriving Tiger)'), + Result(state=State.OK, notice='Last update check: 3 hours 27 minutes'), + Metric('last_check', 12455.0), + Result(state=State.OK, summary='There are 75 updates available, total download size is 289.8MiB. This update requires a reboot.'), + Metric('updates', 4.0), + ]), +]) +def test_check_opnsense_firmware(freezer, params, section, result): + freezer.move_to('2024-10-25 20:00') + assert list(opnsense_firmware.check_opnsense_firmware(params, section)) == result + + +@pytest.mark.parametrize('section, result', [ + ({}, []), + (EXAMPLE_SECTION, []), + (EXAMPLE_SECTION_BUSINESS, [Service()]), +]) +def test_discovery_opnsense_business(section, result): + assert list(opnsense_firmware.discovery_opnsense_business(section)) == result + + +@pytest.mark.parametrize('params, result', [ + ({}, [ + Result(state=State.OK, summary='License expires in: 278 days'), + Metric('expiredays', 278.0), + ]), + ({'expiredays': ('fixed', (360, 180))}, [ + Result(state=State.WARN, summary='License expires in: 278 days (warn/crit below 360 days/180 days)'), + Metric('expiredays', 278.0), + ]), + ({'expiredays': ('fixed', (360, 300))}, [ + Result(state=State.CRIT, summary='License expires in: 278 days (warn/crit below 360 days/300 days)'), + Metric('expiredays', 278.0), + ]), +]) +def test_check_opnsense_business(freezer, params, result): + freezer.move_to('2024-10-25 20:00') + assert list(opnsense_firmware.check_opnsense_business(params, EXAMPLE_SECTION_BUSINESS)) == result