netcmd: domain backup restore command
authorAaron Haslett <aaronhaslett@catalyst.net.nz>
Mon, 30 Apr 2018 23:11:01 +0000 (11:11 +1200)
committerAndrew Bartlett <abartlet@samba.org>
Tue, 3 Jul 2018 08:39:14 +0000 (10:39 +0200)
Add a command option that restores a backup file. This is only intended
for recovering from a catastrophic failure of the domain. The old domain
DCs are removed from the DB and a new DC is added.

Signed-off-by: Aaron Haslett <aaronhaslett@catalyst.net.nz>
Signed-off-by: Tim Beale <timbeale@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/join.py
python/samba/netcmd/domain_backup.py

index 70ff956cef7bab43d4d7f5545853c7a4477aee9f..b8038bc510c8548f632efc4375c2408bad467db4 100644 (file)
        <para>Copy a running DC's current DB into a backup tar file.</para>
 </refsect3>
 
+<refsect3>
+       <title>domain backup restore</title>
+       <para>Restore the domain's DB from a backup-file.</para>
+</refsect3>
+
 <refsect3>
        <title>domain classicupgrade [options] <replaceable>classic_smb_conf</replaceable></title>
        <para>Upgrade from Samba classic (NT4-like) database to Samba AD DC
index e7ea11187ef1bf8d6410a2611ff5781036a2a862..39c9a3a69025c59387c23b9f7fc9c66238783248 100644 (file)
@@ -57,7 +57,7 @@ class DCJoinContext(object):
                  netbios_name=None, targetdir=None, domain=None,
                  machinepass=None, use_ntvfs=False, dns_backend=None,
                  promote_existing=False, plaintext_secrets=False,
-                 backend_store=None):
+                 backend_store=None, forced_local_samdb=None):
         if site is None:
             site = "Default-First-Site-Name"
 
@@ -79,16 +79,20 @@ class DCJoinContext(object):
         ctx.creds.set_gensec_features(creds.get_gensec_features() | gensec.FEATURE_SEAL)
         ctx.net = Net(creds=ctx.creds, lp=ctx.lp)
 
-        if server is not None:
-            ctx.server = server
-        else:
-            ctx.logger.info("Finding a writeable DC for domain '%s'" % domain)
-            ctx.server = ctx.find_dc(domain)
-            ctx.logger.info("Found DC %s" % ctx.server)
+        ctx.server = server
+        ctx.forced_local_samdb = forced_local_samdb
 
-        ctx.samdb = SamDB(url="ldap://%s" % ctx.server,
-                          session_info=system_session(),
-                          credentials=ctx.creds, lp=ctx.lp)
+        if forced_local_samdb:
+            ctx.samdb = forced_local_samdb
+            ctx.server = ctx.samdb.url
+        else:
+            if not ctx.server:
+                ctx.logger.info("Finding a writeable DC for domain '%s'" % domain)
+                ctx.server = ctx.find_dc(domain)
+                ctx.logger.info("Found DC %s" % ctx.server)
+            ctx.samdb = SamDB(url="ldap://%s" % ctx.server,
+                              session_info=system_session(),
+                              credentials=ctx.creds, lp=ctx.lp)
 
         try:
             ctx.samdb.search(scope=ldb.SCOPE_ONELEVEL, attrs=["dn"])
@@ -563,7 +567,9 @@ class DCJoinContext(object):
         '''add the ntdsdsa object'''
 
         rec = ctx.join_ntdsdsa_obj()
-        if ctx.RODC:
+        if ctx.forced_local_samdb:
+            ctx.samdb.add(rec, controls=["relax:0"])
+        elif ctx.RODC:
             ctx.samdb.add(rec, ["rodc_join:1:1"])
         else:
             ctx.DsAddEntry([rec])
@@ -572,7 +578,7 @@ class DCJoinContext(object):
         res = ctx.samdb.search(base=ctx.ntds_dn, scope=ldb.SCOPE_BASE, attrs=["objectGUID"])
         ctx.ntds_guid = misc.GUID(ctx.samdb.schema_format_value("objectGUID", res[0]["objectGUID"][0]))
 
-    def join_add_objects(ctx):
+    def join_add_objects(ctx, specified_sid=None):
         '''add the various objects needed for the join'''
         if ctx.acct_dn:
             print("Adding %s" % ctx.acct_dn)
@@ -602,12 +608,18 @@ class DCJoinContext(object):
             elif ctx.promote_existing:
                 rec["msDS-RevealOnDemandGroup"] = []
 
+            if specified_sid:
+                rec["objectSid"] = ndr_pack(specified_sid)
+
             if ctx.promote_existing:
                 if ctx.promote_from_dn != ctx.acct_dn:
                     ctx.samdb.rename(ctx.promote_from_dn, ctx.acct_dn)
                 ctx.samdb.modify(ldb.Message.from_dict(ctx.samdb, rec, ldb.FLAG_MOD_REPLACE))
             else:
-                ctx.samdb.add(rec)
+                controls = None
+                if specified_sid is not None:
+                    controls = ["relax:0"]
+                ctx.samdb.add(rec, controls=controls)
 
         if ctx.krbtgt_dn:
             ctx.add_krbtgt_account()
index 53e65b9373f3475779564c2e81af3aaa9dc8f1b4..5a25da13e7c40a84dfc1f559ea5de85a28c7e20d 100644 (file)
@@ -21,42 +21,25 @@ import sys
 import tarfile
 import logging
 import shutil
+import tempfile
 import samba
 import samba.getopt as options
 from samba.samdb import SamDB
 import ldb
 from samba import smb
-from samba.ntacls import backup_online
+from samba.ntacls import backup_online, backup_restore
 from samba.auth import system_session
 from samba.join import DCJoinContext, join_clone
 from samba.dcerpc.security import dom_sid
 from samba.netcmd import Option, CommandError
-import traceback
+from samba.dcerpc import misc
+from samba import Ldb
+from fsmo import cmd_fsmo_seize
+from samba.provision import make_smbconf
+from samba.upgradehelpers import update_krbtgt_account_password
+from samba.remove_dc import remove_dc
+from samba.provision import secretsdb_self_join
 
-tmpdir = 'backup_temp_dir'
-
-
-def rm_tmp():
-    if os.path.exists(tmpdir):
-        shutil.rmtree(tmpdir)
-
-
-def using_tmp_dir(func):
-    def inner(*args, **kwargs):
-        try:
-            rm_tmp()
-            os.makedirs(tmpdir)
-            rval = func(*args, **kwargs)
-            rm_tmp()
-            return rval
-        except Exception as e:
-            rm_tmp()
-
-            # print a useful stack-trace for unexpected exceptions
-            if type(e) is not CommandError:
-                traceback.print_exc()
-            raise e
-    return inner
 
 
 # work out a SID (based on a free RID) to use when the domain gets restored.
@@ -175,7 +158,6 @@ class cmd_domain_backup_online(samba.netcmd.Command):
                help="Directory to write the backup file to"),
        ]
 
-    @using_tmp_dir
     def run(self, sambaopts=None, credopts=None, server=None, targetdir=None):
         logger = self.get_logger()
         logger.setLevel(logging.DEBUG)
@@ -190,6 +172,8 @@ class cmd_domain_backup_online(samba.netcmd.Command):
             logger.info('Creating targetdir %s...' % targetdir)
             os.makedirs(targetdir)
 
+        tmpdir = tempfile.mkdtemp(dir=targetdir)
+
         # Run a clone join on the remote
         ctx = join_clone(logger=logger, creds=creds, lp=lp,
                          include_secrets=True, dns_backend='SAMBA_INTERNAL',
@@ -224,6 +208,203 @@ class cmd_domain_backup_online(samba.netcmd.Command):
         backup_file = backup_filepath(targetdir, realm, time_str)
         create_backup_tar(logger, tmpdir, backup_file)
 
+        shutil.rmtree(tmpdir)
+
+
+class cmd_domain_backup_restore(cmd_fsmo_seize):
+    '''Restore the domain's DB from a backup-file.
+
+    This restores a previously backed up copy of the domain's DB on a new DC.
+
+    Note that the restored DB will not contain the original DC that the backup
+    was taken from (or any other DCs in the original domain). Only the new DC
+    (specified by --newservername) will be present in the restored DB.
+
+    Samba can then be started against the restored DB. Any existing DCs for the
+    domain should be shutdown before the new DC is started. Other DCs can then
+    be joined to the new DC to recover the network.
+
+    Note that this command should be run as the root user - it will fail
+    otherwise.'''
+
+    synopsis = ("%prog --backup-file=<tar-file> --targetdir=<output-dir> "
+                "--newservername=<DC-name>")
+    takes_options = [
+        Option("--backup-file", help="Path to backup file", type=str),
+        Option("--targetdir", help="Path to write to", type=str),
+        Option("--newservername", help="Name for new server", type=str),
+    ]
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "credopts": options.CredentialsOptions,
+    }
+
+    def run(self, sambaopts=None, credopts=None, backup_file=None,
+            targetdir=None, newservername=None):
+        if not (backup_file and os.path.exists(backup_file)):
+            raise CommandError('Backup file not found.')
+        if targetdir is None:
+            raise CommandError('Please specify a target directory')
+        if os.path.exists(targetdir) and os.listdir(targetdir):
+            raise CommandError('Target directory is not empty')
+        if not newservername:
+            raise CommandError('Server name required')
+
+        logger = logging.getLogger()
+        logger.setLevel(logging.DEBUG)
+        logger.addHandler(logging.StreamHandler(sys.stdout))
+
+        # ldapcmp prefers the server's netBIOS name in upper-case
+        newservername = newservername.upper()
+
+        # extract the backup .tar to a temp directory
+        targetdir = os.path.abspath(targetdir)
+        tf = tarfile.open(backup_file)
+        tf.extractall(targetdir)
+        tf.close()
+
+        # use the smb.conf that got backed up, by default (save what was
+        # actually backed up, before we mess with it)
+        smbconf = os.path.join(targetdir, 'etc', 'smb.conf')
+        shutil.copyfile(smbconf, smbconf + ".orig")
+
+        # if a smb.conf was specified on the cmd line, then use that instead
+        cli_smbconf = sambaopts.get_loadparm_path()
+        if cli_smbconf:
+            logger.info("Using %s as restored domain's smb.conf" % cli_smbconf)
+            shutil.copyfile(cli_smbconf, smbconf)
+
+        lp = samba.param.LoadParm()
+        lp.load(smbconf)
+
+        # open a DB connection to the restored DB
+        private_dir = os.path.join(targetdir, 'private')
+        samdb_path = os.path.join(private_dir, 'sam.ldb')
+        samdb = SamDB(url=samdb_path, session_info=system_session(), lp=lp)
+
+        # Create account using the join_add_objects function in the join object
+        # We need namingContexts, account control flags, and the sid saved by
+        # the backup process.
+        res = samdb.search(base="", scope=ldb.SCOPE_BASE,
+                           attrs=['namingContexts'])
+        ncs = [str(r) for r in res[0].get('namingContexts')]
+
+        creds = credopts.get_credentials(lp)
+        ctx = DCJoinContext(logger, creds=creds, lp=lp,
+                            forced_local_samdb=samdb,
+                            netbios_name=newservername)
+        ctx.nc_list = ncs
+        ctx.full_nc_list = ncs
+        ctx.userAccountControl = (samba.dsdb.UF_SERVER_TRUST_ACCOUNT |
+                                  samba.dsdb.UF_TRUSTED_FOR_DELEGATION)
+
+        # rewrite the smb.conf to make sure it uses the new targetdir settings.
+        # (This doesn't update all filepaths in a customized config, but it
+        # corrects the same paths that get set by a new provision)
+        logger.info('Updating basic smb.conf settings...')
+        make_smbconf(smbconf, newservername, ctx.domain_name,
+                     ctx.realm, targetdir, lp=lp,
+                     serverrole="active directory domain controller")
+
+        # Get the SID saved by the backup process and create account
+        res = samdb.search(base=ldb.Dn(samdb, "@SAMBA_DSDB"),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=['sidForRestore'])
+        sid = res[0].get('sidForRestore')[0]
+        logger.info('Creating account with SID: ' + str(sid))
+        ctx.join_add_objects(specified_sid=dom_sid(sid))
+
+        m = ldb.Message()
+        m.dn = ldb.Dn(samdb, '@ROOTDSE')
+        ntds_guid = str(ctx.ntds_guid)
+        m["dsServiceName"] = ldb.MessageElement("<GUID=%s>" % ntds_guid,
+                                                ldb.FLAG_MOD_REPLACE,
+                                                "dsServiceName")
+        samdb.modify(m)
+
+        secrets_path = os.path.join(private_dir, 'secrets.ldb')
+        secrets_ldb = Ldb(secrets_path, session_info=system_session(), lp=lp)
+        secretsdb_self_join(secrets_ldb, domain=ctx.domain_name,
+                            realm=ctx.realm, dnsdomain=ctx.dnsdomain,
+                            netbiosname=ctx.myname, domainsid=ctx.domsid,
+                            machinepass=ctx.acct_pass,
+                            key_version_number=ctx.key_version_number,
+                            secure_channel_type=misc.SEC_CHAN_BDC)
+
+        # Seize DNS roles
+        domain_dn = samdb.domain_dn()
+        forest_dn = samba.dn_from_dns_name(samdb.forest_dns_name())
+        domaindns_dn = ("CN=Infrastructure,DC=DomainDnsZones,", domain_dn)
+        forestdns_dn = ("CN=Infrastructure,DC=ForestDnsZones,", forest_dn)
+        for dn_prefix, dns_dn in [forestdns_dn, domaindns_dn]:
+            if dns_dn not in ncs:
+                continue
+            full_dn = dn_prefix + dns_dn
+            m = ldb.Message()
+            m.dn = ldb.Dn(samdb, full_dn)
+            m["fSMORoleOwner"] = ldb.MessageElement(samdb.get_dsServiceName(),
+                                                    ldb.FLAG_MOD_REPLACE,
+                                                    "fSMORoleOwner")
+            samdb.modify(m)
+
+        # Seize other roles
+        for role in ['rid', 'pdc', 'naming', 'infrastructure', 'schema']:
+            self.seize_role(role, samdb, force=True)
+
+        # Get all DCs and remove them (this ensures these DCs cannot
+        # replicate because they will not have a password)
+        search_expr = "(&(objectClass=Server)(serverReference=*))"
+        res = samdb.search(samdb.get_config_basedn(), scope=ldb.SCOPE_SUBTREE,
+                           expression=search_expr)
+        for m in res:
+            cn = m.get('cn')[0]
+            if cn != newservername:
+                remove_dc(samdb, logger, cn)
+
+        # Remove the repsFrom and repsTo from each NC to ensure we do
+        # not try (and fail) to talk to the old DCs
+        for nc in ncs:
+            msg = ldb.Message()
+            msg.dn = ldb.Dn(samdb, nc)
+
+            msg["repsFrom"] = ldb.MessageElement([],
+                                                 ldb.FLAG_MOD_REPLACE,
+                                                 "repsFrom")
+            msg["repsTo"] = ldb.MessageElement([],
+                                                 ldb.FLAG_MOD_REPLACE,
+                                                 "repsTo")
+            samdb.modify(msg)
+
+        # Update the krbtgt passwords twice, ensuring no tickets from
+        # the old domain are valid
+        update_krbtgt_account_password(samdb)
+        update_krbtgt_account_password(samdb)
+
+        # restore the sysvol directory from the backup tar file, including the
+        # original NTACLs. Note that the backup_restore() will fail if not root
+        sysvol_tar = os.path.join(targetdir, 'sysvol.tar.gz')
+        dest_sysvol_dir = lp.get('path', 'sysvol')
+        if not os.path.exists(dest_sysvol_dir):
+            os.makedirs(dest_sysvol_dir)
+        backup_restore(sysvol_tar, dest_sysvol_dir, samdb, smbconf)
+        os.remove(sysvol_tar)
+
+        # Remove DB markers added by the backup process
+        m = ldb.Message()
+        m.dn = ldb.Dn(samdb, "@SAMBA_DSDB")
+        m["backupDate"] = ldb.MessageElement([], ldb.FLAG_MOD_DELETE,
+                                             "backupDate")
+        m["sidForRestore"] = ldb.MessageElement([], ldb.FLAG_MOD_DELETE,
+                                                "sidForRestore")
+        samdb.modify(m)
+
+        logger.info("Backup file successfully restored to %s" % targetdir)
+        logger.info("Please check the smb.conf settings are correct before "
+                    "starting samba.")
+
+
 class cmd_domain_backup(samba.netcmd.SuperCommand):
-    '''Domain backup'''
-    subcommands = {'online': cmd_domain_backup_online()}
+    '''Create or restore a backup of the domain.'''
+    subcommands = {'online': cmd_domain_backup_online(),
+                   'restore': cmd_domain_backup_restore()}