From edfa708fa35d1584016020190c59fd7b40434a25 Mon Sep 17 00:00:00 2001 From: Maxime Hamon Date: Tue, 7 Apr 2026 21:34:27 +0200 Subject: [PATCH] Add mTLS support for Loki monitoring hook --- borgmatic/config/schema.yaml | 18 +++++++ borgmatic/hooks/monitoring/loki.py | 35 ++++++++++++-- .../configuration/monitoring/loki.md | 24 ++++++++++ .../integration/hooks/monitoring/test_loki.py | 2 +- tests/unit/hooks/monitoring/test_loki.py | 47 +++++++++++++++++++ 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 76106d89..374ebf34 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -3098,6 +3098,24 @@ properties: Grafana Loki log URL to notify when a backup begins, ends, or fails. example: "http://localhost:3100/loki/api/v1/push" + tls: + type: object + additionalProperties: false + properties: + cert_path: + type: string + description: | + Path to a PEM client certificate file for mutual + TLS authentication. + example: /etc/borgmatic/loki-client.crt + key_path: + type: string + description: | + Path to a PEM private key file for the client + certificate. + example: /etc/borgmatic/loki-client.key + description: | + TLS options for mutual TLS (mTLS) authentication with Loki. labels: type: object additionalProperties: diff --git a/borgmatic/hooks/monitoring/loki.py b/borgmatic/hooks/monitoring/loki.py index afe637b9..0bde9915 100644 --- a/borgmatic/hooks/monitoring/loki.py +++ b/borgmatic/hooks/monitoring/loki.py @@ -27,9 +27,16 @@ class Loki_log_buffer: adding labels to the log stream and takes care of communication with Loki. ''' - def __init__(self, url, dry_run): + def __init__(self, url, dry_run, tls_cert_path=None, tls_key_path=None): + ''' + Given a Loki URL, a dry run flag, and optional TLS certificate and key paths for mTLS authentication, + create an instance of Loki_log_buffer. + ''' + self.url = url self.dry_run = dry_run + self.tls_cert_path = tls_cert_path + self.tls_key_path = tls_key_path self.root = {'streams': [{'stream': {}, 'values': []}]} def add_value(self, value): @@ -77,6 +84,7 @@ class Loki_log_buffer: 'Content-Type': 'application/json', 'User-Agent': 'borgmatic', }, + cert=(self.tls_cert_path, self.tls_key_path) if self.tls_cert_path else None, ) result.raise_for_status() except requests.RequestException: @@ -88,7 +96,7 @@ class Loki_log_handler(logging.Handler): A log handler that sends logs to Loki. ''' - def __init__(self, url, send_logs, log_level, dry_run): + def __init__(self, url, send_logs, log_level, dry_run, tls_cert_path=None, tls_key_path=None): ''' Given a URL to send logs to, whether all borgmatic logs should be sent (or just explicitly added messages from this hook), the log level to use (influencing which logs get sent), and @@ -96,7 +104,9 @@ class Loki_log_handler(logging.Handler): ''' super().__init__() - self.buffer = Loki_log_buffer(url, dry_run) + self.buffer = Loki_log_buffer( + url, dry_run, tls_cert_path=tls_cert_path, tls_key_path=tls_key_path + ) self.send_logs = send_logs self.setLevel(log_level) @@ -139,7 +149,24 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve Add a handler to the root logger to regularly send the logs to Loki. ''' url = hook_config.get('url') - loki = Loki_log_handler(url, hook_config.get('send_logs', False), monitoring_log_level, dry_run) + tls = hook_config.get('tls', {}) + + if bool(tls.get('cert_path')) != bool(tls.get('key_path')): + logger.critical( + 'Invalid Loki TLS configuration: cert_path and key_path must both be set or both be unset' + ) + raise ValueError( + 'Invalid Loki TLS configuration: cert_path and key_path must both be set or both be unset' + ) + + loki = Loki_log_handler( + url, + hook_config.get('send_logs', False), + monitoring_log_level, + dry_run, + tls_cert_path=tls.get('cert_path'), + tls_key_path=tls.get('key_path'), + ) for key, value in hook_config.get('labels').items(): if value == '__hostname': diff --git a/docs/reference/configuration/monitoring/loki.md b/docs/reference/configuration/monitoring/loki.md index b9374f9f..1ee54fd4 100644 --- a/docs/reference/configuration/monitoring/loki.md +++ b/docs/reference/configuration/monitoring/loki.md @@ -89,3 +89,27 @@ for more information. New in version 2.0.0Set the defaults for these flags in your borgmatic configuration via the `monitoring_verbosity`, `list`, and `statistics` options. + + +### Mutual TLS authentication + +New in version **TBD** Since +Loki does not come with a built-in authentication layer [(doc)](https://grafana.com/docs/loki/latest/operations/authentication/), this feature is typically used +alongside a reverse proxy (such as [nginx](https://docs.nginx.com/waf/configure/secure-mtls/) or +[Traefik](https://doc.traefik.io/traefik/reference/routing-configuration/http/tls/tls-options/#client-authentication-mtls)) that handles mTLS termination. +If your setup is configured for mTLS authentication, you can provide a client certificate and private key: + +```yaml +loki: + url: https://loki.fqdn/loki/api/v1/push + + labels: + app: borgmatic + + tls: + cert_path: /etc/borgmatic/loki-client.crt + key_path: /etc/borgmatic/loki-client.key +``` + +Both `cert_path` and `key_path` must be [PEM-encoded](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail). They are passed directly +to the underlying HTTP client, so the standard mutual TLS handshake is performed for every request borgmatic sends to Loki. diff --git a/tests/integration/hooks/monitoring/test_loki.py b/tests/integration/hooks/monitoring/test_loki.py index 0b08f08d..2f3d3a6d 100644 --- a/tests/integration/hooks/monitoring/test_loki.py +++ b/tests/integration/hooks/monitoring/test_loki.py @@ -90,7 +90,7 @@ def test_ping_monitor_sends_log_message(): config_filename = 'test.yaml' post_called = False - def post(url, data, timeout, headers): + def post(url, data, timeout, headers, **kwargs): nonlocal post_called post_called = True diff --git a/tests/unit/hooks/monitoring/test_loki.py b/tests/unit/hooks/monitoring/test_loki.py index c72c2619..bbf06284 100644 --- a/tests/unit/hooks/monitoring/test_loki.py +++ b/tests/unit/hooks/monitoring/test_loki.py @@ -1,5 +1,6 @@ import json +import pytest from flexmock import flexmock from borgmatic.hooks.monitoring import loki as module @@ -78,3 +79,49 @@ def test_loki_log_handler_flush_with_empty_buffer_does_not_raise(): handler = module.Loki_log_handler(flexmock(), send_logs=False, log_level=10, dry_run=False) handler.flush() + + +def test_loki_log_buffer_init_with_tls_stores_cert_and_key_paths(): + buffer = module.Loki_log_buffer( + flexmock(), + dry_run=False, + tls_cert_path='/path/to/cert.crt', + tls_key_path='/path/to/key.key', + ) + + assert buffer.tls_cert_path == '/path/to/cert.crt' + assert buffer.tls_key_path == '/path/to/key.key' + + +def test_loki_log_handler_init_with_tls_passes_paths_to_buffer(): + handler = module.Loki_log_handler( + flexmock(), + send_logs=False, + log_level=10, + dry_run=False, + tls_cert_path='/path/to/cert.crt', + tls_key_path='/path/to/key.key', + ) + + assert handler.buffer.tls_cert_path == '/path/to/cert.crt' + assert handler.buffer.tls_key_path == '/path/to/key.key' + + +def test_initialize_monitor_with_only_cert_path_raises(): + hook_config = { + 'url': 'http://localhost:3100/loki/api/v1/push', + 'tls': {'cert_path': '/path/to/cert.crt'}, + } + + with pytest.raises(ValueError): + module.initialize_monitor(hook_config, {}, 'test.yaml', 1, False) + + +def test_initialize_monitor_with_only_key_path_raises(): + hook_config = { + 'url': 'http://localhost:3100/loki/api/v1/push', + 'tls': {'key_path': '/path/to/key.key'}, + } + + with pytest.raises(ValueError): + module.initialize_monitor(hook_config, {}, 'test.yaml', 1, False)