From 4f0162d5f2c4cd6672de62040ca8048612d3bf42 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 24 Sep 2025 10:34:20 -0700 Subject: [PATCH] Add loading of systemd credentials even when running borgmatic outside of a systemd service (#1123). --- NEWS | 2 + borgmatic/config/schema.yaml | 19 ++++++++ borgmatic/hooks/credential/systemd.py | 24 ++++++++-- docs/how-to/provide-your-passwords.md | 27 +++++++---- tests/unit/hooks/credential/test_systemd.py | 51 ++++++++++++++++++++- 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/NEWS b/NEWS index 8ffa96d6..dc5fe202 100644 --- a/NEWS +++ b/NEWS @@ -1,4 +1,6 @@ 2.0.9.dev0 + * #1123: Add loading of systemd credentials even when running borgmatic outside of a systemd + service. * #1149: Add support for Python 3.14. * #1149: Include automated tests in the source dist tarball uploaded to PyPI. diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 36da3d5e..f65b2f96 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -3002,6 +3002,25 @@ properties: description: | Configuration for integration with Linux LVM (Logical Volume Manager). + systemd: + type: object + additionalProperties: false + properties: + systemd_creds_command: + type: string + description: | + Command to use instead of "systemd-creds". Only used as a + fallback when borgmatic is run outside of a systemd service. + example: /usr/local/bin/systemd-creds + encrypted_credentials_directory: + type: string + description: | + Directory containing encrypted credentials for + "systemd-creds" to use instead of + "/etc/credstore.encrypted". + example: /path/to/credstore.encrypted + description: | + Configuration for integration with systemd credentials. container: type: object additionalProperties: false diff --git a/borgmatic/hooks/credential/systemd.py b/borgmatic/hooks/credential/systemd.py index 4a9dd9ed..7213f396 100644 --- a/borgmatic/hooks/credential/systemd.py +++ b/borgmatic/hooks/credential/systemd.py @@ -1,6 +1,9 @@ import logging import os import re +import shlex + +import borgmatic.execute logger = logging.getLogger(__name__) @@ -24,15 +27,28 @@ def load_credential(hook_config, config, credential_parameters): raise ValueError(f'Cannot load invalid credential name: "{name}"') + if not CREDENTIAL_NAME_PATTERN.match(credential_name): + raise ValueError(f'Cannot load invalid credential name "{credential_name}"') + credentials_directory = os.environ.get('CREDENTIALS_DIRECTORY') if not credentials_directory: - raise ValueError( - f'Cannot load credential "{credential_name}" because the systemd CREDENTIALS_DIRECTORY environment variable is not set', + logger.debug( + f'Falling back to loading credential "{credential_name}" via systemd-creds because the systemd CREDENTIALS_DIRECTORY environment variable is not set' ) - if not CREDENTIAL_NAME_PATTERN.match(credential_name): - raise ValueError(f'Cannot load invalid credential name "{credential_name}"') + command = ( + *shlex.split((hook_config or {}).get('systemd_creds_command', 'systemd-creds')), + 'decrypt', + os.path.join( + (hook_config or {}).get( + 'encrypted_credentials_directory', '/etc/credstore.encrypted' + ), + credential_name, + ), + ) + + return borgmatic.execute.execute_command_and_capture_output(command).rstrip(os.linesep) try: with open( diff --git a/docs/how-to/provide-your-passwords.md b/docs/how-to/provide-your-passwords.md index 7ce3c8c3..2350a1b2 100644 --- a/docs/how-to/provide-your-passwords.md +++ b/docs/how-to/provide-your-passwords.md @@ -127,15 +127,26 @@ encryption_passcommand: cat ${CREDENTIALS_DIRECTORY}/borgmatic_backupserver1 Adjust `borgmatic_backupserver1` according to the name of the credential and the directory set in the service file. -Be aware that when using this systemd `{credential ...}` feature, you may no -longer be able to run certain borgmatic actions outside of the systemd service, -as the credentials are only available from within the context of that service. -So for instance, `borgmatic list` necessarily relies on the -`encryption_passphrase` in order to access the Borg repository, but `list` -shouldn't need to load any credentials for your database or monitoring hooks. +New in version 2.0.9 When using +the systemd `{credential ...}` feature, borgmatic loads systemd credentials even +when run outside of a systemd service. This works by falling back to calling +`systemd-creds decrypt` instead of reading credentials directly. To customize +this behavior, you can override the `systemd-creds` command and/or the +credential store directory it uses: -The one exception is `borgmatic config validate`, which doesn't actually load -any credentials and should continue working anywhere. +```yaml +systemd: + systemd_creds_command: /usr/local/bin/systemd-creds + encrypted_credentials_directory: /path/to/credstore.encrypted +``` + +Prior to version 2.0.9 The +systemd `{credential ...}` feature did not work when run outside of a systemd +service. But depending on the borgmatic action invoked and the configuration +option where `{credential ...}` was used, you could sometimes get away without +working systemd credentials for certain actions. For instance, `borgmatic list` +doesn't connect to any databases or monitoring services, and `borgmatic config +validate` doesn't use credentials as all. ### Container secrets diff --git a/tests/unit/hooks/credential/test_systemd.py b/tests/unit/hooks/credential/test_systemd.py index 80cb3a33..71dbbb9b 100644 --- a/tests/unit/hooks/credential/test_systemd.py +++ b/tests/unit/hooks/credential/test_systemd.py @@ -19,13 +19,60 @@ def test_load_credential_with_invalid_credential_parameters_raises(credential_pa ) -def test_load_credential_without_credentials_directory_raises(): +def test_load_credential_without_credentials_directory_falls_back_to_systemd_creds_command(): flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return( None, ) + flexmock(module.borgmatic.execute).should_receive( + 'execute_command_and_capture_output' + ).with_args(('systemd-creds', 'decrypt', '/etc/credstore.encrypted/mycredential')).and_return( + 'password' + ).once() - with pytest.raises(ValueError): + assert ( module.load_credential(hook_config={}, config={}, credential_parameters=('mycredential',)) + == 'password' + ) + + +def test_load_credential_without_credentials_directory_calls_custom_systemd_creds_command(): + flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return( + None, + ) + flexmock(module.borgmatic.execute).should_receive( + 'execute_command_and_capture_output' + ).with_args( + ('/path/to/systemd-creds', '--flag', 'decrypt', '/etc/credstore.encrypted/mycredential') + ).and_return('password').once() + + assert ( + module.load_credential( + hook_config={'systemd_creds_command': '/path/to/systemd-creds --flag'}, + config={}, + credential_parameters=('mycredential',), + ) + == 'password' + ) + + +def test_load_credential_without_credentials_directory_uses_custom_encrypted_credentials_directory(): + flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return( + None, + ) + flexmock(module.borgmatic.execute).should_receive( + 'execute_command_and_capture_output' + ).with_args(('systemd-creds', 'decrypt', '/my/credstore.encrypted/mycredential')).and_return( + 'password' + ).once() + + assert ( + module.load_credential( + hook_config={'encrypted_credentials_directory': '/my/credstore.encrypted'}, + config={}, + credential_parameters=('mycredential',), + ) + == 'password' + ) def test_load_credential_with_invalid_credential_name_raises():