Quellcode durchsuchen

Merge pull request #817 from enkore/feature/append-only

Feature append only
TW vor 9 Jahren
Ursprung
Commit
8cfc930066
3 geänderte Dateien mit 112 neuen und 1 gelöschten Zeilen
  1. 12 1
      borg/repository.py
  2. 28 0
      borg/testsuite/repository.py
  3. 72 0
      docs/usage.rst

+ 12 - 1
borg/repository.py

@@ -1,5 +1,6 @@
 from configparser import ConfigParser
 from configparser import ConfigParser
 from binascii import hexlify, unhexlify
 from binascii import hexlify, unhexlify
+from datetime import datetime
 from itertools import islice
 from itertools import islice
 import errno
 import errno
 import logging
 import logging
@@ -84,6 +85,7 @@ class Repository:
         config.set('repository', 'version', '1')
         config.set('repository', 'version', '1')
         config.set('repository', 'segments_per_dir', str(self.DEFAULT_SEGMENTS_PER_DIR))
         config.set('repository', 'segments_per_dir', str(self.DEFAULT_SEGMENTS_PER_DIR))
         config.set('repository', 'max_segment_size', str(self.DEFAULT_MAX_SEGMENT_SIZE))
         config.set('repository', 'max_segment_size', str(self.DEFAULT_MAX_SEGMENT_SIZE))
+        config.set('repository', 'append_only', '0')
         config.set('repository', 'id', hexlify(os.urandom(32)).decode('ascii'))
         config.set('repository', 'id', hexlify(os.urandom(32)).decode('ascii'))
         self.save_config(path, config)
         self.save_config(path, config)
 
 
@@ -105,6 +107,8 @@ class Repository:
     def destroy(self):
     def destroy(self):
         """Destroy the repository at `self.path`
         """Destroy the repository at `self.path`
         """
         """
+        if self.append_only:
+            raise ValueError(self.path + " is in append-only mode")
         self.close()
         self.close()
         os.remove(os.path.join(self.path, 'config'))  # kill config first
         os.remove(os.path.join(self.path, 'config'))  # kill config first
         shutil.rmtree(self.path)
         shutil.rmtree(self.path)
@@ -148,6 +152,7 @@ class Repository:
             raise self.InvalidRepository(path)
             raise self.InvalidRepository(path)
         self.max_segment_size = self.config.getint('repository', 'max_segment_size')
         self.max_segment_size = self.config.getint('repository', 'max_segment_size')
         self.segments_per_dir = self.config.getint('repository', 'segments_per_dir')
         self.segments_per_dir = self.config.getint('repository', 'segments_per_dir')
+        self.append_only = self.config.getboolean('repository', 'append_only', fallback=False)
         self.id = unhexlify(self.config.get('repository', 'id').strip())
         self.id = unhexlify(self.config.get('repository', 'id').strip())
         self.io = LoggedIO(self.path, self.max_segment_size, self.segments_per_dir)
         self.io = LoggedIO(self.path, self.max_segment_size, self.segments_per_dir)
 
 
@@ -163,7 +168,8 @@ class Repository:
         """Commit transaction
         """Commit transaction
         """
         """
         self.io.write_commit()
         self.io.write_commit()
-        self.compact_segments(save_space=save_space)
+        if not self.append_only:
+            self.compact_segments(save_space=save_space)
         self.write_index()
         self.write_index()
         self.rollback()
         self.rollback()
 
 
@@ -211,6 +217,9 @@ class Repository:
         self.index.write(os.path.join(self.path, 'index.tmp'))
         self.index.write(os.path.join(self.path, 'index.tmp'))
         os.rename(os.path.join(self.path, 'index.tmp'),
         os.rename(os.path.join(self.path, 'index.tmp'),
                   os.path.join(self.path, 'index.%d' % transaction_id))
                   os.path.join(self.path, 'index.%d' % transaction_id))
+        if self.append_only:
+            with open(os.path.join(self.path, 'transactions'), 'a') as log:
+                print('transaction %d, UTC time %s' % (transaction_id, datetime.utcnow().isoformat()), file=log)
         # Remove old indices
         # Remove old indices
         current = '.%d' % transaction_id
         current = '.%d' % transaction_id
         for name in os.listdir(self.path):
         for name in os.listdir(self.path):
@@ -323,6 +332,8 @@ class Repository:
         This method verifies all segment checksums and makes sure
         This method verifies all segment checksums and makes sure
         the index is consistent with the data stored in the segments.
         the index is consistent with the data stored in the segments.
         """
         """
+        if self.append_only and repair:
+            raise ValueError(self.path + " is in append-only mode")
         error_found = False
         error_found = False
 
 
         def report_error(msg):
         def report_error(msg):

+ 28 - 0
borg/testsuite/repository.py

@@ -187,6 +187,34 @@ class RepositoryCommitTestCase(RepositoryTestCaseBase):
         self.assert_equal(len(self.repository), 3)
         self.assert_equal(len(self.repository), 3)
 
 
 
 
+class RepositoryAppendOnlyTestCase(RepositoryTestCaseBase):
+    def test_destroy_append_only(self):
+        # Can't destroy append only repo (via the API)
+        self.repository.append_only = True
+        with self.assert_raises(ValueError):
+            self.repository.destroy()
+
+    def test_append_only(self):
+        def segments_in_repository():
+            return len(list(self.repository.io.segment_iterator()))
+        self.repository.put(b'00000000000000000000000000000000', b'foo')
+        self.repository.commit()
+
+        self.repository.append_only = False
+        assert segments_in_repository() == 1
+        self.repository.put(b'00000000000000000000000000000000', b'foo')
+        self.repository.commit()
+        # normal: compact squashes the data together, only one segment
+        assert segments_in_repository() == 1
+
+        self.repository.append_only = True
+        assert segments_in_repository() == 1
+        self.repository.put(b'00000000000000000000000000000000', b'foo')
+        self.repository.commit()
+        # append only: does not compact, only new segments written
+        assert segments_in_repository() == 2
+
+
 class RepositoryCheckTestCase(RepositoryTestCaseBase):
 class RepositoryCheckTestCase(RepositoryTestCaseBase):
 
 
     def list_indices(self):
     def list_indices(self):

+ 72 - 0
docs/usage.rst

@@ -694,3 +694,75 @@ Now, let's see how to restore some LVs from such a backup. ::
     $ # we assume that you created an empty root and home LV and overwrite it now:
     $ # we assume that you created an empty root and home LV and overwrite it now:
     $ borg extract --stdout /mnt/backup::repo dev/vg0/root-snapshot > /dev/vg0/root
     $ borg extract --stdout /mnt/backup::repo dev/vg0/root-snapshot > /dev/vg0/root
     $ borg extract --stdout /mnt/backup::repo dev/vg0/home-snapshot > /dev/vg0/home
     $ borg extract --stdout /mnt/backup::repo dev/vg0/home-snapshot > /dev/vg0/home
+
+
+Append-only mode
+~~~~~~~~~~~~~~~~
+
+A repository can be made "append-only", which means that Borg will never overwrite or
+delete committed data. This is useful for scenarios where multiple machines back up to
+a central backup server using ``borg serve``, since a hacked machine cannot delete
+backups permanently.
+
+To activate append-only mode, edit the repository ``config`` file and add a line
+``append_only=1`` to the ``[repository]`` section (or edit the line if it exists).
+
+In append-only mode Borg will create a transaction log in the ``transactions`` file,
+where each line is a transaction and a UTC timestamp.
+
+Example
++++++++
+
+Suppose an attacker remotely deleted all backups, but your repository was in append-only
+mode. A transaction log in this situation might look like this: ::
+
+    transaction 1, UTC time 2016-03-31T15:53:27.383532
+    transaction 5, UTC time 2016-03-31T15:53:52.588922
+    transaction 11, UTC time 2016-03-31T15:54:23.887256
+    transaction 12, UTC time 2016-03-31T15:55:54.022540
+    transaction 13, UTC time 2016-03-31T15:55:55.472564
+
+From your security logs you conclude the attacker gained access at 15:54:00 and all
+the backups where deleted or replaced by compromised backups. From the log you know
+that transactions 11 and later are compromised. Note that the transaction ID is the
+name of the *last* file in the transaction. For example, transaction 11 spans files 6
+to 11.
+
+In a real attack you'll likely want to keep the compromised repository
+intact to analyze what the attacker tried to achieve. It's also a good idea to make this
+copy just in case something goes wrong during the recovery. Since recovery is done by
+deleting some files, a hard link copy (``cp -al``) is sufficient.
+
+The first step to reset the repository to transaction 5, the last uncompromised transaction,
+is to remove the ``hints.N`` and ``index.N`` files in the repository (these two files are
+always expendable). In this example N is 13.
+
+Then remove or move all segment files from the segment directories in ``data/`` starting
+with file 6::
+
+    rm data/**/{6..13}
+
+That's all to it.
+
+Drawbacks
++++++++++
+
+As data is only appended, and nothing deleted, commands like ``prune`` or ``delete``
+won't free disk space, they merely tag data as deleted in a new transaction.
+
+Note that you can go back-and-forth between normal and append-only operation by editing
+the configuration file, it's not a "one way trip".
+
+Further considerations
+++++++++++++++++++++++
+
+Append-only mode is not respected by tools other than Borg. ``rm`` still works on the
+repository. Make sure that backup client machines only get to access the repository via
+``borg serve``.
+
+Ensure that no remote access is possible if the repository is temporarily set to normal mode
+for e.g. regular pruning.
+
+Further protections can be implemented, but are outside of Borgs scope. For example,
+file system snapshots or wrapping ``borg serve`` to set special permissions or ACLs on
+new data files.