diff --git a/borgmatic/borg/environment.py b/borgmatic/borg/environment.py index 839d0887..082389ab 100644 --- a/borgmatic/borg/environment.py +++ b/borgmatic/borg/environment.py @@ -1,6 +1,7 @@ import os import borgmatic.borg.passcommand +import borgmatic.hooks.credential.tag OPTION_TO_ENVIRONMENT_VARIABLE = { 'borg_base_directory': 'BORG_BASE_DIR', @@ -14,6 +15,8 @@ OPTION_TO_ENVIRONMENT_VARIABLE = { 'temporary_directory': 'TMPDIR', } +CREDENTIAL_OPTIONS = {'encryption_passphrase'} + DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE = { 'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK', 'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK', @@ -37,7 +40,10 @@ def make_environment(config): for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items(): value = config.get(option_name) - if value: + if option_name in CREDENTIAL_OPTIONS: + value = borgmatic.hooks.credential.tag.resolve_credential(value) + + if value is not None: environment[environment_variable_name] = str(value) passphrase = borgmatic.borg.passcommand.get_passphrase_from_passcommand(config) diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index f0d48cbe..bf444a7c 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -534,15 +534,15 @@ def run_actions( def load_configurations( - config_filenames, overrides=None, resolve_env=True, resolve_credentials=True + config_filenames, overrides=None, resolve_env=True ): ''' Given a sequence of configuration filenames, a sequence of configuration file override strings - in the form of "option.suboption=value", whether to resolve environment variables, and whether - to resolve credentials, load and validate each configuration file. Return the results as a tuple - of: dict of configuration filename to corresponding parsed configuration, a sequence of paths - for all loaded configuration files (including includes), and a sequence of logging.LogRecord - instances containing any parse errors. + in the form of "option.suboption=value", and whether to resolve environment variables, load and + validate each configuration file. Return the results as a tuple of: dict of configuration + filename to corresponding parsed configuration, a sequence of paths for all loaded configuration + files (including includes), and a sequence of logging.LogRecord instances containing any parse + errors. Log records are returned here instead of being logged directly because logging isn't yet initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this @@ -572,7 +572,6 @@ def load_configurations( validate.schema_filename(), overrides, resolve_env, - resolve_credentials, ) config_paths.update(paths) logs.extend(parse_logs) @@ -922,7 +921,6 @@ def main(extra_summary_logs=[]): # pragma: no cover config_filenames, global_arguments.overrides, resolve_env=global_arguments.resolve_env and not validate, - resolve_credentials=not validate, ) configuration_parse_errors = ( (max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False diff --git a/borgmatic/config/credential.py b/borgmatic/config/credential.py deleted file mode 100644 index ccec7ded..00000000 --- a/borgmatic/config/credential.py +++ /dev/null @@ -1,37 +0,0 @@ -import borgmatic.hooks.dispatch - -UNSPECIFIED = object() - - -def resolve_credentials(config, item=UNSPECIFIED): - ''' - Resolves values like "!credential hookname credentialname" from the given configuration by - calling relevant hooks to get the actual credential values. The item parameter is used to - support recursing through the config hierarchy; it represents the current piece of config being - looked at. - - Raise ValueError if the config could not be parsed or the credential could not be loaded. - ''' - if item is UNSPECIFIED: - item = config - - if isinstance(item, str): - if item.startswith('!credential '): - try: - (tag_name, hook_name, credential_name) = item.split(' ', 2) - except ValueError: - raise ValueError(f'Cannot load credential with invalid syntax "{item}"') - - return borgmatic.hooks.dispatch.call_hook( - 'load_credential', config, hook_name, credential_name - ) - - if isinstance(item, list): - for index, subitem in enumerate(item): - item[index] = resolve_credentials(config, subitem) - - if isinstance(item, dict): - for key, value in item.items(): - item[key] = resolve_credentials(config, value) - - return item diff --git a/borgmatic/config/validate.py b/borgmatic/config/validate.py index 04b25724..e591a6c5 100644 --- a/borgmatic/config/validate.py +++ b/borgmatic/config/validate.py @@ -5,7 +5,6 @@ import jsonschema import ruamel.yaml import borgmatic.config -import borgmatic.config.credential from borgmatic.config import constants, environment, load, normalize, override @@ -86,14 +85,14 @@ def apply_logical_validation(config_filename, parsed_configuration): def parse_configuration( - config_filename, schema_filename, overrides=None, resolve_env=True, resolve_credentials=True + config_filename, schema_filename, overrides=None, resolve_env=True ): ''' Given the path to a config filename in YAML format, the path to a schema filename in a YAML rendition of JSON Schema format, a sequence of configuration file override strings in the form - of "option.suboption=value", whether to resolve environment variables, and whether to resolve - credentials, return the parsed configuration as a data structure of nested dicts and lists - corresponding to the schema. Example return value: + of "option.suboption=value", and whether to resolve environment variables, return the parsed + configuration as a data structure of nested dicts and lists corresponding to the schema. Example + return value: { 'source_directories': ['/home', '/etc'], @@ -122,9 +121,6 @@ def parse_configuration( if resolve_env: environment.resolve_env_variables(config) - if resolve_credentials: - borgmatic.config.credential.resolve_credentials(config) - logs = normalize.normalize(config_filename, config) try: diff --git a/borgmatic/hooks/credential/systemd.py b/borgmatic/hooks/credential/systemd.py index de3eedaa..0e7cdb47 100644 --- a/borgmatic/hooks/credential/systemd.py +++ b/borgmatic/hooks/credential/systemd.py @@ -30,6 +30,6 @@ def load_credential(hook_config, config, credential_name): with open(os.path.join(credentials_directory, credential_name)) as credential_file: return credential_file.read().rstrip(os.linesep) except (FileNotFoundError, OSError) as error: - logger.error(error) + logger.warning(error) raise ValueError(f'Cannot load credential "{credential_name}" from file: {error.filename}') diff --git a/borgmatic/hooks/credential/tag.py b/borgmatic/hooks/credential/tag.py new file mode 100644 index 00000000..baef0b77 --- /dev/null +++ b/borgmatic/hooks/credential/tag.py @@ -0,0 +1,29 @@ +import functools + +import borgmatic.hooks.dispatch + +IS_A_HOOK = False + + +@functools.cache +def resolve_credential(tag): + ''' + Given a configuration tag string like "!credential hookname credentialname", resolve it by + calling the relevant hook to get the actual credential value. If the given tag is not actually a + credential tag, then return the value unchanged. + + Cache the value so repeated calls to this function don't need to load the credential repeatedly. + + Raise ValueError if the config could not be parsed or the credential could not be loaded. + ''' + if tag and tag.startswith('!credential '): + try: + (tag_name, hook_name, credential_name) = tag.split(' ', 2) + except ValueError: + raise ValueError(f'Cannot load credential with invalid syntax "{tag}"') + + return borgmatic.hooks.dispatch.call_hook( + 'load_credential', {}, hook_name, credential_name + ) + + return tag diff --git a/borgmatic/hooks/monitoring/ntfy.py b/borgmatic/hooks/monitoring/ntfy.py index f56a6047..160b2518 100644 --- a/borgmatic/hooks/monitoring/ntfy.py +++ b/borgmatic/hooks/monitoring/ntfy.py @@ -2,6 +2,8 @@ import logging import requests +import borgmatic.hooks.credential.tag + logger = logging.getLogger(__name__) @@ -47,9 +49,14 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev 'X-Tags': state_config.get('tags'), } - username = hook_config.get('username') - password = hook_config.get('password') - access_token = hook_config.get('access_token') + try: + username = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('username')) + password = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('password')) + access_token = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('access_token')) + except ValueError as error: + logger.warning(f'Ntfy credential error: {error}') + return + auth = None if access_token is not None: diff --git a/borgmatic/hooks/monitoring/pagerduty.py b/borgmatic/hooks/monitoring/pagerduty.py index 6afc27c2..b2ff1153 100644 --- a/borgmatic/hooks/monitoring/pagerduty.py +++ b/borgmatic/hooks/monitoring/pagerduty.py @@ -5,6 +5,7 @@ import platform import requests +import borgmatic.hooks.credential.tag from borgmatic.hooks.monitoring import monitor logger = logging.getLogger(__name__) @@ -39,11 +40,17 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev if dry_run: return + try: + inegration_key = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('integration_key')) + except ValueError as error: + logger.warning(f'PagerDuty credential error: {error}') + return + hostname = platform.node() local_timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat() payload = json.dumps( { - 'routing_key': hook_config['integration_key'], + 'routing_key': integration_key, 'event_action': 'trigger', 'payload': { 'summary': f'backup failed on {hostname}', diff --git a/borgmatic/hooks/monitoring/pushover.py b/borgmatic/hooks/monitoring/pushover.py index 8211c32d..4f86ee0d 100644 --- a/borgmatic/hooks/monitoring/pushover.py +++ b/borgmatic/hooks/monitoring/pushover.py @@ -2,6 +2,8 @@ import logging import requests +import borgmatic.hooks.credential.tag + logger = logging.getLogger(__name__) @@ -32,8 +34,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev state_config = hook_config.get(state.name.lower(), {}) - token = hook_config.get('token') - user = hook_config.get('user') + try: + token = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('token')) + user = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('user')) + except ValueError as error: + logger.warning(f'Pushover credential error: {error}') + return logger.info(f'Updating Pushover{dry_run_label}') diff --git a/borgmatic/hooks/monitoring/zabbix.py b/borgmatic/hooks/monitoring/zabbix.py index 96b0a9da..f66c93a5 100644 --- a/borgmatic/hooks/monitoring/zabbix.py +++ b/borgmatic/hooks/monitoring/zabbix.py @@ -2,6 +2,8 @@ import logging import requests +import borgmatic.hooks.credential.tag + logger = logging.getLogger(__name__) @@ -34,10 +36,15 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev }, ) + try: + username = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('username')) + password = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('password')) + api_key = borgmatic.hooks.credential.tag.resolve_credential(hook_config.get('api_key')) + except ValueError as error: + logger.warning(f'Zabbix credential error: {error}') + return + 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')