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