Add mTLS support for Loki monitoring hook #1293

Merged
witten merged 1 commit from maxhamon/borgmatic:loki-mtls into main 2026-04-13 16:07:57 +00:00
5 changed files with 121 additions and 5 deletions

View file

@ -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:

View file

@ -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':

View file

@ -89,3 +89,27 @@ for more information.
<span class="minilink minilink-addedin">New in version 2.0.0</span>Set the
defaults for these flags in your borgmatic configuration via the
`monitoring_verbosity`, `list`, and `statistics` options.
### Mutual TLS authentication
<span class="minilink minilink-addedin">New in version **TBD**</span> 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.

View file

@ -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

View file

@ -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)
witten marked this conversation as resolved

Nice tests!!

Nice tests!!