diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 9e901a32..9536164a 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1609,6 +1609,86 @@ properties: example: - start - finish + zabbix: + type: object + additionalProperties: false + properties: + itemid: + type: integer + description: | + The ID of the Zabbix item used for collecting data. + Unique across the entire Zabbix system. + example: 55105 + host: + type: string + description: | + Host name where the item is stored. Required if "itemid" is not set. + example: borg-server + key: + type: string + description: | + Key of the host where the item is stored. Required if "itemid" is not set. + example: borg.status + server: + type: string + description: | + The address of your Zabbix instance. + example: https://zabbix.your-domain.com + username: + type: string + description: | + The username used for authentication. Not needed if using an API key. + example: testuser + password: + type: string + description: | + The password used for authentication. Not needed if using an API key. + example: fakepassword + api_key: + type: string + description: | + The API key used for authentication. Not needed if using an username/password. + example: fakekey + start: + type: object + properties: + value: + type: ["integer", "string"] + description: | + The value to set the item to on start. + example: STARTED + finish: + type: object + properties: + value: + type: ["integer", "string"] + description: | + The value to set the item to on finish. + example: FINISH + fail: + type: object + properties: + value: + type: ["integer", "string"] + description: | + The value to set the item to on fail. + example: ERROR + states: + type: array + items: + type: string + enum: + - start + - finish + - fail + uniqueItems: true + description: | + List of one or more monitoring states to ping for: "start", + "finish", and/or "fail". Defaults to pinging for failure + only. + example: + - start + - finish apprise: type: object required: ['services'] diff --git a/borgmatic/hooks/dispatch.py b/borgmatic/hooks/dispatch.py index 6131ef6b..be85af8c 100644 --- a/borgmatic/hooks/dispatch.py +++ b/borgmatic/hooks/dispatch.py @@ -14,6 +14,7 @@ from borgmatic.hooks import ( postgresql, sqlite, uptimekuma, + zabbix, ) logger = logging.getLogger(__name__) @@ -32,6 +33,7 @@ HOOK_NAME_TO_MODULE = { 'postgresql_databases': postgresql, 'sqlite_databases': sqlite, 'uptime_kuma': uptimekuma, + 'zabbix': zabbix, } diff --git a/borgmatic/hooks/monitor.py b/borgmatic/hooks/monitor.py index 9512fe99..de4951b7 100644 --- a/borgmatic/hooks/monitor.py +++ b/borgmatic/hooks/monitor.py @@ -9,6 +9,7 @@ MONITOR_HOOK_NAMES = ( 'ntfy', 'pagerduty', 'uptime_kuma', + 'zabbix', ) diff --git a/borgmatic/hooks/zabbix.py b/borgmatic/hooks/zabbix.py new file mode 100644 index 00000000..13f51b64 --- /dev/null +++ b/borgmatic/hooks/zabbix.py @@ -0,0 +1,139 @@ +import json +import logging + +import requests + +logger = logging.getLogger(__name__) + + +def initialize_monitor( + ping_url, config, config_filename, monitoring_log_level, dry_run +): # pragma: no cover + ''' + No initialization is necessary for this monitor. + ''' + pass + + +def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): + ''' + Update the configured Zabbix item using either the itemid, or a host and key. + If this is a dry run, then don't actually update anything. + ''' + + run_states = hook_config.get('states', ['fail']) + + if state.name.lower() not in run_states: + return + + dry_run_label = ' (dry run; not actually updating)' if dry_run else '' + + state_config = hook_config.get( + state.name.lower(), + { + 'value': state.name.lower(), + }, + ) + + server = hook_config.get('server') + username = hook_config.get('username') + password = hook_config.get('password') + api_key = hook_config.get('api_key') + itemid = hook_config.get('itemid') + host = hook_config.get('host') + key = hook_config.get('key') + value = state_config.get('value') + headers = {'Content-Type': 'application/json-rpc'} + + logger.info(f'{config_filename}: Updating Zabbix{dry_run_label}') + logger.debug(f'{config_filename}: Using Zabbix URL: {server}') + + if server is None: + logger.warning(f'{config_filename}: Server missing for Zabbix') + return + + # Determine the zabbix method used to store the value: itemid or host/key + if itemid is not None: + logger.info(f'{config_filename}: Updating {itemid} on Zabbix') + data = { + "jsonrpc": "2.0", + "method": "history.push", + "params": {"itemid": itemid, "value": value}, + "id": 1, + } + + elif (host and key) is not None: + logger.info(f'{config_filename}: Updating Host:{host} and Key:{key} on Zabbix') + data = { + "jsonrpc": "2.0", + "method": "history.push", + "params": {"host": host, "key": key, "value": value}, + "id": 1, + } + + elif host is not None: + logger.warning(f'{config_filename}: Key missing for Zabbix') + return + + elif key is not None: + logger.warning(f'{config_filename}: Host missing for Zabbix.') + return + else: + logger.warning(f'{config_filename}: No zabbix itemid or host/key provided.') + return + + # Determine the authentication method: API key or username/password + if api_key is not None: + logger.info(f'{config_filename}: Using API key auth for Zabbix') + headers['Authorization'] = 'Bearer ' + api_key + + elif (username and password) is not None: + logger.info(f'{config_filename}: Using user/pass auth with user {username} for Zabbix') + auth_data = { + "jsonrpc": "2.0", + "method": "user.login", + "params": { + "username": username, + "password": password + }, + "id": 1 + } + if not dry_run: + logging.getLogger('urllib3').setLevel(logging.ERROR) + try: + response = requests.post(server, headers=headers, json=auth_data) + data['auth'] = response.json().get('result') + if not response.ok: + response.raise_for_status() + except requests.exceptions.RequestException as error: + logger.warning(f'{config_filename}: Zabbix error: {error}') + + elif username is not None: + logger.warning(f'{config_filename}: Password missing for Zabbix authentication') + return + + elif password is not None: + logger.warning(f'{config_filename}: Username missing for Zabbix authentication') + return + else: + logger.warning(f'{config_filename}: Authentication data missing for Zabbix') + return + + + if not dry_run: + logging.getLogger('urllib3').setLevel(logging.ERROR) + try: + response = requests.post(server, headers=headers, json=data) + if not response.ok: + response.raise_for_status() + except requests.exceptions.RequestException as error: + logger.warning(f'{config_filename}: Zabbix error: {error}') + + +def destroy_monitor( + ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run +): # pragma: no cover + ''' + No destruction is necessary for this monitor. + ''' + pass diff --git a/docs/how-to/monitor-your-backups.md b/docs/how-to/monitor-your-backups.md index e96c4040..3ea51735 100644 --- a/docs/how-to/monitor-your-backups.md +++ b/docs/how-to/monitor-your-backups.md @@ -47,6 +47,7 @@ them as backups happen: * [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook) * [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook) * [Uptime Kuma](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook) + * [Zabbix](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook) The idea is that you'll receive an alert when something goes wrong or when the service doesn't hear from borgmatic for a configured interval (if supported). @@ -562,6 +563,58 @@ Heartbeat Retry = 360 # = 10 minutes Resend Notification every X times = 1 ``` +## Zabbix hook + +New in version 1.9.0 +[zabbix](https://www.zabbix.com/) is an open-source monitoring tool used for tracking and managing the performance and availability of networks, servers, and applications in real-time. + +This hook does not do any notifications on its own. Instead, it relies on +your Zabbix instance to notify and perform escalations based on the Zabbix +configuration. The `states` defined in the configuration will determine which states +will trigger the hook. The value defined in the configuration of each state is +used to populate the data of the configured Zabbix item. If none are provided, +it default to a lower-case string of the state. + +An example configuration is shown here with all the available options. + +```yaml +zabbix: + server: http://cloud.zabbix.com/zabbix/api_jsonrpc.php + + username: myuser + password: secret + api_key: b2ecba64d8beb47fc161ae48b164cfd7104a79e8e48e6074ef5b141d8a0aeeca + + host: "borg-server" + key: borg.status + itemid: 55105 + + start: + value: "STARTED" + finish: + value: "OK" + fail: + value: "ERROR" + states: + - start + - finish + - fail +``` + +### Zabbix 7.0+ +This hook requires the Zabbix server be running version 7.0+ + +Authentication Methods +Authentication can be accomplished via `api_key` or `username` and `password`. +If both are declared, `api_key` will be chosen. + +Items The item +to be updated can be chosen by either declaring the `itemid` or +`host` and `key`. If both are declared, `itemid` will be chosen. + +Keep in mind that `host` is referring to the 'Host name' on the +Zabbix host and not the 'Visual name'. + ## Scripting borgmatic diff --git a/tests/unit/hooks/test_zabbix.py b/tests/unit/hooks/test_zabbix.py new file mode 100644 index 00000000..2fe28cca --- /dev/null +++ b/tests/unit/hooks/test_zabbix.py @@ -0,0 +1,324 @@ +from enum import Enum + +from flexmock import flexmock + +import borgmatic.hooks.monitor +from borgmatic.hooks import zabbix as module + +SERVER = 'https://zabbix.com/zabbix/api_jsonrpc.php' +ITEMID = 55105 +USERNAME = 'testuser' +PASSWORD = 'fakepassword' +API_KEY = 'fakekey' +HOST = 'borg-server' +KEY = 'borg.status' +VALUE = 'fail' + +DATA_HOST_KEY = { + "jsonrpc": "2.0", + "method": "history.push", + "params": {"host": HOST, "key": KEY, "value": VALUE}, + "id": 1, +} + +DATA_HOST_KEY_WITH_TOKEN = { + "jsonrpc": "2.0", + "method": "history.push", + "params": {"host": HOST, "key": KEY, "value": VALUE}, + "id": 1, + "auth": "3fe6ed01a69ebd79907a120bcd04e494" +} + +DATA_ITEMID = { + "jsonrpc": "2.0", + "method": "history.push", + "params": {"itemid": ITEMID, "value": VALUE}, + "id": 1, +} + +DATA_HOST_KEY_WITH_TOKEN = { + "jsonrpc": "2.0", + "method": "history.push", + "params": {"itemid": ITEMID, "value": VALUE}, + "id": 1, + "auth": "3fe6ed01a69ebd79907a120bcd04e494" +} + +DATA_USER_LOGIN = { + "jsonrpc": "2.0", + "method": "user.login", + "params": {"username": USERNAME, "password": PASSWORD}, + "id": 1, +} + +AUTH_HEADERS_API_KEY = { + 'Content-Type': 'application/json-rpc', + 'Authorization': f'Bearer {API_KEY}' +} + +AUTH_HEADERS_USERNAME_PASSWORD = { + 'Content-Type': 'application/json-rpc' +} + +def test_ping_monitor_config_with_api_key_only_exit_early(): + # This test should exit early since only providing an API KEY is not enough + # for the hook to work + hook_config = { + 'api_key': API_KEY + } + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_with_host_only_exit_early(): + # This test should exit early since only providing a HOST is not enough + # for the hook to work + hook_config = { + 'host': HOST + } + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_with_key_only_exit_early(): + # This test should exit early since only providing a KEY is not enough + # for the hook to work + hook_config = { + 'key': KEY + } + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_with_server_only_exit_early(): + # This test should exit early since only providing a SERVER is not enough + # for the hook to work + hook_config = { + 'server': SERVER + } + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_user_password_no_zabbix_data_exit_early(): + # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to + hook_config = { + 'server': SERVER, + 'username': USERNAME, + 'password': PASSWORD + } + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_api_key_no_zabbix_data_exit_early(): + # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to + hook_config = { + 'server': SERVER, + 'api_key': API_KEY + } + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_itemid_no_auth_data_exit_early(): + # This test should exit early since there is no authentication provided + # and Zabbix requires authentication to use it's API + hook_config = { + 'server': SERVER, + 'itemid': ITEMID + } + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_host_and_key_no_auth_data_exit_early(): + # This test should exit early since there is no authentication provided + # and Zabbix requires authentication to use it's API + hook_config = { + 'server': SERVER, + 'host': HOST, + 'key': KEY + } + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_host_and_key_with_api_key_auth_data_successful(): + # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY + # to authenticate and HOST/KEY to know which item to populate in Zabbix. + hook_config = { + 'server': SERVER, + 'host': HOST, + 'key': KEY, + 'api_key': API_KEY + } + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_API_KEY, + json=DATA_HOST_KEY, + ).and_return(flexmock(ok=True)).once() + flexmock(module.logger).should_receive('warning').never() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_host_and_key_with_username_password_auth_data_successful(): + # This test should simulate a successful POST to a Zabbix server. This test uses USERNAME/PASSWORD + # to authenticate and HOST/KEY to know which item to populate in Zabbix. + hook_config = { + 'server': SERVER, + 'host': HOST, + 'key': KEY, + 'username': USERNAME, + 'password': PASSWORD + } + + auth_response = flexmock(ok=True) + auth_response.should_receive('json').and_return({"jsonrpc":"2.0","result":"3fe6ed01a69ebd79907a120bcd04e494","id":1}) + + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_USERNAME_PASSWORD, + json=DATA_USER_LOGIN, + ).and_return(auth_response).once() + + flexmock(module.logger).should_receive('warning').never() + + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_USERNAME_PASSWORD, + json=DATA_HOST_KEY_WITH_TOKEN, + ).and_return(flexmock(ok=True)).once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_itemid_with_api_key_auth_data_successful(): + # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY + # to authenticate and HOST/KEY to know which item to populate in Zabbix. + hook_config = { + 'server': SERVER, + 'itemid': ITEMID, + 'api_key': API_KEY + } + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_API_KEY, + json=DATA_ITEMID, + ).and_return(flexmock(ok=True)).once() + flexmock(module.logger).should_receive('warning').never() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + +def test_ping_monitor_config_itemid_with_username_password_auth_data_successful(): + # This test should simulate a successful POST to a Zabbix server. This test uses USERNAME/PASSWORD + # to authenticate and HOST/KEY to know which item to populate in Zabbix. + hook_config = { + 'server': SERVER, + 'itemid': ITEMID, + 'username': USERNAME, + 'password': PASSWORD + } + + auth_response = flexmock(ok=True) + auth_response.should_receive('json').and_return({"jsonrpc":"2.0","result":"3fe6ed01a69ebd79907a120bcd04e494","id":1}) + + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_USERNAME_PASSWORD, + json=DATA_USER_LOGIN, + ).and_return(auth_response).once() + + flexmock(module.logger).should_receive('warning').never() + + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_USERNAME_PASSWORD, + json=DATA_HOST_KEY_WITH_TOKEN, + ).and_return(flexmock(ok=True)).once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) +test_ping_monitor_config_itemid_with_username_password_auth_data_successful() \ No newline at end of file