Forráskód Böngészése

Add a passphrase hook so borgmatic can collect the encryption passphrase once and pass it to Borg multiple times (#961).

Dan Helfman 4 hónapja
szülő
commit
26b44699ba

+ 20 - 1
borgmatic/borg/environment.py

@@ -1,5 +1,7 @@
 import os
 import os
 
 
+import borgmatic.hooks.dispatch
+
 OPTION_TO_ENVIRONMENT_VARIABLE = {
 OPTION_TO_ENVIRONMENT_VARIABLE = {
     'borg_base_directory': 'BORG_BASE_DIR',
     'borg_base_directory': 'BORG_BASE_DIR',
     'borg_config_directory': 'BORG_CONFIG_DIR',
     'borg_config_directory': 'BORG_CONFIG_DIR',
@@ -7,7 +9,6 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
     'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
     'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
     'borg_security_directory': 'BORG_SECURITY_DIR',
     'borg_security_directory': 'BORG_SECURITY_DIR',
     'borg_keys_directory': 'BORG_KEYS_DIR',
     'borg_keys_directory': 'BORG_KEYS_DIR',
-    'encryption_passcommand': 'BORG_PASSCOMMAND',
     'encryption_passphrase': 'BORG_PASSPHRASE',
     'encryption_passphrase': 'BORG_PASSPHRASE',
     'ssh_command': 'BORG_RSH',
     'ssh_command': 'BORG_RSH',
     'temporary_directory': 'TMPDIR',
     'temporary_directory': 'TMPDIR',
@@ -36,6 +37,24 @@ def make_environment(config):
         if value:
         if value:
             environment[environment_variable_name] = str(value)
             environment[environment_variable_name] = str(value)
 
 
+    passphrase = borgmatic.hooks.dispatch.call_hook(
+        function_name='load_credential',
+        config=config,
+        hook_name='passcommand',
+        credential_name='encryption_passphrase',
+    )
+
+    # If the passcommand produced a passphrase, send it to Borg via an anonymous pipe.
+    if passphrase:
+        read_file_descriptor, write_file_descriptor = os.pipe()
+        os.write(write_file_descriptor, passphrase.encode('utf-8'))
+        os.close(write_file_descriptor)
+
+        # This, plus subprocess.Popen(..., close_fds=False) in execute.py, is necessary for the Borg
+        # child process to inherit the file descriptor.
+        os.set_inheritable(read_file_descriptor, True)
+        environment['BORG_PASSPHRASE_FD'] = str(read_file_descriptor)
+
     for (
     for (
         option_name,
         option_name,
         environment_variable_name,
         environment_variable_name,

+ 6 - 0
borgmatic/execute.py

@@ -307,6 +307,8 @@ def execute_command(
         shell=shell,
         shell=shell,
         env=environment,
         env=environment,
         cwd=working_directory,
         cwd=working_directory,
+        # Necessary for the passcommand credential hook to work.
+        close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
     )
     )
     if not run_to_completion:
     if not run_to_completion:
         return process
         return process
@@ -354,6 +356,8 @@ def execute_command_and_capture_output(
             shell=shell,
             shell=shell,
             env=environment,
             env=environment,
             cwd=working_directory,
             cwd=working_directory,
+            # Necessary for the passcommand credential hook to work.
+            close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
         )
         )
     except subprocess.CalledProcessError as error:
     except subprocess.CalledProcessError as error:
         if (
         if (
@@ -414,6 +418,8 @@ def execute_command_with_processes(
             shell=shell,
             shell=shell,
             env=environment,
             env=environment,
             cwd=working_directory,
             cwd=working_directory,
+            # Necessary for the passcommand credential hook to work.
+            close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
         )
         )
     except (subprocess.CalledProcessError, OSError):
     except (subprocess.CalledProcessError, OSError):
         # Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
         # Something has gone wrong. So vent each process' output buffer to prevent it from hanging.

+ 0 - 0
borgmatic/hooks/credential/__init__.py


+ 61 - 0
borgmatic/hooks/credential/passcommand.py

@@ -0,0 +1,61 @@
+import functools
+import logging
+import shlex
+
+
+import borgmatic.config.paths
+import borgmatic.execute
+
+
+logger = logging.getLogger(__name__)
+
+
+@functools.cache
+def run_passcommand(passcommand, passphrase_configured, working_directory):
+    '''
+    Run the given passcommand using the given working directory and return the passphrase produced
+    by the command. But bail first if a passphrase is already configured; this mimics Borg's
+    behavior.
+
+    Cache the results so that the passcommand only needs to run—and potentially prompt the user—once
+    per borgmatic invocation.
+    '''
+    if passcommand and passphrase_configured:
+        logger.warning(
+            'Ignoring the "encryption_passcommand" option because "encryption_passphrase" is set'
+        )
+        return None
+
+    return borgmatic.execute.execute_command_and_capture_output(
+        shlex.split(passcommand),
+        working_directory=working_directory,
+    )
+
+
+def load_credential(hook_config, config, credential_name):
+    '''
+    Given the hook configuration dict, the configuration dict, and a credential name to load, call
+    the configured passcommand to produce and return an encryption passphrase. In effect, we're
+    doing an end-run around Borg by invoking its passcommand ourself. This allows us to pass the
+    resulting passphrase to multiple different Borg invocations without the user having to be
+    prompted multiple times.
+
+    If no passcommand is configured, then return None.
+
+    The credential name must be "encryption_passphrase"; that's the only supported credential with
+    this particular hook.
+    '''
+    if credential_name != 'encryption_passphrase':
+        raise ValueError(
+            f'Credential name "{credential_name}" is not supported for the passphrase credential hook'
+        )
+
+    passcommand = config.get('encryption_passcommand')
+
+    if not passcommand:
+        return None
+
+    passphrase = config.get('encryption_passphrase')
+    working_directory = borgmatic.config.paths.get_working_directory(config)
+
+    return run_passcommand(passcommand, bool(passphrase), working_directory)

+ 8 - 6
borgmatic/hooks/dispatch.py

@@ -3,6 +3,7 @@ import importlib
 import logging
 import logging
 import pkgutil
 import pkgutil
 
 
+import borgmatic.hooks.credential
 import borgmatic.hooks.data_source
 import borgmatic.hooks.data_source
 import borgmatic.hooks.monitoring
 import borgmatic.hooks.monitoring
 
 
@@ -10,6 +11,7 @@ logger = logging.getLogger(__name__)
 
 
 
 
 class Hook_type(enum.Enum):
 class Hook_type(enum.Enum):
+    CREDENTIAL = 'credential'
     DATA_SOURCE = 'data_source'
     DATA_SOURCE = 'data_source'
     MONITORING = 'monitoring'
     MONITORING = 'monitoring'
 
 
@@ -40,7 +42,7 @@ def call_hook(function_name, config, hook_name, *args, **kwargs):
     module_name = hook_name.split('_databases')[0]
     module_name = hook_name.split('_databases')[0]
 
 
     # Probe for a data source or monitoring hook module corresponding to the hook name.
     # Probe for a data source or monitoring hook module corresponding to the hook name.
-    for parent_module in (borgmatic.hooks.data_source, borgmatic.hooks.monitoring):
+    for parent_module in (borgmatic.hooks.credential, borgmatic.hooks.data_source, borgmatic.hooks.monitoring):
         if module_name not in get_submodule_names(parent_module):
         if module_name not in get_submodule_names(parent_module):
             continue
             continue
 
 
@@ -62,8 +64,8 @@ def call_hook(function_name, config, hook_name, *args, **kwargs):
 def call_hooks(function_name, config, hook_type, *args, **kwargs):
 def call_hooks(function_name, config, hook_type, *args, **kwargs):
     '''
     '''
     Given a configuration dict, call the requested function of the Python module corresponding to
     Given a configuration dict, call the requested function of the Python module corresponding to
-    each hook of the given hook type (either "data_source" or "monitoring"). Supply each call with
-    the configuration for that hook, and any given args and kwargs.
+    each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
+    call with the configuration for that hook, and any given args and kwargs.
 
 
     Collect any return values into a dict from module name to return value. Note that the module
     Collect any return values into a dict from module name to return value. Note that the module
     name is the name of the hook module itself, which might be different from the hook configuration
     name is the name of the hook module itself, which might be different from the hook configuration
@@ -88,9 +90,9 @@ def call_hooks(function_name, config, hook_type, *args, **kwargs):
 def call_hooks_even_if_unconfigured(function_name, config, hook_type, *args, **kwargs):
 def call_hooks_even_if_unconfigured(function_name, config, hook_type, *args, **kwargs):
     '''
     '''
     Given a configuration dict, call the requested function of the Python module corresponding to
     Given a configuration dict, call the requested function of the Python module corresponding to
-    each hook of the given hook type (either "data_source" or "monitoring"). Supply each call with
-    the configuration for that hook and any given args and kwargs. Collect any return values into a
-    dict from hook name to return value.
+    each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
+    call with the configuration for that hook and any given args and kwargs. Collect any return
+    values into a dict from hook name to return value.
 
 
     Raise AttributeError if the function name is not found in the module.
     Raise AttributeError if the function name is not found in the module.
     Raise anything else that a called function raises. An error stops calls to subsequent functions.
     Raise anything else that a called function raises. An error stops calls to subsequent functions.

+ 1 - 0
docs/reference/source-code.md

@@ -18,6 +18,7 @@ you get started. Starting at the top level, we have:
    * [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion. 
    * [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion. 
    * [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration. Interested in adding a new configuration option? Check out `schema.yaml` here.
    * [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration. Interested in adding a new configuration option? Check out `schema.yaml` here.
    * [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database, filesystem, or monitoring integration? Start here.
    * [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database, filesystem, or monitoring integration? Start here.
+     * [credential](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/credential): Credential hooks—for loading passphrases and secrets from external providers.
      * [data_source](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/data_source): Database and filesystem hooks—anything that produces data or files to go into a backup archive.
      * [data_source](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/data_source): Database and filesystem hooks—anything that produces data or files to go into a backup archive.
      * [monitoring](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/monitoring): Monitoring hooks—integrations with third-party or self-hosted monitoring services.
      * [monitoring](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/monitoring): Monitoring hooks—integrations with third-party or self-hosted monitoring services.
  * [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.
  * [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.