[PATCH] Domain backup and restore samba-tool commands

Tim Beale timbeale at catalyst.net.nz
Thu Jun 28 05:14:09 UTC 2018


Hi,

FYI, attached are the latest patches for the backup/restore tool. The
samba-tool patches have been reworked quite a bit, and I think are
pretty much ready now (apart from integrating with Joe's NTACL patches).
The backup-specific tests (patch #4) probably still need extending a bit.

CI link:
https://gitlab.com/catalyst-samba/samba/pipelines/24728231

Cheers,
Tim
-------------- next part --------------
From 1535cc430c60d40e2b81f6421e7ba2a5f000f69c 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] join: Pipe through dns_backend option for clones

Allow join_clone() calls to specify a dns_backend parameter for the new
cloned DB.

Signed-off-by: Aaron Haslett<aaronhaslett at catalyst.net.nz>
---
 python/samba/join.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/python/samba/join.py b/python/samba/join.py
index 30ecce7..b5cdf52 100644
--- a/python/samba/join.py
+++ b/python/samba/join.py
@@ -1495,10 +1495,10 @@ def join_DC(logger=None, server=None, creds=None, lp=None, site=None, netbios_na
     logger.info("Joined domain %s (SID %s) as a DC" % (ctx.domain_name, ctx.domsid))
 
 def join_clone(logger=None, server=None, creds=None, lp=None,
-               targetdir=None, domain=None, include_secrets=False):
+               targetdir=None, domain=None, include_secrets=False, dns_backend="NONE"):
     """Join as a DC."""
     ctx = dc_join(logger, server, creds, lp, site=None, netbios_name=None, targetdir=targetdir, domain=domain,
-                  machinepass=None, use_ntvfs=False, dns_backend="NONE", promote_existing=False, clone_only=True)
+                  machinepass=None, use_ntvfs=False, dns_backend=dns_backend, promote_existing=False, clone_only=True)
 
     lp.set("workgroup", ctx.domain_name)
     logger.info("workgroup is %s" % ctx.domain_name)
-- 
2.7.4


From ae254a8b01d648fed29aa76336646fe739d9a5f6 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 2/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>
---
 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 | 231 +++++++++++++++++++++++++++++++++++
 4 files changed, 244 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 b5cdf52..61f202c 100644
--- a/python/samba/join.py
+++ b/python/samba/join.py
@@ -1514,6 +1514,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..cc97ed0 100644
--- a/python/samba/netcmd/domain.py
+++ b/python/samba/netcmd/domain.py
@@ -4334,3 +4334,5 @@ class cmd_domain(SuperCommand):
     subcommands["tombstones"] = cmd_domain_tombstones()
     subcommands["schemaupgrade"] = cmd_domain_schema_upgrade()
     subcommands["functionalprep"] = cmd_domain_functional_prep()
+    from domain_backup import cmd_domain_backup
+    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..74f105f
--- /dev/null
+++ b/python/samba/netcmd/domain_backup.py
@@ -0,0 +1,231 @@
+# 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, os, sys, tarfile, subprocess, logging, shutil, errno
+import tempfile
+import samba
+from samba import Ldb
+import tdb
+import samba.getopt as options
+from samba.samdb import SamDB
+import ldb
+from samba.auth import system_session
+from samba.join import dc_join, 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 copy_sysvol_files(server, creds, output_dir):
+    '''Copies the remote DC's sysvol files into the given output directory'''
+
+    # Run smbclient to get the remote sysvol files
+    cmd = os.path.join(samba.param.bin_dir(), 'smbclient')
+    cmd += ' //{s}/SYSVOL -U{user}%{pwd}'.format(s=server,
+            user=creds.get_username(), pwd=creds.get_password())
+    cmd = cmd.split(' ')
+
+    # download the sysvol files into a temporary tar file (for convenience)
+    tmp_sysvol_tar = os.path.join(output_dir, 'tmpsysvol.tar')
+    cmd += ['-c',  'tar cF {} ./'.format(tmp_sysvol_tar)]
+    status = subprocess.call(cmd, close_fds=True, shell=False)
+
+    # extract the sysvol files into the temporary directory (we'll tar up
+    # everything in the tmpdir later)
+    if not os.path.exists(output_dir):
+        os.makedirs(output_dir)
+    tf = tarfile.open(tmp_sysvol_tar)
+    tf.extractall(output_dir)
+    tf.close()
+    os.remove(tmp_sysvol_tar)
+
+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, session_info=system_session(),
+                           credentials=creds, lp=lp)
+        new_sid = get_sid_for_restore(remote_sam)
+        realm = remote_sam.domain_dns_name()
+
+        # 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)
+
+        # Extract the remote DC's sysvol files into our tmp backup-dir
+        copy_sysvol_files(server, creds, paths.sysvol)
+
+        # 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 b5fd163f91cd3f6d14092595ed3dc14863f65bf6 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 3/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>
---
 docs-xml/manpages/samba-tool.8.xml   |   5 ++
 python/samba/join.py                 |  36 +++++---
 python/samba/netcmd/domain_backup.py | 170 ++++++++++++++++++++++++++++++++++-
 3 files changed, 197 insertions(+), 14 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 61f202c..a9488ef 100644
--- a/python/samba/join.py
+++ b/python/samba/join.py
@@ -57,7 +57,8 @@ class dc_join(object):
                  netbios_name=None, targetdir=None, domain=None,
                  machinepass=None, use_ntvfs=False, dns_backend=None,
                  promote_existing=False, clone_only=False,
-                 plaintext_secrets=False, backend_store=None):
+                 plaintext_secrets=False, backend_store=None,
+                 forced_local_samdb=None):
         if site is None:
             site = "Default-First-Site-Name"
 
@@ -81,16 +82,20 @@ class dc_join(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"])
@@ -573,7 +578,9 @@ class dc_join(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])
@@ -582,7 +589,7 @@ class dc_join(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)
@@ -612,12 +619,15 @@ class dc_join(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)
+                ctx.samdb.add(rec, controls=["relax:0"])
 
         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 74f105f..e47e733 100644
--- a/python/samba/netcmd/domain_backup.py
+++ b/python/samba/netcmd/domain_backup.py
@@ -21,13 +21,18 @@ import tempfile
 import samba
 from samba import Ldb
 import tdb
+from fsmo import cmd_fsmo_seize
 import samba.getopt as options
 from samba.samdb import SamDB
 import ldb
+from samba.provision import make_smbconf
 from samba.auth import system_session
+from samba.upgradehelpers import update_krbtgt_account_password
+from samba.emulate.traffic import create_machine_account
 from samba.join import dc_join, join_clone
 from samba.dcerpc.security import dom_sid
 from samba.netcmd import Option, CommandError
+from samba.dcerpc import misc
 import traceback
 
 tmpdir = 'backup_temp_dir'
@@ -226,6 +231,169 @@ class cmd_domain_backup_online(samba.netcmd.Command):
         backup_file = backup_filepath(targetdir, realm, time_str)
         create_backup_tar(logger, tmpdir, backup_file)
 
+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.
+    '''
+
+    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 = dc_join(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, serverrole="active directory domain controller",
+                     lp=lp)
+
+        # 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')
+        m["dsServiceName"] = ldb.MessageElement("<GUID=%s>" % str(ctx.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)
+        samba.provision.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
+        res = samdb.search(samdb.get_config_basedn(),
+                           scope=ldb.SCOPE_SUBTREE,
+                           expression="(&(objectClass=Server)(serverReference=*))")
+        for m in res:
+            cn = m.get('cn')[0]
+            if cn != newservername:
+                samba.remove_dc.remove_dc(samdb, logger, cn)
+
+        # Update tgt and DC passwords twice
+        update_krbtgt_account_password(samdb)
+        update_krbtgt_account_password(samdb)
+
+        # 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("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()}
+    subcommands = {'online': cmd_domain_backup_online(),
+                   'restore': cmd_domain_backup_restore()}
-- 
2.7.4


From e7c989271631a89decad9c388fc754a7078f7aaa 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 4/6] tests: Add tests for the domain backup online/restore
 commands

Signed-off-by: Aaron Haslett <aaronhaslett at catalyst.net.nz>
---
 python/samba/tests/domain_backup.py | 174 ++++++++++++++++++++++++++++++++++++
 source4/selftest/tests.py           |   3 +
 2 files changed, 177 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..d4920ab
--- /dev/null
+++ b/python/samba/tests/domain_backup.py
@@ -0,0 +1,174 @@
+# 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.credentials import Credentials
+from samba import gensec, auth, tests, provision, param
+import tarfile, os
+from samba.tests.samba_tool.base import SambaToolCmdTest
+from samba import NTSTATUSError
+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(lp):
+    p = provision.provision_paths_from_lp(lp, lp.get("realm"))
+    secrets_ldb = Ldb(p.secrets, 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):
+
+    def test_online_untar(self):
+        cfg_fn = self.run_backup_and_restore()
+        bkp_lp = param.LoadParm(filename_for_non_global_lp = cfg_fn)
+        p = provision.provision_paths_from_lp(bkp_lp, bkp_lp.get("realm"))
+
+        samdb = SamDB(url=p.samdb, session_info=system_session(), lp=bkp_lp,
+                      credentials=self.get_credentials())
+        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.
+        res = get_prim_dom(bkp_lp)
+        self.assertEqual(len(res), 0)
+
+    def test_online_restore(self):
+        cfg_fn = self.run_backup_and_restore(use_restore=True)
+        self.check_restored_database(cfg_fn)
+
+    # We can't run kerberos test here because the host name changes during the
+    # restore.  In this test we will just check that the restore procedure
+    # modified the database correctly.  Full restore tool testing will be done
+    # using the restoredc testenv.
+    def check_restored_database(self, cfg_fn):
+        bkp_lp = param.LoadParm(filename_for_non_global_lp = cfg_fn)
+        p = provision.provision_paths_from_lp(bkp_lp, bkp_lp.get("realm"))
+
+        bkp_pd = get_prim_dom(bkp_lp)
+        self.assertEqual(len(bkp_pd), 1)
+        acn = bkp_pd[0].get('samAccountName')
+        self.assertIsNotNone(acn)
+        self.assertEqual(acn[0].replace('$', ''), bkp_lp.get("netbios name"))
+        self.assertIsNotNone(bkp_pd[0].get('secret'))
+
+        local_lp = tests.env_loadparm()
+        local_pd = get_prim_dom(local_lp)
+        self.assertEqual(int(local_pd[0].get('msDS-KeyVersionNumber')[0]) + 1,
+                         int(bkp_pd[0].get('msDS-KeyVersionNumber')[0]))
+
+        samdb = SamDB(url=p.samdb, session_info=system_session(), lp=bkp_lp,
+                      credentials=self.get_credentials())
+        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'))
+
+        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(bkp_lp.get("netbios name"))\
+                            in owner.extended_str())
+            self.assertTrue("CN={},".format(local_lp.get("netbios name"))\
+                            not in owner.extended_str())
+
+    def run_backup_and_restore(self, use_restore=False):
+        # Make a clean temp directory for the backup
+        tmp_backup_dir = os.path.join(os.environ["SELFTEST_PREFIX"],
+                                      ".backup-test-tmp")
+        if os.path.exists(tmp_backup_dir):
+            import shutil
+            shutil.rmtree(tmp_backup_dir)
+        os.mkdir(tmp_backup_dir)
+
+        c = self.get_credentials()
+        cred_str = "-U{}%{}".format(c.get_username(), c.get_password())
+        # Run the backup and check we got one backup tar file
+        nb_name = tests.env_loadparm().get("netbios name")
+        args = ["domain", "backup", "online",
+                "--server="+nb_name]
+        args += [cred_str, "--targetdir=" + tmp_backup_dir]
+        (result, out, err) = self.runsubcmd(*args)
+
+        self.assertCmdSuccess(result, out, err,
+                              "Ensuring domain backup command ran successfully")
+        tar_files = [fn for fn in os.listdir(tmp_backup_dir)
+                     if fn.startswith("samba-backup-") and
+                        fn.endswith(".tar.bz2")]
+        assert len(tar_files) == 1, "expected domain backup to create one" +\
+                                    "tar file but got {}".format(len(tar_files))
+
+        extract_dir = os.path.join(tmp_backup_dir, 'tree')
+        os.mkdir(extract_dir)
+        cfg_fn = os.path.join(os.getcwd(), extract_dir, "etc", "smb.conf")
+
+        # Run restore or extract tar
+        tar_fn = os.path.join(tmp_backup_dir, tar_files[0])
+        if use_restore:
+            args = ["domain", "backup", "restore", "--backup-file=" + tar_fn,
+                    "--targetdir=" + extract_dir,
+                    "--newservername=BACKUPSERV"]
+            (result, out, err) = self.runsubcmd(*args)
+            self.assertCmdSuccess(result, out, err,
+                              "Ensuring domain backup restore ran successfully")
+        else:
+            with tarfile.open(tar_fn) as tf:
+                tf.extractall(extract_dir)
+
+            # Get the root of the backup folders from loadparm to F/R in conf
+            lp = param.LoadParm(filename_for_non_global_lp = cfg_fn)
+            p = provision.provision_paths_from_lp(lp, lp.get("realm"))
+            backup_dirs = [p.private_dir, p.state_dir, p.sysvol]
+            backup_root = os.path.commonprefix(backup_dirs)
+            if backup_root[-1] == '/':
+                backup_root = backup_root[:-1]
+
+            # Make paths in the restored config file point the new temp dir
+            sed_lines = ""
+            with open(cfg_fn, "r") as cfg_file:
+                sed_lines = cfg_file.read().replace(backup_root,
+                                                os.path.realpath(extract_dir))
+            with open(cfg_fn, "w") as cfg_file:
+                cfg_file.write(sed_lines)
+
+        # Fill the temp backup dir with the empty folder that are needed,
+        for tmp_subdir_to_make in ["lockdir", "cachedir", "pid"]:
+            path = os.path.join(extract_dir, tmp_subdir_to_make)
+            if not os.path.exists(path):
+                os.mkdir(path)
+
+        return cfg_fn
diff --git a/source4/selftest/tests.py b/source4/selftest/tests.py
index 1b592c3..7a99fe9 100755
--- a/source4/selftest/tests.py
+++ b/source4/selftest/tests.py
@@ -727,6 +727,9 @@ planoldpythontestsuite("fl2003dc:local",
 planoldpythontestsuite("ad_dc",
                        "samba.tests.password_hash_ldap",
                        extra_args=['-U"$USERNAME%$PASSWORD"'])
+planoldpythontestsuite("ad_dc_ntvfs: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 06ba85e2d12401712c3d346248f73a5b2edd83ab 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 5/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>
---
 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 e47e733..e5b2ddf 100644
--- a/python/samba/netcmd/domain_backup.py
+++ b/python/samba/netcmd/domain_backup.py
@@ -263,7 +263,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..4e8c83a 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 6d3e6ecf83c883870cacd389b06d61a5a9198aeb 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 6/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>
---
 source4/dsdb/tests/python/login_basics.py | 181 ++++++++++++++++++++++++++++++
 source4/selftest/tests.py                 |  28 ++++-
 testprogs/blackbox/join_ldapcmp.sh        |  41 +++++++
 testprogs/blackbox/ldapcmp_restoredc.sh   |  65 +++++++++++
 4 files changed, 311 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..3cb218a
--- /dev/null
+++ b/source4/dsdb/tests/python/login_basics.py
@@ -0,0 +1,181 @@
+#!/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
+
+sys.path.insert(0, "bin/python")
+import samba
+from samba.tests.subunitrun import TestProgram, SubunitOptions
+import samba.getopt as options
+from samba.auth import system_session
+from samba.credentials import Credentials, MUST_USE_KERBEROS
+from samba import dsdb
+from samba.samdb import SamDB
+import samba.tests
+
+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)
+
+from password_lockout_base import BasePasswordTestCase
+
+#
+# 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, session_info=system_session(self.lp),
+                         credentials=self.global_creds, 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=dsdb.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=dsdb.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=dsdb.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)
+            res = self._check_account(userdn,
+                                      badPwdCount=1,
+                                      badPasswordTime=("greater", badPasswordTime),
+                                      logonCount=logonCount,
+                                      lastLogon=lastLogon,
+                                      lastLogonTimestamp=lastLogonTimestamp,
+                                      userAccountControl=dsdb.UF_NORMAL_ACCOUNT,
+                                      msDSUserAccountControlComputed=0,
+                                      msg='Test Kerberos login with old password fails')
+            badPasswordTime = int(res[0]["badPasswordTime"][0])
+        else:
+            # for NTLM, logging in with the old password succeeds
+            res = self._check_account(userdn,
+                                      badPwdCount=0,
+                                      badPasswordTime=badPasswordTime,
+                                      logonCount=(logoncount_relation, logonCount),
+                                      lastLogon=(lastlogon_relation, lastLogon),
+                                      lastLogonTimestamp=lastLogonTimestamp,
+                                      userAccountControl=dsdb.UF_NORMAL_ACCOUNT,
+                                      msDSUserAccountControlComputed=0,
+                                      msg='Test NTLM login with old password succeeds')
+
+        # 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=dsdb.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 7a99fe9..61af03b 100755
--- a/source4/selftest/tests.py
+++ b/source4/selftest/tests.py
@@ -807,6 +807,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"),
@@ -853,6 +864,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)
@@ -883,8 +901,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")
@@ -1063,7 +1081,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)
@@ -1086,7 +1105,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



More information about the samba-technical mailing list