[PATCH] Domain backup and restore samba-tool commands

Tim Beale timbeale at catalyst.net.nz
Tue Jul 3 01:03:54 UTC 2018


Hi,

Latest patch-set attached. Changes:
- Some updates to the new domain_backup tests to make them more robust
(patch #3)
- Patches reviewed by Andrew (with a few minor tweaks here and there)
- Pulled in patch #6 from another change-set sent to the list earlier:
https://lists.samba.org/archive/samba-technical/2018-June/128546.html

Note patches are dependent on the changes I sent out earlier today, that
are still pending auto-build:
https://lists.samba.org/archive/samba-technical/2018-July/128840.html

CI Link: https://gitlab.com/catalyst-samba/samba/pipelines/24998053

Git branch:
https://gitlab.com/catalyst-samba/samba/commits/abartlet-tim-backup-rename-for-master

One more reviewer would be appreciated.

Thanks,
Tim

On 29/06/18 16:35, Tim Beale via samba-technical wrote:
> Hi,
>
> Updated patch-set (attached) is ready for review and delivery. Branch
> also here:
> https://gitlab.com/catalyst-samba/samba/commits/tim-backup-tool
>
> Note that this patch-set is dependent on the rename changes sent out
> earlier today, which are pending autobuild/delivery to master.
> https://lists.samba.org/archive/samba-technical/2018-June/128786.html
>
> CI link:
> https://gitlab.com/catalyst-samba/samba/pipelines/24803625
>
> Review appreciated.
>
> Thanks,
> Tim
>

-------------- next part --------------
From 2c40333d033b4c7984a99652305a0b8f8caf327a Mon Sep 17 00:00:00 2001
From: Aaron Haslett <aaronhaslett at catalyst.net.nz>
Date: Tue, 1 May 2018 11:10:11 +1200
Subject: [PATCH 1/6] netcmd: domain backup online command

This adds a samba-tool command that can be run against a remote DC to
produce a backup-file for the current domain. The backup stores similar
info to what a new DC would get if it joined the network.

Signed-off-by: Aaron Haslett <aaronhaslett at catalyst.net.nz>
Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet at samba.org>
---
 docs-xml/manpages/samba-tool.8.xml   |  10 ++
 python/samba/join.py                 |   1 +
 python/samba/netcmd/domain.py        |   2 +
 python/samba/netcmd/domain_backup.py | 229 +++++++++++++++++++++++++++++++++++
 4 files changed, 242 insertions(+)
 create mode 100644 python/samba/netcmd/domain_backup.py

diff --git a/docs-xml/manpages/samba-tool.8.xml b/docs-xml/manpages/samba-tool.8.xml
index f785e53..70ff956 100644
--- a/docs-xml/manpages/samba-tool.8.xml
+++ b/docs-xml/manpages/samba-tool.8.xml
@@ -292,6 +292,16 @@
 </refsect2>
 
 <refsect3>
+	<title>domain backup</title>
+	<para>Create or restore a backup of the domain.</para>
+</refsect3>
+
+<refsect3>
+	<title>domain backup online</title>
+	<para>Copy a running DC's current DB into a backup tar 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
 	database.</para>
diff --git a/python/samba/join.py b/python/samba/join.py
index b7ab1ed..e7ea111 100644
--- a/python/samba/join.py
+++ b/python/samba/join.py
@@ -1496,6 +1496,7 @@ def join_clone(logger=None, server=None, creds=None, lp=None,
 
     ctx.do_join()
     logger.info("Cloned domain %s (SID %s)" % (ctx.domain_name, ctx.domsid))
+    return ctx
 
 def join_subdomain(logger=None, server=None, creds=None, lp=None, site=None,
         netbios_name=None, targetdir=None, parent_domain=None, dnsdomain=None,
diff --git a/python/samba/netcmd/domain.py b/python/samba/netcmd/domain.py
index 3dbe2fb..8624907 100644
--- a/python/samba/netcmd/domain.py
+++ b/python/samba/netcmd/domain.py
@@ -100,6 +100,7 @@ from samba.provision.common import (
 )
 
 from samba.netcmd.pso import cmd_domain_passwordsettings_pso
+from samba.netcmd.domain_backup import cmd_domain_backup
 
 string_version_to_constant = {
     "2008_R2" : DS_DOMAIN_FUNCTION_2008_R2,
@@ -4334,3 +4335,4 @@ class cmd_domain(SuperCommand):
     subcommands["tombstones"] = cmd_domain_tombstones()
     subcommands["schemaupgrade"] = cmd_domain_schema_upgrade()
     subcommands["functionalprep"] = cmd_domain_functional_prep()
+    subcommands["backup"] = cmd_domain_backup()
diff --git a/python/samba/netcmd/domain_backup.py b/python/samba/netcmd/domain_backup.py
new file mode 100644
index 0000000..53e65b9
--- /dev/null
+++ b/python/samba/netcmd/domain_backup.py
@@ -0,0 +1,229 @@
+# domain_backup
+#
+# Copyright Andrew Bartlett <abartlet at samba.org>
+#
+# 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/>.
+#
+import datetime
+import os
+import sys
+import tarfile
+import logging
+import shutil
+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.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
+
+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.
+# This ensures that the restored DC's SID won't clash with any other RIDs
+# already in use in the domain
+def get_sid_for_restore(samdb):
+    # Find the DN of the RID set of the server
+    res = samdb.search(base=ldb.Dn(samdb, samdb.get_serverName()),
+                       scope=ldb.SCOPE_BASE, attrs=["serverReference"])
+    server_ref_dn = ldb.Dn(samdb, res[0]['serverReference'][0])
+    res = samdb.search(base=server_ref_dn,
+                       scope=ldb.SCOPE_BASE,
+                       attrs=['rIDSetReferences'])
+    rid_set_dn = ldb.Dn(samdb, res[0]['rIDSetReferences'][0])
+
+    # Get the alloc pools and next RID of the RID set
+    res = samdb.search(base=rid_set_dn,
+                       scope=ldb.SCOPE_SUBTREE,
+                       expression="(rIDNextRID=*)",
+                       attrs=['rIDAllocationPool',
+                              'rIDPreviousAllocationPool',
+                              'rIDNextRID'])
+
+    # Decode the bounds of the RID allocation pools
+    rid = int(res[0].get('rIDNextRID')[0])
+
+    def split_val(num):
+        high = (0xFFFFFFFF00000000 & int(num)) >> 32
+        low = 0x00000000FFFFFFFF & int(num)
+        return low, high
+    pool_l, pool_h = split_val(res[0].get('rIDPreviousAllocationPool')[0])
+    npool_l, npool_h = split_val(res[0].get('rIDAllocationPool')[0])
+
+    # Calculate next RID based on pool bounds
+    if rid == npool_h:
+        raise CommandError('Out of RIDs, finished AllocPool')
+    if rid == pool_h:
+        if pool_h == npool_h:
+            raise CommandError('Out of RIDs, finished PrevAllocPool.')
+        rid = npool_l
+    else:
+        rid += 1
+
+    # Construct full SID
+    sid = dom_sid(samdb.get_domain_sid())
+    return str(sid) + '-' + str(rid)
+
+
+def get_timestamp():
+    return datetime.datetime.now().isoformat().replace(':', '-')
+
+
+def backup_filepath(targetdir, name, time_str):
+    filename = 'samba-backup-{}-{}.tar.bz2'.format(name, time_str)
+    return os.path.join(targetdir, filename)
+
+
+def create_backup_tar(logger, tmpdir, backup_filepath):
+    # Adds everything in the tmpdir into a new tar file
+    logger.info("Creating backup file %s..." % backup_filepath)
+    tf = tarfile.open(backup_filepath, 'w:bz2')
+    tf.add(tmpdir, arcname='./')
+    tf.close()
+
+
+# Add a backup-specific marker to the DB with info that we'll use during
+# the restore process
+def add_backup_marker(samdb, marker, value):
+    m = ldb.Message()
+    m.dn = ldb.Dn(samdb, "@SAMBA_DSDB")
+    m[marker] = ldb.MessageElement(value, ldb.FLAG_MOD_ADD, marker)
+    samdb.modify(m)
+
+
+def check_online_backup_args(logger, credopts, server, targetdir):
+    # Make sure we have all the required args.
+    u_p = {'user': credopts.creds.get_username(),
+           'pass': credopts.creds.get_password()}
+    if None in u_p.values():
+        raise CommandError("Creds required.")
+    if server is None:
+        raise CommandError('Server required')
+    if targetdir is None:
+        raise CommandError('Target directory required')
+
+    if not os.path.exists(targetdir):
+        logger.info('Creating targetdir %s...' % targetdir)
+        os.makedirs(targetdir)
+
+
+class cmd_domain_backup_online(samba.netcmd.Command):
+    '''Copy a running DC's current DB into a backup tar file.
+
+    Takes a backup copy of the current domain from a running DC. If the domain
+    were to undergo a catastrophic failure, then the backup file can be used to
+    recover the domain. The backup created is similar to the DB that a new DC
+    would receive when it joins the domain.
+
+    Note that:
+    - it's recommended to run 'samba-tool dbcheck' before taking a backup-file
+      and fix any errors it reports.
+    - all the domain's secrets are included in the backup file.
+    - although the DB contents can be untarred and examined manually, you need
+      to run 'samba-tool domain backup restore' before you can start a Samba DC
+      from the backup file.'''
+
+    synopsis = "%prog --server=<DC-to-backup> --targetdir=<output-dir>"
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "credopts": options.CredentialsOptions,
+    }
+
+    takes_options = [
+        Option("--server", help="The DC to backup", type=str),
+        Option("--targetdir", type=str,
+               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)
+
+        # Make sure we have all the required args.
+        check_online_backup_args(logger, credopts, server, targetdir)
+
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        if not os.path.exists(targetdir):
+            logger.info('Creating targetdir %s...' % targetdir)
+            os.makedirs(targetdir)
+
+        # Run a clone join on the remote
+        ctx = join_clone(logger=logger, creds=creds, lp=lp,
+                         include_secrets=True, dns_backend='SAMBA_INTERNAL',
+                         server=server, targetdir=tmpdir)
+
+        # get the paths used for the clone, then drop the old samdb connection
+        paths = ctx.paths
+        del ctx
+
+        # Get a free RID to use as the new DC's SID (when it gets restored)
+        remote_sam = SamDB(url='ldap://' + server, credentials=creds,
+                           session_info=system_session(), lp=lp)
+        new_sid = get_sid_for_restore(remote_sam)
+        realm = remote_sam.domain_dns_name()
+
+        # Grab the remote DC's sysvol files and bundle them into a tar file
+        sysvol_tar = os.path.join(tmpdir, 'sysvol.tar.gz')
+        smb_conn = smb.SMB(server, "sysvol", lp=lp, creds=creds)
+        backup_online(smb_conn, sysvol_tar, remote_sam.get_domain_sid())
+
+        # remove the default sysvol files created by the clone (we want to
+        # make sure we restore the sysvol.tar.gz files instead)
+        shutil.rmtree(paths.sysvol)
+
+        # Edit the downloaded sam.ldb to mark it as a backup
+        samdb = SamDB(url=paths.samdb, session_info=system_session(), lp=lp)
+        time_str = get_timestamp()
+        add_backup_marker(samdb, "backupDate", time_str)
+        add_backup_marker(samdb, "sidForRestore", new_sid)
+
+        # Add everything in the tmpdir to the backup tar file
+        backup_file = backup_filepath(targetdir, realm, time_str)
+        create_backup_tar(logger, tmpdir, backup_file)
+
+class cmd_domain_backup(samba.netcmd.SuperCommand):
+    '''Domain backup'''
+    subcommands = {'online': cmd_domain_backup_online()}
-- 
2.7.4


From 0e35e8a2bfb0ae265951c8fbd7d2acbeb339e535 Mon Sep 17 00:00:00 2001
From: Aaron Haslett <aaronhaslett at catalyst.net.nz>
Date: Tue, 1 May 2018 11:11:01 +1200
Subject: [PATCH 2/6] netcmd: domain backup restore command

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 at catalyst.net.nz>
Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet at samba.org>
---
 docs-xml/manpages/samba-tool.8.xml   |   5 +
 python/samba/join.py                 |  38 ++++--
 python/samba/netcmd/domain_backup.py | 239 ++++++++++++++++++++++++++++++-----
 3 files changed, 240 insertions(+), 42 deletions(-)

diff --git a/docs-xml/manpages/samba-tool.8.xml b/docs-xml/manpages/samba-tool.8.xml
index 70ff956..b8038bc 100644
--- a/docs-xml/manpages/samba-tool.8.xml
+++ b/docs-xml/manpages/samba-tool.8.xml
@@ -302,6 +302,11 @@
 </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
 	database.</para>
diff --git a/python/samba/join.py b/python/samba/join.py
index e7ea111..39c9a3a 100644
--- a/python/samba/join.py
+++ b/python/samba/join.py
@@ -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()
diff --git a/python/samba/netcmd/domain_backup.py b/python/samba/netcmd/domain_backup.py
index 53e65b9..5a25da1 100644
--- a/python/samba/netcmd/domain_backup.py
+++ b/python/samba/netcmd/domain_backup.py
@@ -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()}
-- 
2.7.4


From a2b9f6c99b0c02443de94eb4b28bffebb1d64639 Mon Sep 17 00:00:00 2001
From: Aaron Haslett <aaronhaslett at catalyst.net.nz>
Date: Mon, 11 Jun 2018 19:13:35 +1200
Subject: [PATCH 3/6] tests: Add tests for the domain backup online/restore
 commands

Signed-off-by: Aaron Haslett <aaronhaslett at catalyst.net.nz>
Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet at samba.org>
---
 python/samba/tests/domain_backup.py | 307 ++++++++++++++++++++++++++++++++++++
 source4/selftest/tests.py           |   3 +
 2 files changed, 310 insertions(+)
 create mode 100644 python/samba/tests/domain_backup.py

diff --git a/python/samba/tests/domain_backup.py b/python/samba/tests/domain_backup.py
new file mode 100644
index 0000000..fbe03f8
--- /dev/null
+++ b/python/samba/tests/domain_backup.py
@@ -0,0 +1,307 @@
+# Unix SMB/CIFS implementation.
+# Copyright (C) Andrew Bartlett <abartlet at samba.org>
+#
+# 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/>.
+#
+from samba import provision, param
+import tarfile
+import os
+import shutil
+from samba.tests.samba_tool.base import SambaToolCmdTest
+from samba.tests import TestCaseInTempDir, env_loadparm
+import ldb
+from samba.samdb import SamDB
+from samba.auth import system_session
+from samba import Ldb, dn_from_dns_name
+from samba.netcmd.fsmo import get_fsmo_roleowner
+
+
+def get_prim_dom(secrets_path, lp):
+    secrets_ldb = Ldb(secrets_path, session_info=system_session(), lp=lp)
+    return secrets_ldb.search(base="CN=Primary Domains",
+                              attrs=['objectClass', 'samAccountName',
+                                     'secret', 'msDS-KeyVersionNumber'],
+                              scope=ldb.SCOPE_SUBTREE,
+                              expression="(objectClass=kerberosSecret)")
+
+
+class DomainBackup(SambaToolCmdTest, TestCaseInTempDir):
+
+    def setUp(self):
+        super(DomainBackup, self).setUp()
+
+        server = os.environ["DC_SERVER"]
+        self.user_auth = "-U%s%%%s" % (os.environ["DC_USERNAME"],
+                                       os.environ["DC_PASSWORD"])
+
+        # LDB connection to the original server being backed up
+        self.ldb = self.getSamDB("-H", "ldap://%s" % server,
+                                 self.user_auth)
+        self.new_server = "BACKUPSERV"
+        self.server = server.upper()
+
+    def assert_partitions_present(self, samdb):
+        """Asserts all expected partitions are present in the backup samdb"""
+        res = samdb.search(base="", scope=ldb.SCOPE_BASE,
+                           attrs=['namingContexts'])
+        actual_ncs = [str(r) for r in res[0].get('namingContexts')]
+
+        basedn = str(samdb.get_default_basedn())
+        config_dn = "CN=Configuration,%s" % basedn
+        expected_ncs = [basedn, config_dn, "CN=Schema,%s" % config_dn,
+                        "DC=DomainDnsZones,%s" % basedn,
+                        "DC=ForestDnsZones,%s" % basedn]
+
+        for nc in expected_ncs:
+            self.assertTrue(nc in actual_ncs,
+                            "%s not in %s" % (nc, str(actual_ncs)))
+
+    def assert_dcs_present(self, samdb, expected_server, expected_count=None):
+        """Checks that the expected server is present in the restored DB"""
+        search_expr = "(&(objectClass=Server)(serverReference=*))"
+        res = samdb.search(samdb.get_config_basedn(),
+                           scope=ldb.SCOPE_SUBTREE,
+                           expression=search_expr)
+        server_found = False
+        for msg in res:
+            if expected_server in str(msg.dn):
+                server_found = True
+
+        self.assertTrue(server_found,
+                        "Could not find %s server" % expected_server)
+
+        if expected_count:
+            self.assertTrue(len(res) == expected_count)
+
+    def restore_dir(self):
+        extract_dir = os.path.join(self.tempdir, 'tree')
+        if not os.path.exists(extract_dir):
+            os.mkdir(extract_dir)
+            self.addCleanup(shutil.rmtree, extract_dir)
+        return extract_dir
+
+    def untar_backup(self, backup_file):
+        """Untar the backup file's raw contents (i.e. not a proper restore)"""
+        extract_dir = self.restore_dir()
+        with tarfile.open(backup_file) as tf:
+            tf.extractall(extract_dir)
+
+    def test_backup_untar(self):
+        """Creates a backup, untars the raw files, and sanity-checks the DB"""
+        backup_file = self.create_backup()
+        self.untar_backup(backup_file)
+
+        private_dir = os.path.join(self.restore_dir(), "private")
+        samdb_path = os.path.join(private_dir, "sam.ldb")
+        lp = env_loadparm()
+        samdb = SamDB(url=samdb_path, session_info=system_session(), lp=lp)
+
+        # check that backup markers were added to the DB
+        res = samdb.search(base=ldb.Dn(samdb, "@SAMBA_DSDB"),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=['sidForRestore', 'backupDate'])
+        self.assertEqual(len(res), 1)
+        self.assertIsNotNone(res[0].get('sidForRestore'))
+        self.assertIsNotNone(res[0].get('backupDate'))
+
+        # We have no secrets.ldb entry as we never got that during the backup.
+        secrets_path = os.path.join(private_dir, "secrets.ldb")
+        res = get_prim_dom(secrets_path, lp)
+        self.assertEqual(len(res), 0)
+
+        # sanity-check that all the partitions got backed up
+        self.assert_partitions_present(samdb)
+
+    def test_backup_restore(self):
+        """Does a backup/restore, with specific checks of the resulting DB"""
+        backup_file = self.create_backup()
+        self.restore_backup(backup_file)
+        lp = self.check_restored_smbconf()
+        self.check_restored_database(lp)
+
+    def create_smbconf(self, settings):
+        """Creates a very basic smb.conf to pass to the restore tool"""
+
+        # without the testenv config's settings, the NTACL backup_restore()
+        # operation will fail (because we're not root). So first suck in all
+        # testenv's settings, so we retain these in the new config. Note we
+        # use a non-global LP so that these settings don't leak into other
+        # places we use LoadParms
+        testenv_conf = os.environ["SMB_CONF_PATH"]
+        local_lp = param.LoadParm(filename_for_non_global_lp=testenv_conf)
+
+        # add the new settings to the LP, then write the settings to file
+        for key, val in settings.items():
+            local_lp.set(key, val)
+
+        new_smbconf = os.path.join(self.tempdir, "smb.conf")
+        local_lp.dump(False, new_smbconf)
+
+        self.addCleanup(os.remove, new_smbconf)
+        return new_smbconf
+
+    def test_backup_restore_with_conf(self):
+        """Checks smb.conf values passed to the restore are retained"""
+        backup_file = self.create_backup()
+
+        # create an smb.conf that we pass to the restore. The netbios/state
+        # dir should get overridden by the restore, the other settings should
+        # trickle through into the restored dir's smb.conf
+        settings = {'state directory': '/var/run',
+                    'netbios name': 'FOOBAR'}
+        assert_settings = {'drs: max link sync': '275',
+                           'prefork children': '7'}
+        settings.update(assert_settings)
+        smbconf = self.create_smbconf(settings)
+
+        self.restore_backup(backup_file, ["--configfile=" + smbconf])
+
+        # this will check netbios name/state dir
+        lp = self.check_restored_smbconf()
+        self.check_restored_database(lp)
+
+        # check the remaining settings are still intact
+        for key, val in assert_settings.items():
+            self.assertEqual(str(lp.get(key)), val,
+                             "'%s' was '%s' in smb.conf" % (key, lp.get(key)))
+
+    def check_restored_smbconf(self):
+        """Sanity-check important smb.conf values are restored correctly"""
+        smbconf = os.path.join(self.restore_dir(), "etc", "smb.conf")
+        bkp_lp = param.LoadParm(filename_for_non_global_lp=smbconf)
+        self.assertEqual(bkp_lp.get('netbios name'), self.new_server)
+
+        # we restore with a fixed directory structure, so we can sanity-check
+        # that the core filepaths settings are what we expect them to be
+        private_dir = os.path.join(self.restore_dir(), "private")
+        self.assertEqual(bkp_lp.get('private dir'), private_dir)
+        state_dir = os.path.join(self.restore_dir(), "state")
+        self.assertEqual(bkp_lp.get('state directory'), state_dir)
+        return bkp_lp
+
+    def check_restored_database(self, bkp_lp):
+        paths = provision.provision_paths_from_lp(bkp_lp, bkp_lp.get("realm"))
+
+        bkp_pd = get_prim_dom(paths.secrets, bkp_lp)
+        self.assertEqual(len(bkp_pd), 1)
+        acn = bkp_pd[0].get('samAccountName')
+        self.assertIsNotNone(acn)
+        self.assertEqual(acn[0].replace('$', ''), self.new_server)
+        self.assertIsNotNone(bkp_pd[0].get('secret'))
+
+        samdb = SamDB(url=paths.samdb, session_info=system_session(),
+                      lp=bkp_lp, credentials=self.get_credentials())
+
+        # check that the backup markers have been removed from the restored DB
+        res = samdb.search(base=ldb.Dn(samdb, "@SAMBA_DSDB"),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=['sidForRestore', 'backupDate'])
+        self.assertEqual(len(res), 1)
+        self.assertIsNone(res[0].get('sidForRestore'))
+        self.assertIsNone(res[0].get('backupDate'))
+
+        # check that the repsFrom and repsTo values have been removed
+        # from the restored DB
+        res = samdb.search(base=samdb.get_default_basedn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=['repsFrom', 'repsTo'])
+        self.assertEqual(len(res), 1)
+        self.assertIsNone(res[0].get('repsFrom'))
+        self.assertIsNone(res[0].get('repsTo'))
+
+        res = samdb.search(base=samdb.get_config_basedn(),
+                           scope=ldb.SCOPE_BASE,
+                           attrs=['repsFrom', 'repsTo'])
+        self.assertEqual(len(res), 1)
+        self.assertIsNone(res[0].get('repsFrom'))
+        self.assertIsNone(res[0].get('repsTo'))
+
+        # check the restored DB has the expected partitions/DC/FSMO roles
+        self.assert_partitions_present(samdb)
+        self.assert_dcs_present(samdb, self.new_server, expected_count=1)
+        self.assert_fsmo_roles(samdb, self.new_server, self.server)
+
+    def assert_fsmo_roles(self, samdb, server, exclude_server):
+        """Asserts the expected server is the FSMO role owner"""
+        domain_dn = samdb.domain_dn()
+        forest_dn = dn_from_dns_name(samdb.forest_dns_name())
+        fsmos = {'infrastructure': "CN=Infrastructure," + domain_dn,
+                 'naming': "CN=Partitions,%s" % samdb.get_config_basedn(),
+                 'schema': str(samdb.get_schema_basedn()),
+                 'rid': "CN=RID Manager$,CN=System," + domain_dn,
+                 'pdc': domain_dn,
+                 'domaindns':
+                     "CN=Infrastructure,DC=DomainDnsZones," + domain_dn,
+                 'forestdns':
+                     "CN=Infrastructure,DC=ForestDnsZones," + forest_dn}
+        for role, dn in fsmos.items():
+            owner = get_fsmo_roleowner(samdb, ldb.Dn(samdb, dn), role)
+            self.assertTrue("CN={},".format(server) in owner.extended_str(),
+                            "Expected %s to own FSMO role %s" % (server, role))
+            self.assertTrue("CN={},".format(exclude_server)
+                            not in owner.extended_str(),
+                            "%s found as FSMO %s role owner" % (server, role))
+
+    def run_cmd(self, args):
+        """Executes a samba-tool backup/restore command"""
+
+        # we use check_output() here to execute the command because we want the
+        # command run in a separate process. This means a completely clean
+        # LoadParm object gets used for the restore (otherwise the global LP
+        # settings can bleed from one test case to another).
+        cmd = " ".join(args)
+        print("Executing: samba-tool %s" % cmd)
+        out = self.check_output("samba-tool " + cmd)
+        print(out)
+
+    def create_backup(self):
+        """Runs the backup cmd to produce a backup file for the testenv DC"""
+        # Run the backup command and check we got one backup tar file
+        args = ["domain", "backup", "online",
+                "--server=" + self.server]
+        args += [self.user_auth, "--targetdir=" + self.tempdir]
+
+        self.run_cmd(args)
+
+        # find the filename of the backup-file generated
+        tar_files = []
+        for filename in os.listdir(self.tempdir):
+            if (filename.startswith("samba-backup-") and
+                filename.endswith(".tar.bz2")):
+                tar_files.append(filename)
+
+        self.assertTrue(len(tar_files) == 1,
+                       "Domain backup created %u tar files" % len(tar_files))
+
+        # clean up the backup file once the test finishes
+        backup_file = os.path.join(self.tempdir, tar_files[0])
+        self.addCleanup(os.remove, backup_file)
+        return backup_file
+
+    def restore_backup(self, backup_file, extra_args=None):
+        """Restores the samba directory files from a given backup"""
+        # Run the restore command
+        extract_dir = self.restore_dir()
+        args = ["domain", "backup", "restore", "--backup-file=" + backup_file,
+                "--targetdir=" + extract_dir,
+                "--newservername=" + self.new_server]
+        if extra_args:
+            args += extra_args
+
+        self.run_cmd(args)
+
+        # sanity-check the restore doesn't modify the original DC by mistake
+        self.assert_partitions_present(self.ldb)
+        self.assert_dcs_present(self.ldb, self.server)
+        self.assert_fsmo_roles(self.ldb, self.server, self.new_server)
diff --git a/source4/selftest/tests.py b/source4/selftest/tests.py
index 2551603..bca002f 100755
--- a/source4/selftest/tests.py
+++ b/source4/selftest/tests.py
@@ -731,6 +731,9 @@ planoldpythontestsuite("fl2003dc:local",
 planoldpythontestsuite("ad_dc",
                        "samba.tests.password_hash_ldap",
                        extra_args=['-U"$USERNAME%$PASSWORD"'])
+planoldpythontestsuite("ad_dc:local",
+                       "samba.tests.domain_backup",
+                       extra_args=['-U"$USERNAME%$PASSWORD"'])
 # Encrypted secrets
 # ensure default provision (ad_dc) and join (vampire_dc)
 # encrypt secret values on disk.
-- 
2.7.4


From 0a141def6fb3dcb9f61eedefefff810733ef9373 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Tue, 29 May 2018 16:05:02 +1200
Subject: [PATCH 4/6] selftest: Add testenv for testing backup/restore

This adds a new testenv for testing that a DC created using the
samba-tool backup/restore can actually be started up. This actually
requires 2 new testenvs:

1. A 'backupfromdc' that solely exists to make a online backup of.
2. A 'restoredc' which takes the backup, and then uses the backup file
to do a restore, which we then start the DC based on.

The backupfromdc is just a plain vanilla AD DC. We use a separate test
env purely for this purpose, because the restoredc will use the same
domain (and so using an existing testenv would potentially interfere
with existing test cases).

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet at samba.org>
---
 python/samba/netcmd/domain_backup.py |   4 +-
 selftest/target/Samba.pm             |   2 +
 selftest/target/Samba4.pm            | 212 +++++++++++++++++++++++++++++++++++
 3 files changed, 217 insertions(+), 1 deletion(-)

diff --git a/python/samba/netcmd/domain_backup.py b/python/samba/netcmd/domain_backup.py
index 5a25da1..c706c3c 100644
--- a/python/samba/netcmd/domain_backup.py
+++ b/python/samba/netcmd/domain_backup.py
@@ -246,7 +246,9 @@ class cmd_domain_backup_restore(cmd_fsmo_seize):
             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):
+        # allow restoredc to install into a directory prepopulated by selftest
+        if (os.path.exists(targetdir) and os.listdir(targetdir) and
+            os.environ.get('SAMBA_SELFTEST') != '1'):
             raise CommandError('Target directory is not empty')
         if not newservername:
             raise CommandError('Server name required')
diff --git a/selftest/target/Samba.pm b/selftest/target/Samba.pm
index 81d3d21..9c79345 100644
--- a/selftest/target/Samba.pm
+++ b/selftest/target/Samba.pm
@@ -407,6 +407,8 @@ sub get_interface($)
     $interfaces{"fakednsforwarder2"} = 37;
     $interfaces{"s4member_dflt"} = 38;
     $interfaces{"vampire2000dc"} = 39;
+    $interfaces{"backupfromdc"} = 40;
+    $interfaces{"restoredc"} = 41;
 
     # update lib/socket_wrapper/socket_wrapper.c
     #  #define MAX_WRAPPED_INTERFACES 64
diff --git a/selftest/target/Samba4.pm b/selftest/target/Samba4.pm
index 7abc16e..eea84c1 100755
--- a/selftest/target/Samba4.pm
+++ b/selftest/target/Samba4.pm
@@ -2142,6 +2142,7 @@ sub check_env($$)
 	ad_dc_no_nss         => [],
 	ad_dc_no_ntlm        => [],
 	ad_dc_ntvfs          => [],
+	backupfromdc         => [],
 
 	fl2008r2dc           => ["ad_dc"],
 	fl2003dc             => ["ad_dc"],
@@ -2159,6 +2160,8 @@ sub check_env($$)
 	s4member_dflt_domain => ["ad_dc_ntvfs"],
 	s4member             => ["ad_dc_ntvfs"],
 
+	restoredc            => ["backupfromdc"],
+
 	none                 => [],
 );
 
@@ -2578,6 +2581,215 @@ sub setup_ad_dc_no_ntlm
 	return $env;
 }
 
+# Sets up a DC that's solely used to do a domain backup from. We then use the
+# backupfrom-DC to create the restore-DC - this proves that the backup/restore
+# process will create a Samba DC that will actually start up.
+# We don't use the backup-DC for anything else because its domain will conflict
+# with the restore DC.
+sub setup_backupfromdc
+{
+	my ($self, $path) = @_;
+
+	# If we didn't build with ADS, pretend this env was never available
+	if (not $self->{target3}->have_ads()) {
+	       return "UNKNOWN";
+	}
+
+	my $env = $self->provision_ad_dc($path, "backupfromdc", "BACKUPDOMAIN",
+					 "backupdom.samba.example.com", "");
+	unless ($env) {
+		return undef;
+	}
+
+	if (not defined($self->check_or_start($env, "standard"))) {
+	    return undef;
+	}
+
+	my $upn_array = ["$env->{REALM}.upn"];
+	my $spn_array = ["$env->{REALM}.spn"];
+
+	$self->setup_namespaces($env, $upn_array, $spn_array);
+
+	return $env;
+}
+
+# Creates a backup of a running testenv DC
+sub create_backup
+{
+	# note: dcvars contains the env info for the backup DC testenv
+	my ($self, $env, $dcvars, $backupdir, $backup_cmd) = @_;
+
+	# get all the env variables we pass in with the samba-tool command
+	my $cmd_env = "";
+	$cmd_env .= "SOCKET_WRAPPER_DEFAULT_IFACE=\"$env->{SOCKET_WRAPPER_DEFAULT_IFACE}\" ";
+	if (defined($env->{RESOLV_WRAPPER_CONF})) {
+		$cmd_env .= "RESOLV_WRAPPER_CONF=\"$env->{RESOLV_WRAPPER_CONF}\" ";
+	} else {
+		$cmd_env .= "RESOLV_WRAPPER_HOSTS=\"$env->{RESOLV_WRAPPER_HOSTS}\" ";
+	}
+	# Note: use the backupfrom-DC's krb5.conf to do the backup
+	$cmd_env .= " KRB5_CONFIG=\"$dcvars->{KRB5_CONFIG}\" ";
+	$cmd_env .= "KRB5CCNAME=\"$env->{KRB5_CCACHE}\" ";
+
+	# use samba-tool to create a backup from the 'backupfromdc' DC
+	my $cmd = "";
+	my $samba_tool = Samba::bindir_path($self, "samba-tool");
+	my $server = $dcvars->{DC_SERVER_IP};
+
+	$cmd .= "$cmd_env $samba_tool domain backup $backup_cmd --server=$server";
+	$cmd .= " --targetdir=$backupdir -U$dcvars->{DC_USERNAME}\%$dcvars->{DC_PASSWORD}";
+
+	print "Executing: $cmd\n";
+	unless(system($cmd) == 0) {
+		warn("Failed to create backup using: \n$cmd");
+		return undef;
+	}
+
+	# get the name of the backup file created
+	opendir(DIR, $backupdir);
+	my @files = grep(/\.tar/, readdir(DIR));
+	closedir(DIR);
+
+	if(scalar @files != 1) {
+		warn("Backup file not found in directory $backupdir\n");
+		return undef;
+	}
+	my $backup_file = "$backupdir/$files[0]";
+	print "Using backup file $backup_file...\n";
+
+	return $backup_file;
+}
+
+# Restores a backup-file to populate a testenv for a new DC
+sub restore_backup_file
+{
+	my ($self, $backup_file, $restore_opts, $restoredir, $smbconf) = @_;
+
+	# pass the restore command the testenv's smb.conf that we've already
+	# generated. But move it to a temp-dir first, so that the restore doesn't
+	# overwrite it
+	my $tmpdir = File::Temp->newdir();
+	my $tmpconf = "$tmpdir/smb.conf";
+	my $cmd = "cp $smbconf $tmpconf";
+	unless(system($cmd) == 0) {
+		warn("Failed to backup smb.conf using: \n$cmd");
+		return -1;
+	}
+
+	my $samba_tool = Samba::bindir_path($self, "samba-tool");
+	$cmd = "$samba_tool domain backup restore --backup-file=$backup_file";
+	$cmd .= " --targetdir=$restoredir $restore_opts --configfile=$tmpconf";
+
+	print "Executing: $cmd\n";
+	unless(system($cmd) == 0) {
+		warn("Failed to restore backup using: \n$cmd");
+		return -1;
+	}
+
+	print "Restore complete\n";
+	return 0
+}
+
+# sets up the initial directory and returns the new testenv's env info
+# (without actually doing a 'domain join')
+sub prepare_dc_testenv
+{
+	my ($self, $prefix, $dcname, $domain, $realm, $password) = @_;
+
+	my $ctx = $self->provision_raw_prepare($prefix, "domain controller",
+					       $dcname,
+					       $domain,
+					       $realm,
+					       undef,
+					       "2008",
+					       $password,
+					       undef,
+					       undef);
+
+	# the restore uses a slightly different state-dir location to other testenvs
+	$ctx->{statedir} = "$ctx->{prefix_abs}/state";
+	push(@{$ctx->{directories}}, "$ctx->{statedir}");
+
+	# add support for sysvol/netlogon/tmp shares
+	$ctx->{share} = "$ctx->{prefix_abs}/share";
+	push(@{$ctx->{directories}}, "$ctx->{share}");
+
+	$ctx->{smb_conf_extra_options} = "
+	max xmit = 32K
+	server max protocol = SMB2
+
+[sysvol]
+	path = $ctx->{statedir}/sysvol
+	read only = no
+
+[netlogon]
+	path = $ctx->{statedir}/sysvol/$ctx->{dnsname}/scripts
+	read only = no
+
+[tmp]
+	path = $ctx->{share}
+	read only = no
+	posix:sharedelay = 10000
+	posix:oplocktimeout = 3
+	posix:writetimeupdatedelay = 50000
+
+";
+
+	my $env = $self->provision_raw_step1($ctx);
+
+	$env->{DC_SERVER} = $env->{SERVER};
+	$env->{DC_SERVER_IP} = $env->{SERVER_IP};
+	$env->{DC_SERVER_IPV6} = $env->{SERVER_IPV6};
+	$env->{DC_NETBIOSNAME} = $env->{NETBIOSNAME};
+	$env->{DC_USERNAME} = $env->{USERNAME};
+	$env->{DC_PASSWORD} = $env->{PASSWORD};
+
+    return $env;
+}
+
+
+# Set up a DC testenv solely by using the samba-tool domain backup/restore
+# commands. This proves that we can backup an online DC ('backupfromdc') and
+# use the backup file to create a valid, working samba DC.
+sub setup_restoredc
+{
+	# note: dcvars contains the env info for the dependent testenv ('backupfromdc')
+	my ($self, $prefix, $dcvars) = @_;
+	print "Preparing RESTORE DC...\n";
+
+	my $env = $self->prepare_dc_testenv($prefix, "restoredc",
+					    $dcvars->{DOMAIN}, $dcvars->{REALM},
+					    $dcvars->{PASSWORD});
+
+	# create a backup of the 'backupfromdc'
+	my $backupdir = File::Temp->newdir();
+	my $backup_file = $self->create_backup($env, $dcvars, $backupdir, "online");
+	unless($backup_file) {
+		return undef;
+	}
+
+	# restore the backup file to populate the restore-DC testenv
+	my $restore_dir = abs_path($prefix);
+	my $ret = $self->restore_backup_file($backup_file,
+					     "--newservername=$env->{SERVER}",
+					     $restore_dir, $env->{SERVERCONFFILE});
+	unless ($ret == 0) {
+		return undef;
+	}
+
+	# start samba for the restored DC
+	if (not defined($self->check_or_start($env, "standard"))) {
+	    return undef;
+	}
+
+	my $upn_array = ["$env->{REALM}.upn"];
+	my $spn_array = ["$env->{REALM}.spn"];
+
+	$self->setup_namespaces($env, $upn_array, $spn_array);
+
+	return $env;
+}
+
 sub setup_none
 {
 	my ($self, $path) = @_;
-- 
2.7.4


From e8037d6f324cd3cad690702b990bb123c368ee63 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 21 Jun 2018 15:04:00 +1200
Subject: [PATCH 5/6] tests: Add a sub-set of tests to show the restored DC is
 sound

+ Add a new ldapcmp_restoredc.sh test that asserts that the original DC
backed up (backupfromdc) matches the new restored DC.
+ Add a new join_ldapcmp.sh test that asserts we can join a given DC,
and that the resulting DB matches the joined DC
+ Add a new login_basics.py test that sanity-checks Kerberos and NTLM
user login works. (This reuses the password_lockout base code, without
taking as long as the password_lockout tests do). Basic LDAP and SAMR
connections are also tested as a side-effect.
+ run the netlogonsvc test against the restored DC to prove we can
establish a netlogon connection.
+ run the same subset of rpc.echo tests that we do for RODC
+ run dbcheck over the new testenvs at the end of the test run

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet at samba.org>
---
 source4/dsdb/tests/python/login_basics.py | 184 ++++++++++++++++++++++++++++++
 source4/selftest/tests.py                 |  28 ++++-
 testprogs/blackbox/join_ldapcmp.sh        |  41 +++++++
 testprogs/blackbox/ldapcmp_restoredc.sh   |  65 +++++++++++
 4 files changed, 314 insertions(+), 4 deletions(-)
 create mode 100755 source4/dsdb/tests/python/login_basics.py
 create mode 100755 testprogs/blackbox/join_ldapcmp.sh
 create mode 100755 testprogs/blackbox/ldapcmp_restoredc.sh

diff --git a/source4/dsdb/tests/python/login_basics.py b/source4/dsdb/tests/python/login_basics.py
new file mode 100755
index 0000000..d162788
--- /dev/null
+++ b/source4/dsdb/tests/python/login_basics.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Basic sanity-checks of user login. This sanity-checks that a user can login
+# over both NTLM and Kerberos, that incorrect passwords are rejected, and that
+# the user can change their password successfully.
+#
+# Copyright Andrew Bartlett 2018
+#
+from __future__ import print_function
+import optparse
+import sys
+from samba.tests.subunitrun import TestProgram, SubunitOptions
+import samba.getopt as options
+from samba.auth import system_session
+from samba.credentials import MUST_USE_KERBEROS
+from samba.dsdb import UF_NORMAL_ACCOUNT
+from samba.samdb import SamDB
+from password_lockout_base import BasePasswordTestCase
+
+sys.path.insert(0, "bin/python")
+
+parser = optparse.OptionParser("password_lockout.py [options] <host>")
+sambaopts = options.SambaOptions(parser)
+parser.add_option_group(sambaopts)
+parser.add_option_group(options.VersionOptions(parser))
+# use command line creds if available
+credopts = options.CredentialsOptions(parser)
+parser.add_option_group(credopts)
+subunitopts = SubunitOptions(parser)
+parser.add_option_group(subunitopts)
+opts, args = parser.parse_args()
+
+if len(args) < 1:
+    parser.print_usage()
+    sys.exit(1)
+
+host = args[0]
+
+lp = sambaopts.get_loadparm()
+global_creds = credopts.get_credentials(lp)
+
+
+#
+# Tests start here
+#
+class BasicUserAuthTests(BasePasswordTestCase):
+
+    def setUp(self):
+        self.host = host
+        self.host_url = host_url
+        self.lp = lp
+        self.global_creds = global_creds
+        self.ldb = SamDB(url=self.host_url, credentials=self.global_creds,
+                         session_info=system_session(self.lp), lp=self.lp)
+        super(BasicUserAuthTests, self).setUp()
+
+    def _test_login_basics(self, creds):
+        username = creds.get_username()
+        userpass = creds.get_password()
+        userdn = "cn=%s,cn=users,%s" % (username, self.base_dn)
+        if creds.get_kerberos_state() == MUST_USE_KERBEROS:
+            logoncount_relation = 'greater'
+            lastlogon_relation = 'greater'
+            print("Performs a lockout attempt against LDAP using Kerberos")
+        else:
+            logoncount_relation = 'equal'
+            lastlogon_relation = 'equal'
+            print("Performs a lockout attempt against LDAP using NTLM")
+
+        # get the intial logon values for this user
+        res = self._check_account(userdn,
+                                  badPwdCount=0,
+                                  badPasswordTime=("greater", 0),
+                                  logonCount=(logoncount_relation, 0),
+                                  lastLogon=("greater", 0),
+                                  lastLogonTimestamp=("greater", 0),
+                                  userAccountControl=UF_NORMAL_ACCOUNT,
+                                  msDSUserAccountControlComputed=0,
+                                  msg='Initial test setup...')
+        badPasswordTime = int(res[0]["badPasswordTime"][0])
+        logonCount = int(res[0]["logonCount"][0])
+        lastLogon = int(res[0]["lastLogon"][0])
+        lastLogonTimestamp = int(res[0]["lastLogonTimestamp"][0])
+
+        test_creds = self.insta_creds(creds)
+
+        # check logging in with the wrong password fails
+        test_creds.set_password("thatsAcomplPASS1xBAD")
+        self.assertLoginFailure(self.host_url, test_creds, self.lp)
+        res = self._check_account(userdn,
+                                  badPwdCount=1,
+                                  badPasswordTime=("greater", badPasswordTime),
+                                  logonCount=logonCount,
+                                  lastLogon=lastLogon,
+                                  lastLogonTimestamp=lastLogonTimestamp,
+                                  userAccountControl=UF_NORMAL_ACCOUNT,
+                                  msDSUserAccountControlComputed=0,
+                                  msg='Test login with wrong password')
+        badPasswordTime = int(res[0]["badPasswordTime"][0])
+
+        # check logging in with the correct password succeeds
+        test_creds.set_password(userpass)
+        user_ldb = SamDB(url=self.host_url, credentials=test_creds, lp=self.lp)
+        res = self._check_account(userdn,
+                                  badPwdCount=0,
+                                  badPasswordTime=badPasswordTime,
+                                  logonCount=(logoncount_relation, logonCount),
+                                  lastLogon=('greater', lastLogon),
+                                  lastLogonTimestamp=lastLogonTimestamp,
+                                  userAccountControl=UF_NORMAL_ACCOUNT,
+                                  msDSUserAccountControlComputed=0,
+                                  msg='Test login with correct password')
+        logonCount = int(res[0]["logonCount"][0])
+        lastLogon = int(res[0]["lastLogon"][0])
+
+        # check that the user can change its password
+        new_password = "thatsAcomplPASS2"
+        user_ldb.modify_ldif("""
+dn: %s
+changetype: modify
+delete: userPassword
+userPassword: %s
+add: userPassword
+userPassword: %s
+""" % (userdn, userpass, new_password))
+
+        # discard the old creds (i.e. get rid of our valid Kerberos ticket)
+        del test_creds
+        test_creds = self.insta_creds(creds)
+        test_creds.set_password(userpass)
+
+        # for Kerberos, logging in with the old password fails
+        if creds.get_kerberos_state() == MUST_USE_KERBEROS:
+            self.assertLoginFailure(self.host_url, test_creds, self.lp)
+            info_msg = 'Test Kerberos login with old password fails'
+            expectBadPwdTime = ("greater", badPasswordTime)
+            res = self._check_account(userdn,
+                                      badPwdCount=1,
+                                      badPasswordTime=expectBadPwdTime,
+                                      logonCount=logonCount,
+                                      lastLogon=lastLogon,
+                                      lastLogonTimestamp=lastLogonTimestamp,
+                                      userAccountControl=UF_NORMAL_ACCOUNT,
+                                      msDSUserAccountControlComputed=0,
+                                      msg=info_msg)
+            badPasswordTime = int(res[0]["badPasswordTime"][0])
+        else:
+            # for NTLM, logging in with the old password succeeds
+            user_ldb = SamDB(url=self.host_url, credentials=test_creds,
+                             lp=self.lp)
+            info_msg = 'Test NTLM login with old password succeeds'
+            res = self._check_account(userdn,
+                                      badPwdCount=0,
+                                      badPasswordTime=badPasswordTime,
+                                      logonCount=logonCount,
+                                      lastLogon=lastLogon,
+                                      lastLogonTimestamp=lastLogonTimestamp,
+                                      userAccountControl=UF_NORMAL_ACCOUNT,
+                                      msDSUserAccountControlComputed=0,
+                                      msg=info_msg)
+
+        # check logging in with the new password succeeds
+        test_creds.set_password(new_password)
+        user_ldb = SamDB(url=self.host_url, credentials=test_creds, lp=self.lp)
+        res = self._check_account(userdn,
+                                  badPwdCount=0,
+                                  badPasswordTime=badPasswordTime,
+                                  logonCount=(logoncount_relation, logonCount),
+                                  lastLogon=(lastlogon_relation, lastLogon),
+                                  lastLogonTimestamp=lastLogonTimestamp,
+                                  userAccountControl=UF_NORMAL_ACCOUNT,
+                                  msDSUserAccountControlComputed=0,
+                                  msg='Test login with new password succeeds')
+
+    def test_login_basics_krb5(self):
+        self._test_login_basics(self.lockout1krb5_creds)
+
+    def test_login_basics_ntlm(self):
+        self._test_login_basics(self.lockout1ntlm_creds)
+
+host_url = "ldap://%s" % host
+
+TestProgram(module=__name__, opts=subunitopts)
diff --git a/source4/selftest/tests.py b/source4/selftest/tests.py
index bca002f..4504a2b 100755
--- a/source4/selftest/tests.py
+++ b/source4/selftest/tests.py
@@ -811,6 +811,17 @@ plantestsuite_loadlist("samba4.ldap.sort.python(ad_dc_ntvfs)", "ad_dc_ntvfs", [p
 plantestsuite_loadlist("samba4.ldap.vlv.python(ad_dc_ntvfs)", "ad_dc_ntvfs", [python, os.path.join(samba4srcdir, "dsdb/tests/python/vlv.py"), '$SERVER', '-U"$USERNAME%$PASSWORD"', '--workgroup=$DOMAIN', '$LOADLIST', '$LISTOPT'])
 plantestsuite_loadlist("samba4.ldap.linked_attributes.python(ad_dc_ntvfs)", "ad_dc_ntvfs:local", [python, os.path.join(samba4srcdir, "dsdb/tests/python/linked_attributes.py"), '$PREFIX_ABS/ad_dc_ntvfs/private/sam.ldb', '-U"$USERNAME%$PASSWORD"', '--workgroup=$DOMAIN', '$LOADLIST', '$LISTOPT'])
 
+# These should be the first tests run against testenvs created by backup/restore
+for env in ['restoredc']:
+    # check that a restored DC matches the original DC (backupfromdc)
+    plantestsuite("samba4.blackbox.ldapcmp_restore", env,
+        ["PYTHON=%s" % python,
+         os.path.join(bbdir, "ldapcmp_restoredc.sh"),
+         '$PREFIX_ABS/backupfromdc', '$PREFIX_ABS/%s' % env])
+    # basic test that we can join the testenv DC
+    plantestsuite("samba4.blackbox.join_ldapcmp", env,
+        ["PYTHON=%s" % python, os.path.join(bbdir, "join_ldapcmp.sh")])
+
 plantestsuite_loadlist("samba4.ldap.rodc.python(rodc)", "rodc",
                        [python,
                         os.path.join(samba4srcdir, "dsdb/tests/python/rodc.py"),
@@ -857,6 +868,13 @@ for env in ["ad_dc_ntvfs"]:
                            extra_path=[os.path.join(samba4srcdir, 'dsdb/tests/python')]
                            )
 
+# this is a basic sanity-check of Kerberos/NTLM user login
+for env in ["restoredc"]:
+    plantestsuite_loadlist("samba4.ldap.login_basics.python(%s)" % env, env,
+        [python, os.path.join(samba4srcdir, "dsdb/tests/python/login_basics.py"),
+         "$SERVER", '-U"$USERNAME%$PASSWORD"', "-W$DOMAIN", "--realm=$REALM",
+         '$LOADLIST', '$LISTOPT'])
+
 planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.upgradeprovisionneeddc")
 planpythontestsuite("ad_dc:local", "samba.tests.posixacl", py3_compatible=True)
 planpythontestsuite("ad_dc_no_nss:local", "samba.tests.posixacl", py3_compatible=True)
@@ -887,8 +905,8 @@ t = "rpc.samr.large-dc"
 plansmbtorture4testsuite(t, "vampire_dc", ['$SERVER', '-U$USERNAME%$PASSWORD', '--workgroup=$DOMAIN'], modname=("samba4.%s.one" % t))
 plansmbtorture4testsuite(t, "vampire_dc", ['$SERVER', '-U$USERNAME%$PASSWORD', '--workgroup=$DOMAIN'], modname="samba4.%s.two" % t)
 
-# some RODC testing
-for env in ['rodc']:
+# RPC smoke-tests for testenvs of interest (RODC, etc)
+for env in ['rodc', 'restoredc']:
     plansmbtorture4testsuite('rpc.echo', env, ['ncacn_np:$SERVER', "-k", "yes", '-U$USERNAME%$PASSWORD', '--workgroup=$DOMAIN'], modname="samba4.rpc.echo")
     plansmbtorture4testsuite('rpc.echo', "%s:local" % env, ['ncacn_np:$SERVER', "-k", "yes", '-P', '--workgroup=$DOMAIN'], modname="samba4.rpc.echo")
     plansmbtorture4testsuite('rpc.echo', "%s:local" % env, ['ncacn_np:$SERVER', "-k", "no", '-Utestallowed\ account%$DC_PASSWORD', '--workgroup=$DOMAIN'], modname="samba4.rpc.echo.testallowed")
@@ -1067,7 +1085,8 @@ for env in [
 
 planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.kcc.kcc_utils")
 
-for env in [ "simpleserver", "fileserver", "nt4_dc", "ad_dc", "ad_dc_ntvfs", "ad_member"]:
+for env in [ "simpleserver", "fileserver", "nt4_dc", "ad_dc", "ad_dc_ntvfs",
+             "ad_member", "restoredc" ]:
     planoldpythontestsuite(env, "netlogonsvc",
                            extra_path=[os.path.join(srcdir(), 'python/samba/tests')],
                            name="samba.tests.netlogonsvc.python(%s)" % env)
@@ -1090,7 +1109,8 @@ for env in ['vampire_dc', 'promoted_dc', 'rodc']:
 # TODO: Verifying the databases really should be a part of the
 # environment teardown.
 # check the databases are all OK. PLEASE LEAVE THIS AS THE LAST TEST
-for env in ["ad_dc_ntvfs", "ad_dc", "fl2000dc", "fl2003dc", "fl2008r2dc", 'vampire_dc', 'promoted_dc']:
+for env in ["ad_dc_ntvfs", "ad_dc", "fl2000dc", "fl2003dc", "fl2008r2dc",
+            'vampire_dc', 'promoted_dc', 'backupfromdc', 'restoredc']:
     plantestsuite("samba4.blackbox.dbcheck(%s)" % env, env + ":local" , ["PYTHON=%s" % python, os.path.join(bbdir, "dbcheck.sh"), '$PREFIX/provision', configuration])
 
 # cmocka tests not requiring a specific encironment
diff --git a/testprogs/blackbox/join_ldapcmp.sh b/testprogs/blackbox/join_ldapcmp.sh
new file mode 100755
index 0000000..30d3e1e
--- /dev/null
+++ b/testprogs/blackbox/join_ldapcmp.sh
@@ -0,0 +1,41 @@
+#!/bin/sh
+# Does a join against the testenv's DC and then runs ldapcmp on the resulting DB
+
+. `dirname $0`/subunit.sh
+
+TARGET_DIR="$PREFIX_ABS/join_$SERVER"
+
+cleanup_output_dir()
+{
+    if [ -d $TARGET_DIR ]; then
+        rm -fr $TARGET_DIR
+    fi
+}
+
+SAMBA_TOOL="$PYTHON $BINDIR/samba-tool"
+
+join_dc() {
+    JOIN_ARGS="--targetdir=$TARGET_DIR --server=$SERVER -U$USERNAME%$PASSWORD"
+    $SAMBA_TOOL domain join $REALM dc $JOIN_ARGS --option="netbios name = TESTJOINDC"
+}
+
+ldapcmp_result() {
+    DB1_PATH="tdb://$PREFIX_ABS/$SERVER/private/sam.ldb"
+    DB2_PATH="tdb://$TARGET_DIR/private/sam.ldb"
+
+    # interSiteTopologyGenerator gets periodically updated. With the restored
+    # testenvs, it can sometimes point to the old/deleted DC object still
+    $SAMBA_TOOL ldapcmp $DB1_PATH $DB2_PATH --filter=interSiteTopologyGenerator
+}
+
+cleanup_output_dir
+
+# check that we can join this DC
+testit "check_dc_join" join_dc
+
+# check resulting DB matches server DC
+testit "new_db_matches" ldapcmp_result
+
+cleanup_output_dir
+
+exit $failed
diff --git a/testprogs/blackbox/ldapcmp_restoredc.sh b/testprogs/blackbox/ldapcmp_restoredc.sh
new file mode 100755
index 0000000..51951ba
--- /dev/null
+++ b/testprogs/blackbox/ldapcmp_restoredc.sh
@@ -0,0 +1,65 @@
+#!/bin/sh
+# Does an ldapcmp between a newly restored testenv and the original testenv it
+# was based on
+
+if [ $# -lt 2 ]; then
+cat <<EOF
+Usage: $0 ORIG_DC_PREFIX RESTORED_DC_PREFIX
+EOF
+exit 1;
+fi
+
+ORIG_DC_PREFIX_ABS="$1"
+RESTORED_DC_PREFIX_ABS="$2"
+shift 2
+
+. `dirname $0`/subunit.sh
+
+basedn() {
+    SAMDB_PATH=$1
+    $BINDIR/ldbsearch -H $SAMDB_PATH --basedn='' -s base defaultNamingContext | grep defaultNamingContext | awk '{print $2}'
+}
+
+ldapcmp_with_orig() {
+
+    DB1_PATH="tdb://$ORIG_DC_PREFIX_ABS/private/sam.ldb"
+    DB2_PATH="tdb://$RESTORED_DC_PREFIX_ABS/private/sam.ldb"
+
+    # check if the 2 DCs are in different domains
+    DC1_BASEDN=$(basedn $DB1_PATH)
+    DC2_BASEDN=$(basedn $DB2_PATH)
+    BASE_DN_OPTS=""
+
+    # if necessary, pass extra args to ldapcmp to handle the difference in base DNs
+    if [ "$DC1_BASEDN" != "$DC2_BASEDN" ] ; then
+        BASE_DN_OPTS="--base=$DC1_BASEDN --base2=$DC2_BASEDN"
+    fi
+
+    # the restored DC will remove DNS entries for the old DC(s)
+    IGNORE_ATTRS="dnsRecord,dNSTombstoned"
+
+    # DC2 joined DC1, so it will have different DRS info
+    IGNORE_ATTRS="$IGNORE_ATTRS,msDS-NC-Replica-Locations,msDS-HasInstantiatedNCs"
+    IGNORE_ATTRS="$IGNORE_ATTRS,interSiteTopologyGenerator"
+
+    # there's a servicePrincipalName that uses the objectGUID of the DC's NTDS
+    # Settings that will differ between the two DCs
+    IGNORE_ATTRS="$IGNORE_ATTRS,servicePrincipalName"
+
+    # the restore changes the new DC's password twice
+    IGNORE_ATTRS="$IGNORE_ATTRS,lastLogonTimestamp"
+
+    # The RID pools get bumped during the restore process
+    IGNORE_ATTRS="$IGNORE_ATTRS,rIDAllocationPool,rIDAvailablePool"
+
+    # these are just differences between provisioning a domain and joining a DC
+    IGNORE_ATTRS="$IGNORE_ATTRS,localPolicyFlags,operatingSystem,displayName"
+
+    LDAPCMP_CMD="$PYTHON $BINDIR/samba-tool ldapcmp"
+    $LDAPCMP_CMD $DB1_PATH $DB2_PATH --two --filter=$IGNORE_ATTRS $BASE_DN_OPTS
+}
+
+# check that the restored testenv DC basically matches the original
+testit "orig_dc_matches" ldapcmp_with_orig
+
+exit $failed
-- 
2.7.4


From 5ade84b37bccc571036167049b396f297418b9d2 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Wed, 6 Jun 2018 10:04:29 +1200
Subject: [PATCH 6/6] drs_utils: Add infrastructure to support 'clone with
 rename'

Our end goal is to create a backup clone of a DB, but rename the
domain/realm so we can startup the backup DC without interferring with
the existing Samba network. The basic strategy to do this is to leverage
DRS replication - by renaming the first object in the partition, all
subsequent objects will automatically be renamed.

This patch adds the infrastructure to do this. I've used object
inheritance to handle the special case of renaming the partition
objects. This means the domain-rename special case doesn't really
pollute the existing DRS replication code. All it needs is a small
refactor to create a new 'process_chunk()' function that the new
sub-class can then override.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet at samba.org>
---
 python/samba/drs_utils.py | 74 ++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 70 insertions(+), 4 deletions(-)

diff --git a/python/samba/drs_utils.py b/python/samba/drs_utils.py
index aa4605e..db83267 100644
--- a/python/samba/drs_utils.py
+++ b/python/samba/drs_utils.py
@@ -24,7 +24,8 @@ from samba import dsdb
 from samba import werror
 from samba import WERRORError
 import samba, ldb
-
+from samba.dcerpc.drsuapi import DRSUAPI_ATTID_name
+import re
 
 class drsException(Exception):
     """Base element for drs errors"""
@@ -213,6 +214,12 @@ class drs_Replicate(object):
                 (req.more_flags & drsuapi.DRSUAPI_DRS_GET_TGT) == 0 and
                 self.supported_extensions & drsuapi.DRSUAPI_SUPPORTED_EXTENSION_GETCHGREQ_V10)
 
+    def process_chunk(self, level, ctr, schema, req_level, req, first_chunk):
+        '''Processes a single chunk of received replication data'''
+        # pass the replication into the py_net.c python bindings for processing
+        self.net.replicate_chunk(self.replication_state, level, ctr,
+                                 schema=schema, req_level=req_level, req=req)
+
     def replicate(self, dn, source_dsa_invocation_id, destination_dsa_guid,
                   schema=False, exop=drsuapi.DRSUAPI_EXOP_NONE, rodc=False,
                   replica_flags=None, full_sync=True, sync_forced=False, more_flags=0):
@@ -309,14 +316,15 @@ class drs_Replicate(object):
 
         num_objects = 0
         num_links = 0
+        first_chunk = True
+
         while True:
             (level, ctr) = self.drs.DsGetNCChanges(self.drs_handle, req_level, req)
             if ctr.first_object is None and ctr.object_count != 0:
                 raise RuntimeError("DsGetNCChanges: NULL first_object with object_count=%u" % (ctr.object_count))
 
             try:
-                self.net.replicate_chunk(self.replication_state, level, ctr,
-                    schema=schema, req_level=req_level, req=req)
+                self.process_chunk(level, ctr, schema, req_level, req, first_chunk)
             except WERRORError as e:
                 # Check if retrying with the GET_TGT flag set might resolve this error
                 if self._should_retry_with_get_tgt(e.args[0], req):
@@ -324,11 +332,14 @@ class drs_Replicate(object):
                     print("Missing target object - retrying with DRS_GET_TGT")
                     req.more_flags |= drsuapi.DRSUAPI_DRS_GET_TGT
 
-                    # try sending the request again
+                    # try sending the request again (this has the side-effect
+                    # of causing the DC to restart the replication from scratch)
+                    first_chunk = True
                     continue
                 else:
                     raise e
 
+            first_chunk = False
             num_objects += ctr.object_count
 
             # Cope with servers that do not return level 6, so do not return any links
@@ -342,3 +353,58 @@ class drs_Replicate(object):
             req.highwatermark = ctr.new_highwatermark
 
         return (num_objects, num_links)
+
+
+# Handles the special case of creating a new clone of a DB, while also renaming
+# the entire DB's objects on the way through
+class drs_ReplicateRenamer(drs_Replicate):
+    '''Uses DRS replication to rename the entire DB'''
+
+    def __init__(self, binding_string, lp, creds, samdb, invocation_id,
+                 old_base_dn, new_base_dn):
+        super(drs_ReplicateRenamer, self).__init__(binding_string, lp, creds,
+                                                   samdb, invocation_id)
+        self.old_base_dn = old_base_dn
+        self.new_base_dn = new_base_dn
+
+    def rename_dn(self, dn_str):
+        '''Uses string substitution to replace the base DN'''
+        return re.sub('%s$' % self.old_base_dn, self.new_base_dn, dn_str)
+
+    def update_name_attr(self, base_obj):
+        '''Updates the 'name' attribute for the base DN object'''
+        for attr in base_obj.attribute_ctr.attributes:
+            if attr.attid == DRSUAPI_ATTID_name:
+                base_dn = ldb.Dn(self.samdb, base_obj.identifier.dn)
+                new_name = base_dn.get_rdn_value()
+                attr.value_ctr.values[0].blob = new_name.encode('utf-16-le')
+
+    def rename_top_level_object(self, first_obj):
+        '''Renames the first/top-level object in a partition'''
+        old_dn = first_obj.identifier.dn
+        first_obj.identifier.dn = self.rename_dn(first_obj.identifier.dn)
+        print("Renaming partition %s --> %s" % (old_dn,
+                                                first_obj.identifier.dn))
+
+        # we also need to fix up the 'name' attribute for the base DN,
+        # otherwise the RDNs won't match
+        if first_obj.identifier.dn == self.new_base_dn:
+            self.update_name_attr(first_obj)
+
+    def process_chunk(self, level, ctr, schema, req_level, req, first_chunk):
+        '''Processes a single chunk of received replication data'''
+
+        # we need to rename the NC in every chunk - this gets used in searches
+        # when applying the chunk
+        if ctr.naming_context:
+            ctr.naming_context.dn = self.rename_dn(ctr.naming_context.dn)
+
+        # rename the first object in each partition. This will cause every
+        # subsequent object in the partiton to be renamed as a side-effect
+        if first_chunk and ctr.object_count != 0:
+            self.rename_top_level_object(ctr.first_object.object)
+
+        # then do the normal repl processing to apply this chunk to our DB
+        super(drs_ReplicateRenamer, self).process_chunk(level, ctr, schema,
+                                                        req_level, req,
+                                                        first_chunk)
-- 
2.7.4



More information about the samba-technical mailing list