netcmd: domain backup offline command
authorAaron Haslett <aaronhaslett@catalyst.net.nz>
Tue, 26 Jun 2018 01:47:42 +0000 (13:47 +1200)
committerGary Lockyer <gary@samba.org>
Mon, 6 Aug 2018 03:37:42 +0000 (05:37 +0200)
Unlike the existing 'domain backup online' command, this command allows an
admin to back up a local samba installation using the filesystem and the
tdbbackup tool instead of using remote protocols.  It replaces samba_backup
as that tool does not handle sam.ldb and secrets.ldb correctly.  Those two
databases need to have transactions started on them before their downstream
ldb and tdb files are backed up.

Signed-off-by: Aaron Haslett <aaronhaslett@catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet@samba.org>
Reviewed-by: Gary Lockyer <gary@catalyst.net.nz>
docs-xml/manpages/samba-tool.8.xml
python/samba/mdb_util.py
python/samba/netcmd/domain_backup.py
python/samba/tdb_util.py
source4/scripting/bin/samba_backup [deleted file]

index e4b278e742f4521421db2ddec2b6e964654fa606..2c043b90fcacf084b1c4400c8d7b392822bdb8a1 100644 (file)
        <para>Create or restore a backup of the domain.</para>
 </refsect3>
 
+<refsect3>
+       <title>domain backup offline</title>
+       <para>Backup (with proper locking) local domain directories into a tar file.</para>
+</refsect3>
+
 <refsect3>
        <title>domain backup online</title>
        <para>Copy a running DC's current DB into a backup tar file.</para>
index 4fc6c3e025868496f9195958a3c8f4704c0a8864..4dbff48b05a2da2dee8b6671509966cecd5a416c 100644 (file)
@@ -32,9 +32,6 @@ def mdb_copy(file1, file2):
             break
 
     mdb_copy_cmd = [toolpath, "-n", file1, "%s.copy.mdb" % file1]
-    status = subprocess.call(mdb_copy_cmd, close_fds=True, shell=False)
+    status = subprocess.check_call(mdb_copy_cmd, close_fds=True, shell=False)
 
-    if status == 0:
-        os.rename("%s.copy.mdb" % file1, file2)
-    else:
-        raise Exception("Error copying %d  %s" % (status, file1))
+    os.rename("%s.copy.mdb" % file1, file2)
index f7b8edb28408a3bc5f75442204079a07db8072c8..05146c083d78ac915d6909f0441ad8351d7570b5 100644 (file)
@@ -23,11 +23,12 @@ import logging
 import shutil
 import tempfile
 import samba
+import tdb
 import samba.getopt as options
 from samba.samdb import SamDB
 import ldb
 from samba import smb
-from samba.ntacls import backup_online, backup_restore
+from samba.ntacls import backup_online, backup_restore, backup_offline
 from samba.auth import system_session
 from samba.join import DCJoinContext, join_clone, DCCloneAndRenameContext
 from samba.dcerpc.security import dom_sid
@@ -45,6 +46,11 @@ from samba.provision import guess_names, determine_host_ip, determine_host_ip6
 from samba.provision.sambadns import (fill_dns_data_partitions,
                                       get_dnsadmins_sid,
                                       get_domainguid)
+from samba.tdb_util import tdb_copy
+from samba.mdb_util import mdb_copy
+import errno
+import tdb
+from subprocess import CalledProcessError
 
 
 # work out a SID (based on a free RID) to use when the domain gets restored.
@@ -772,8 +778,234 @@ class cmd_domain_backup_rename(samba.netcmd.Command):
         shutil.rmtree(tmpdir)
 
 
+class cmd_domain_backup_offline(samba.netcmd.Command):
+    '''Backup the local domain directories safely into a tar file.
+
+    Takes a backup copy of the current domain from the local files on disk,
+    with proper locking of the DB to ensure consistency. If the domain were to
+    undergo a catastrophic failure, then the backup file can be used to recover
+    the domain.
+
+    An offline backup differs to an online backup in the following ways:
+    - a backup can be created even if the DC isn't currently running.
+    - includes non-replicated attributes that an online backup wouldn't store.
+    - takes a copy of the raw database files, which has the risk that any
+      hidden problems in the DB are preserved in the backup.'''
+
+    synopsis = "%prog [options]"
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+    }
+
+    takes_options = [
+        Option("--targetdir",
+               help="Output directory (required)",
+               type=str),
+    ]
+
+    backup_ext = '.bak-offline'
+
+    def offline_tdb_copy(self, path):
+        backup_path = path + self.backup_ext
+        try:
+            tdb_copy(path, backup_path, readonly=True)
+        except CalledProcessError as copy_err:
+            # If the copy didn't work, check if it was caused by an EINVAL
+            # error on opening the DB.  If so, it's a mutex locked database,
+            # which we can safely ignore.
+            try:
+                tdb.open(path)
+            except Exception as e:
+                if hasattr(e, 'errno') and e.errno == errno.EINVAL:
+                    return
+                raise e
+            raise copy_err
+        if not os.path.exists(backup_path):
+            s = "tdbbackup said backup succeeded but {} not found"
+            raise CommandError(s.format(backup_path))
+
+    def offline_mdb_copy(self, path):
+        mdb_copy(path, path + self.backup_ext)
+
+    # Secrets databases are a special case: a transaction must be started
+    # on the secrets.ldb file before backing up that file and secrets.tdb
+    def backup_secrets(self, private_dir, lp, logger):
+        secrets_path = os.path.join(private_dir, 'secrets')
+        secrets_obj = Ldb(secrets_path + '.ldb', lp=lp)
+        logger.info('Starting transaction on ' + secrets_path)
+        secrets_obj.transaction_start()
+        self.offline_tdb_copy(secrets_path + '.ldb')
+        self.offline_tdb_copy(secrets_path + '.tdb')
+        secrets_obj.transaction_cancel()
+
+    # sam.ldb must have a transaction started on it before backing up
+    # everything in sam.ldb.d with the appropriate backup function.
+    def backup_smb_dbs(self, private_dir, samdb, lp, logger):
+        # First, determine if DB backend is MDB.  Assume not unless there is a
+        # 'backendStore' attribute on @PARTITION containing the text 'mdb'
+        store_label = "backendStore"
+        res = samdb.search(base="@PARTITION", scope=ldb.SCOPE_BASE,
+                           attrs=[store_label])
+        mdb_backend = store_label in res[0] and res[0][store_label][0] == 'mdb'
+
+        sam_ldb_path = os.path.join(private_dir, 'sam.ldb')
+        copy_function = None
+        if mdb_backend:
+            logger.info('MDB backend detected.  Using mdb backup function.')
+            copy_function = self.offline_mdb_copy
+        else:
+            logger.info('Starting transaction on ' + sam_ldb_path)
+            copy_function = self.offline_tdb_copy
+            sam_obj = Ldb(sam_ldb_path, lp=lp)
+            sam_obj.transaction_start()
+
+        logger.info('   backing up ' + sam_ldb_path)
+        self.offline_tdb_copy(sam_ldb_path)
+        sam_ldb_d = sam_ldb_path + '.d'
+        for sam_file in os.listdir(sam_ldb_d):
+            sam_file = os.path.join(sam_ldb_d, sam_file)
+            if sam_file.endswith('.ldb'):
+                logger.info('   backing up locked/related file ' + sam_file)
+                copy_function(sam_file)
+            else:
+                logger.info('   copying locked/related file ' + sam_file)
+                shutil.copyfile(sam_file, sam_file + self.backup_ext)
+
+        if not mdb_backend:
+            sam_obj.transaction_cancel()
+
+    # Find where a path should go in the fixed backup archive structure.
+    def get_arc_path(self, path, conf_paths):
+        backup_dirs = {"private": conf_paths.private_dir,
+                       "statedir": conf_paths.state_dir,
+                       "etc": os.path.dirname(conf_paths.smbconf)}
+        matching_dirs = [(_, p) for (_, p) in backup_dirs.items() if
+                         path.startswith(p)]
+        arc_path, fs_path = matching_dirs[0]
+
+        # If more than one directory is a parent of this path, then at least
+        # one configured path is a subdir of another. Use closest match.
+        if len(matching_dirs) > 1:
+            arc_path, fs_path = max(matching_dirs, key=lambda (_, p): len(p))
+        arc_path += path[len(fs_path):]
+
+        return arc_path
+
+    def run(self, sambaopts=None, targetdir=None):
+
+        logger = logging.getLogger()
+        logger.setLevel(logging.DEBUG)
+        logger.addHandler(logging.StreamHandler(sys.stdout))
+
+        # Get the absolute paths of all the directories we're going to backup
+        lp = sambaopts.get_loadparm()
+
+        paths = samba.provision.provision_paths_from_lp(lp, lp.get('realm'))
+        if not (paths.samdb and os.path.exists(paths.samdb)):
+            raise CommandError('No sam.db found.  This backup ' +
+                               'tool is only for AD DCs')
+
+        check_targetdir(logger, targetdir)
+
+        samdb = SamDB(url=paths.samdb, session_info=system_session(), lp=lp)
+        sid = get_sid_for_restore(samdb)
+
+        backup_dirs = [paths.private_dir, paths.state_dir,
+                       os.path.dirname(paths.smbconf)]  # etc dir
+        logger.info('running backup on dirs: {}'.format(backup_dirs))
+
+        # Recursively get all file paths in the backup directories
+        all_files = []
+        for backup_dir in backup_dirs:
+            for (working_dir, _, filenames) in os.walk(backup_dir):
+                if working_dir.startswith(paths.sysvol):
+                    continue
+
+                for filename in filenames:
+                    if filename in all_files:
+                        continue
+
+                    # Assume existing backup files are from a previous backup.
+                    # Delete and ignore.
+                    if filename.endswith(self.backup_ext):
+                        os.remove(os.path.join(working_dir, filename))
+                        continue
+                    all_files.append(os.path.join(working_dir, filename))
+
+        # Backup secrets, sam.ldb and their downstream files
+        self.backup_secrets(paths.private_dir, lp, logger)
+        self.backup_smb_dbs(paths.private_dir, samdb, lp, logger)
+
+        # Open the new backed up samdb, flag it as backed up, and write
+        # the next SID so the restore tool can add objects.
+        # WARNING: Don't change this code unless you know what you're doing.
+        #          Writing to a .bak file only works because the DN being
+        #          written to happens to be top level.
+        samdb = SamDB(url=paths.samdb + self.backup_ext,
+                      session_info=system_session(), lp=lp)
+        time_str = get_timestamp()
+        add_backup_marker(samdb, "backupDate", time_str)
+        add_backup_marker(samdb, "sidForRestore", sid)
+
+        # Now handle all the LDB and TDB files that are not linked to
+        # anything else.  Use transactions for LDBs.
+        for path in all_files:
+            if not os.path.exists(path + self.backup_ext):
+                if path.endswith('.ldb'):
+                    logger.info('Starting transaction on solo db: ' + path)
+                    ldb_obj = Ldb(path, lp=lp)
+                    ldb_obj.transaction_start()
+                    logger.info('   running tdbbackup on the same file')
+                    self.offline_tdb_copy(path)
+                    ldb_obj.transaction_cancel()
+                elif path.endswith('.tdb'):
+                    logger.info('running tdbbackup on lone tdb file ' + path)
+                    self.offline_tdb_copy(path)
+
+        # Now make the backup tar file and add all
+        # backed up files and any other files to it.
+        temp_tar_dir = tempfile.mkdtemp(dir=targetdir,
+                                        prefix='INCOMPLETEsambabackupfile')
+        temp_tar_name = os.path.join(temp_tar_dir, "samba-backup.tar.bz2")
+        tar = tarfile.open(temp_tar_name, 'w:bz2')
+
+        logger.info('running offline ntacl backup of sysvol')
+        sysvol_tar_fn = 'sysvol.tar.gz'
+        sysvol_tar = os.path.join(temp_tar_dir, sysvol_tar_fn)
+        backup_offline(paths.sysvol, sysvol_tar, samdb, paths.smbconf)
+        tar.add(sysvol_tar, sysvol_tar_fn)
+        os.remove(sysvol_tar)
+
+        create_log_file(temp_tar_dir, lp, "offline", "localhost", True)
+        backup_fn = os.path.join(temp_tar_dir, "backup.txt")
+        tar.add(backup_fn, os.path.basename(backup_fn))
+        os.remove(backup_fn)
+
+        logger.info('building backup tar')
+        for path in all_files:
+            arc_path = self.get_arc_path(path, paths)
+
+            if os.path.exists(path + self.backup_ext):
+                logger.info('   adding backup ' + arc_path + self.backup_ext +
+                            ' to tar and deleting file')
+                tar.add(path + self.backup_ext, arcname=arc_path)
+                os.remove(path + self.backup_ext)
+            elif path.endswith('.ldb') or path.endswith('.tdb'):
+                logger.info('   skipping ' + arc_path)
+            else:
+                logger.info('   adding misc file ' + arc_path)
+                tar.add(path, arcname=arc_path)
+
+        tar.close()
+        os.rename(temp_tar_name, os.path.join(targetdir,
+                  'samba-backup-{}.tar.bz2'.format(time_str)))
+        os.rmdir(temp_tar_dir)
+        logger.info('Backup succeeded.')
+
+
 class cmd_domain_backup(samba.netcmd.SuperCommand):
     '''Create or restore a backup of the domain.'''
-    subcommands = {'online': cmd_domain_backup_online(),
+    subcommands = {'offline': cmd_domain_backup_offline(),
+                   'online': cmd_domain_backup_online(),
                    'rename': cmd_domain_backup_rename(),
                    'restore': cmd_domain_backup_restore()}
index d967434e5b852e995fa7da7b6a0b38dd6b82245a..60ecee11880b8f587b542b40cc00573328c9325c 100644 (file)
@@ -22,7 +22,7 @@ import samba
 import subprocess
 import os
 
-def tdb_copy(file1, file2):
+def tdb_copy(file1, file2, readonly=False):
     """Copy tdb file using tdbbackup utility and rename it
     """
     # Find the location of tdbbackup tool
@@ -33,9 +33,9 @@ def tdb_copy(file1, file2):
             break
 
     tdbbackup_cmd = [toolpath, "-s", ".copy.tdb", file1]
-    status = subprocess.call(tdbbackup_cmd, close_fds=True, shell=False)
+    if readonly:
+        tdbbackup_cmd.append("-r")
 
-    if status == 0:
-        os.rename("%s.copy.tdb" % file1, file2)
-    else:
-        raise Exception("Error copying %s" % file1)
+    status = subprocess.check_call(tdbbackup_cmd, close_fds=True, shell=False)
+
+    os.rename("%s.copy.tdb" % file1, file2)
diff --git a/source4/scripting/bin/samba_backup b/source4/scripting/bin/samba_backup
deleted file mode 100755 (executable)
index 3e22abe..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/bin/sh
-#
-# Copyright (C) Matthieu Patou <mat@matws.net> 2010-2011
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-#
-# Revised 2013-09-25, Brian Martin, as follows:
-#    - Allow retention period ("DAYS") to be specified as a parameter.
-#    - Allow individual positional parameters to be left at the default
-#      by specifying "-"
-#    - Use IS0 8601 standard dates (yyyy-mm-dd instead of mmddyyyy).
-#    - Display tar exit codes when reporting errors.
-#    - Don't send error messages to /dev/null, so we know what failed.
-#    - Suppress useless tar "socket ignored" message.
-#    - Fix retention period bug when deleting old backups ($DAYS variable
-#      could be set, but was ignored).
-
-
-
-FROMWHERE=/usr/local/samba
-WHERE=/usr/local/backups
-DAYS=90                                # Set default retention period.
-if [ -n "$1" ] && [ "$1" = "-h" -o "$1" = "--usage" ]; then
-       echo "samba_backup [provisiondir] [destinationdir] [retpd]"
-       echo "Will backup your provision located in provisiondir to archive stored"
-       echo "in destinationdir for retpd days. Use - to leave an option unchanged."
-       echo "Default provisiondir: $FROMWHERE"
-       echo "Default destinationdir: $WHERE"
-       echo "Default destinationdir: $DAYS"
-       exit 0
-fi
-
-[ -n "$1" -a "$1" != "-" ]&&FROMWHERE=$1       # Use parm or default if "-".  Validate later.
-[ -n "$2" -a "$2" != "-" ]&&WHERE=$2           # Use parm or default if "-".  Validate later.
-[ -n "$3" -a "$3" -eq "$3" 2> /dev/null ]&&DAYS=$3     # Use parm or default if non-numeric (incl "-").
-
-DIRS="private etc sysvol"
-#Number of days to keep the backup
-WHEN=`date +%Y-%m-%d`  # ISO 8601 standard date.
-
-if [ ! -d $WHERE ]; then
-       echo "Missing backup directory $WHERE"
-       exit 1
-fi
-
-if [ ! -d $FROMWHERE ]; then
-       echo "Missing or wrong provision directory $FROMWHERE"
-       exit 1
-fi
-
-cd $FROMWHERE
-for d in $DIRS;do
-       relativedirname=`find . -type d -name "$d" -prune`
-       n=`echo $d | sed 's/\//_/g'`
-       if [ "$d" = "private" ]; then
-               find $relativedirname -name "*.ldb.bak" -exec rm {} \;
-               for ldb in `find $relativedirname -name "*.ldb"`; do
-                       tdbbackup $ldb
-                       Status=$?       # Preserve $? for message, since [ alters it.
-                       if [ $Status -ne 0 ]; then
-                               echo "Error while backing up $ldb - status $Status"
-                               exit 1
-                       fi
-               done
-               # Run the backup.
-               #    --warning=no-file-ignored set to suppress "socket ignored" messages.
-               tar cjf ${WHERE}/samba4_${n}.${WHEN}.tar.bz2  $relativedirname --exclude=\*.ldb --warning=no-file-ignored --transform 's/.ldb.bak$/.ldb/'
-               Status=$?       # Preserve $? for message, since [ alters it.
-               if [ $Status -ne 0 -a $Status -ne 1 ]; then     # Ignore 1 - private dir is always changing.
-                       echo "Error while archiving ${WHERE}/samba4_${n}.${WHEN}.tar.bz2 - status = $Status"
-                       exit 1
-               fi
-               find $relativedirname -name "*.ldb.bak" -exec rm {} \;
-       else
-               # Run the backup.
-               #    --warning=no-file-ignored set to suppress "socket ignored" messages.
-               tar cjf ${WHERE}/${n}.${WHEN}.tar.bz2  $relativedirname --warning=no-file-ignored
-               Status=$?       # Preserve $? for message, since [ alters it.
-               if [ $Status -ne 0 ]; then
-                       echo "Error while archiving ${WHERE}/${n}.${WHEN}.tar.bz2 - status = $Status"
-                       exit 1
-               fi
-       fi
-done
-
-find $WHERE -name "samba4_*bz2" -mtime +$DAYS -exec rm  {} \;