Add loading of systemd credentials even when running borgmatic outside of a systemd service (#1123).

This commit is contained in:
Dan Helfman
2025-09-24 10:34:20 -07:00
parent ed58bc2943
commit 4f0162d5f2
5 changed files with 109 additions and 14 deletions
+2
View File
@@ -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.
+19
View File
@@ -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
+20 -4
View File
@@ -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(
+19 -8
View File
@@ -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.
<span class="minilink minilink-addedin">New in version 2.0.9</span> 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
```
<span class="minilink minilink-addedin">Prior to version 2.0.9</span> 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
+49 -2
View File
@@ -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():