[PATCH] Domain backup and restore samba-tool commands

Tim Beale timbeale at catalyst.net.nz
Fri Jun 29 04:35:07 UTC 2018


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 e5676fb21b2d1b15626498caa74697b56fb6242f 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/5] 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 | 214 +++++++++++++++++++++++++++++++++++
 4 files changed, 227 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 41a2051..a97924d 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..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..114e993
--- /dev/null
+++ b/python/samba/netcmd/domain_backup.py
@@ -0,0 +1,214 @@
+# 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
+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.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, session_info=system_session(),
+                           credentials=creds, 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 b616922a6afef40946bc794a90a98294d5f6e62c 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/5] 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                 |  35 ++++---
 python/samba/netcmd/domain_backup.py | 187 ++++++++++++++++++++++++++++++++++-
 3 files changed, 211 insertions(+), 16 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 a97924d..22854ae 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,15 @@ 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)
+                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 114e993..2a79c51 100644
--- a/python/samba/netcmd/domain_backup.py
+++ b/python/samba/netcmd/domain_backup.py
@@ -23,12 +23,18 @@ 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
 
 tmpdir = 'backup_temp_dir'
 def rm_tmp():
@@ -209,6 +215,181 @@ 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.
+
+    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, 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:
+                remove_dc(samdb, logger, cn)
+
+        # Update tgt and DC passwords twice
+        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("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 3acb77df006169e90c91655217824f7c4dff5784 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/5] 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>
---
 python/samba/tests/domain_backup.py | 257 ++++++++++++++++++++++++++++++++++++
 source4/selftest/tests.py           |   3 +
 2 files changed, 260 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..42aa7f5
--- /dev/null
+++ b/python/samba/tests/domain_backup.py
@@ -0,0 +1,257 @@
+# 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, shutil
+from samba.tests.samba_tool.base import SambaToolCmdTest
+from samba.tests import TestCaseInTempDir, env_loadparm
+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(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(SambaToolCmdTest, 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()
+        self.basedn = str(self.ldb.get_default_basedn())
+
+    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 = self.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):
+        """Asserts that only the expected server is present in the restored DB"""
+        res = samdb.search(samdb.get_config_basedn(),
+                           scope=ldb.SCOPE_SUBTREE,
+                           expression="(&(objectClass=Server)(serverReference=*))")
+        self.assertTrue(len(res) == 1)
+        self.assertTrue(expected_server in str(res[0].dn))
+
+    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_online_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_online_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"""
+        smbconf = os.path.join(self.tempdir, "smb.conf")
+        f = open(smbconf, 'w')
+        try:
+            f.write("[globals]\n")
+            for key, val in settings.items():
+                f.write("\t%s = %s\n" % (key, val))
+        finally:
+            f.close()
+        self.addCleanup(os.remove, smbconf)
+        return smbconf
+
+    def test_online_restore_with_conf(self):
+        """Checks smb.conf values passed to the restore are honoured appropriately"""
+        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 = { 'log level' : '3',
+                            'prefork children' : '20' }
+        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 values in the restored smb.conf are correct"""
+        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 the restored DB has the expected partitions/DC/FSMO roles
+        self.assert_partitions_present(samdb)
+        self.assert_dcs_present(samdb, self.new_server)
+        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 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]
+        (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(self.tempdir)
+                     if fn.startswith("samba-backup-") and
+                        fn.endswith(".tar.bz2")]
+        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=BACKUPSERV"]
+        if extra_args:
+            args += extra_args
+        (result, out, err) = self.runsubcmd(*args)
+        self.assertCmdSuccess(result, out, err,
+                              "Ensuring domain backup restore ran successfully")
+
+        # sanity-check the restore doesn't modify the original DC as a side-effect
+        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..af06828 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_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 6bd61ceac170a78fb20fafd7471c71a424877b98 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/5] 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 2a79c51..6270ffd 100644
--- a/python/samba/netcmd/domain_backup.py
+++ b/python/samba/netcmd/domain_backup.py
@@ -249,7 +249,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 56a3d292c523ff404f18a66eb1fcc8c10eefe3fc 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/5] 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 af06828..ee1d81f 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



More information about the samba-technical mailing list