netcmd: domain backup restore command
[samba.git] / python / samba / netcmd / domain_backup.py
1 # domain_backup
2 #
3 # Copyright Andrew Bartlett <abartlet@samba.org>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17 #
18 import datetime
19 import os
20 import sys
21 import tarfile
22 import logging
23 import shutil
24 import tempfile
25 import samba
26 import samba.getopt as options
27 from samba.samdb import SamDB
28 import ldb
29 from samba import smb
30 from samba.ntacls import backup_online, backup_restore
31 from samba.auth import system_session
32 from samba.join import DCJoinContext, join_clone
33 from samba.dcerpc.security import dom_sid
34 from samba.netcmd import Option, CommandError
35 from samba.dcerpc import misc
36 from samba import Ldb
37 from fsmo import cmd_fsmo_seize
38 from samba.provision import make_smbconf
39 from samba.upgradehelpers import update_krbtgt_account_password
40 from samba.remove_dc import remove_dc
41 from samba.provision import secretsdb_self_join
42
43
44
45 # work out a SID (based on a free RID) to use when the domain gets restored.
46 # This ensures that the restored DC's SID won't clash with any other RIDs
47 # already in use in the domain
48 def get_sid_for_restore(samdb):
49     # Find the DN of the RID set of the server
50     res = samdb.search(base=ldb.Dn(samdb, samdb.get_serverName()),
51                        scope=ldb.SCOPE_BASE, attrs=["serverReference"])
52     server_ref_dn = ldb.Dn(samdb, res[0]['serverReference'][0])
53     res = samdb.search(base=server_ref_dn,
54                        scope=ldb.SCOPE_BASE,
55                        attrs=['rIDSetReferences'])
56     rid_set_dn = ldb.Dn(samdb, res[0]['rIDSetReferences'][0])
57
58     # Get the alloc pools and next RID of the RID set
59     res = samdb.search(base=rid_set_dn,
60                        scope=ldb.SCOPE_SUBTREE,
61                        expression="(rIDNextRID=*)",
62                        attrs=['rIDAllocationPool',
63                               'rIDPreviousAllocationPool',
64                               'rIDNextRID'])
65
66     # Decode the bounds of the RID allocation pools
67     rid = int(res[0].get('rIDNextRID')[0])
68
69     def split_val(num):
70         high = (0xFFFFFFFF00000000 & int(num)) >> 32
71         low = 0x00000000FFFFFFFF & int(num)
72         return low, high
73     pool_l, pool_h = split_val(res[0].get('rIDPreviousAllocationPool')[0])
74     npool_l, npool_h = split_val(res[0].get('rIDAllocationPool')[0])
75
76     # Calculate next RID based on pool bounds
77     if rid == npool_h:
78         raise CommandError('Out of RIDs, finished AllocPool')
79     if rid == pool_h:
80         if pool_h == npool_h:
81             raise CommandError('Out of RIDs, finished PrevAllocPool.')
82         rid = npool_l
83     else:
84         rid += 1
85
86     # Construct full SID
87     sid = dom_sid(samdb.get_domain_sid())
88     return str(sid) + '-' + str(rid)
89
90
91 def get_timestamp():
92     return datetime.datetime.now().isoformat().replace(':', '-')
93
94
95 def backup_filepath(targetdir, name, time_str):
96     filename = 'samba-backup-{}-{}.tar.bz2'.format(name, time_str)
97     return os.path.join(targetdir, filename)
98
99
100 def create_backup_tar(logger, tmpdir, backup_filepath):
101     # Adds everything in the tmpdir into a new tar file
102     logger.info("Creating backup file %s..." % backup_filepath)
103     tf = tarfile.open(backup_filepath, 'w:bz2')
104     tf.add(tmpdir, arcname='./')
105     tf.close()
106
107
108 # Add a backup-specific marker to the DB with info that we'll use during
109 # the restore process
110 def add_backup_marker(samdb, marker, value):
111     m = ldb.Message()
112     m.dn = ldb.Dn(samdb, "@SAMBA_DSDB")
113     m[marker] = ldb.MessageElement(value, ldb.FLAG_MOD_ADD, marker)
114     samdb.modify(m)
115
116
117 def check_online_backup_args(logger, credopts, server, targetdir):
118     # Make sure we have all the required args.
119     u_p = {'user': credopts.creds.get_username(),
120            'pass': credopts.creds.get_password()}
121     if None in u_p.values():
122         raise CommandError("Creds required.")
123     if server is None:
124         raise CommandError('Server required')
125     if targetdir is None:
126         raise CommandError('Target directory required')
127
128     if not os.path.exists(targetdir):
129         logger.info('Creating targetdir %s...' % targetdir)
130         os.makedirs(targetdir)
131
132
133 class cmd_domain_backup_online(samba.netcmd.Command):
134     '''Copy a running DC's current DB into a backup tar file.
135
136     Takes a backup copy of the current domain from a running DC. If the domain
137     were to undergo a catastrophic failure, then the backup file can be used to
138     recover the domain. The backup created is similar to the DB that a new DC
139     would receive when it joins the domain.
140
141     Note that:
142     - it's recommended to run 'samba-tool dbcheck' before taking a backup-file
143       and fix any errors it reports.
144     - all the domain's secrets are included in the backup file.
145     - although the DB contents can be untarred and examined manually, you need
146       to run 'samba-tool domain backup restore' before you can start a Samba DC
147       from the backup file.'''
148
149     synopsis = "%prog --server=<DC-to-backup> --targetdir=<output-dir>"
150     takes_optiongroups = {
151         "sambaopts": options.SambaOptions,
152         "credopts": options.CredentialsOptions,
153     }
154
155     takes_options = [
156         Option("--server", help="The DC to backup", type=str),
157         Option("--targetdir", type=str,
158                help="Directory to write the backup file to"),
159        ]
160
161     def run(self, sambaopts=None, credopts=None, server=None, targetdir=None):
162         logger = self.get_logger()
163         logger.setLevel(logging.DEBUG)
164
165         # Make sure we have all the required args.
166         check_online_backup_args(logger, credopts, server, targetdir)
167
168         lp = sambaopts.get_loadparm()
169         creds = credopts.get_credentials(lp)
170
171         if not os.path.exists(targetdir):
172             logger.info('Creating targetdir %s...' % targetdir)
173             os.makedirs(targetdir)
174
175         tmpdir = tempfile.mkdtemp(dir=targetdir)
176
177         # Run a clone join on the remote
178         ctx = join_clone(logger=logger, creds=creds, lp=lp,
179                          include_secrets=True, dns_backend='SAMBA_INTERNAL',
180                          server=server, targetdir=tmpdir)
181
182         # get the paths used for the clone, then drop the old samdb connection
183         paths = ctx.paths
184         del ctx
185
186         # Get a free RID to use as the new DC's SID (when it gets restored)
187         remote_sam = SamDB(url='ldap://' + server, credentials=creds,
188                            session_info=system_session(), lp=lp)
189         new_sid = get_sid_for_restore(remote_sam)
190         realm = remote_sam.domain_dns_name()
191
192         # Grab the remote DC's sysvol files and bundle them into a tar file
193         sysvol_tar = os.path.join(tmpdir, 'sysvol.tar.gz')
194         smb_conn = smb.SMB(server, "sysvol", lp=lp, creds=creds)
195         backup_online(smb_conn, sysvol_tar, remote_sam.get_domain_sid())
196
197         # remove the default sysvol files created by the clone (we want to
198         # make sure we restore the sysvol.tar.gz files instead)
199         shutil.rmtree(paths.sysvol)
200
201         # Edit the downloaded sam.ldb to mark it as a backup
202         samdb = SamDB(url=paths.samdb, session_info=system_session(), lp=lp)
203         time_str = get_timestamp()
204         add_backup_marker(samdb, "backupDate", time_str)
205         add_backup_marker(samdb, "sidForRestore", new_sid)
206
207         # Add everything in the tmpdir to the backup tar file
208         backup_file = backup_filepath(targetdir, realm, time_str)
209         create_backup_tar(logger, tmpdir, backup_file)
210
211         shutil.rmtree(tmpdir)
212
213
214 class cmd_domain_backup_restore(cmd_fsmo_seize):
215     '''Restore the domain's DB from a backup-file.
216
217     This restores a previously backed up copy of the domain's DB on a new DC.
218
219     Note that the restored DB will not contain the original DC that the backup
220     was taken from (or any other DCs in the original domain). Only the new DC
221     (specified by --newservername) will be present in the restored DB.
222
223     Samba can then be started against the restored DB. Any existing DCs for the
224     domain should be shutdown before the new DC is started. Other DCs can then
225     be joined to the new DC to recover the network.
226
227     Note that this command should be run as the root user - it will fail
228     otherwise.'''
229
230     synopsis = ("%prog --backup-file=<tar-file> --targetdir=<output-dir> "
231                 "--newservername=<DC-name>")
232     takes_options = [
233         Option("--backup-file", help="Path to backup file", type=str),
234         Option("--targetdir", help="Path to write to", type=str),
235         Option("--newservername", help="Name for new server", type=str),
236     ]
237
238     takes_optiongroups = {
239         "sambaopts": options.SambaOptions,
240         "credopts": options.CredentialsOptions,
241     }
242
243     def run(self, sambaopts=None, credopts=None, backup_file=None,
244             targetdir=None, newservername=None):
245         if not (backup_file and os.path.exists(backup_file)):
246             raise CommandError('Backup file not found.')
247         if targetdir is None:
248             raise CommandError('Please specify a target directory')
249         if os.path.exists(targetdir) and os.listdir(targetdir):
250             raise CommandError('Target directory is not empty')
251         if not newservername:
252             raise CommandError('Server name required')
253
254         logger = logging.getLogger()
255         logger.setLevel(logging.DEBUG)
256         logger.addHandler(logging.StreamHandler(sys.stdout))
257
258         # ldapcmp prefers the server's netBIOS name in upper-case
259         newservername = newservername.upper()
260
261         # extract the backup .tar to a temp directory
262         targetdir = os.path.abspath(targetdir)
263         tf = tarfile.open(backup_file)
264         tf.extractall(targetdir)
265         tf.close()
266
267         # use the smb.conf that got backed up, by default (save what was
268         # actually backed up, before we mess with it)
269         smbconf = os.path.join(targetdir, 'etc', 'smb.conf')
270         shutil.copyfile(smbconf, smbconf + ".orig")
271
272         # if a smb.conf was specified on the cmd line, then use that instead
273         cli_smbconf = sambaopts.get_loadparm_path()
274         if cli_smbconf:
275             logger.info("Using %s as restored domain's smb.conf" % cli_smbconf)
276             shutil.copyfile(cli_smbconf, smbconf)
277
278         lp = samba.param.LoadParm()
279         lp.load(smbconf)
280
281         # open a DB connection to the restored DB
282         private_dir = os.path.join(targetdir, 'private')
283         samdb_path = os.path.join(private_dir, 'sam.ldb')
284         samdb = SamDB(url=samdb_path, session_info=system_session(), lp=lp)
285
286         # Create account using the join_add_objects function in the join object
287         # We need namingContexts, account control flags, and the sid saved by
288         # the backup process.
289         res = samdb.search(base="", scope=ldb.SCOPE_BASE,
290                            attrs=['namingContexts'])
291         ncs = [str(r) for r in res[0].get('namingContexts')]
292
293         creds = credopts.get_credentials(lp)
294         ctx = DCJoinContext(logger, creds=creds, lp=lp,
295                             forced_local_samdb=samdb,
296                             netbios_name=newservername)
297         ctx.nc_list = ncs
298         ctx.full_nc_list = ncs
299         ctx.userAccountControl = (samba.dsdb.UF_SERVER_TRUST_ACCOUNT |
300                                   samba.dsdb.UF_TRUSTED_FOR_DELEGATION)
301
302         # rewrite the smb.conf to make sure it uses the new targetdir settings.
303         # (This doesn't update all filepaths in a customized config, but it
304         # corrects the same paths that get set by a new provision)
305         logger.info('Updating basic smb.conf settings...')
306         make_smbconf(smbconf, newservername, ctx.domain_name,
307                      ctx.realm, targetdir, lp=lp,
308                      serverrole="active directory domain controller")
309
310         # Get the SID saved by the backup process and create account
311         res = samdb.search(base=ldb.Dn(samdb, "@SAMBA_DSDB"),
312                            scope=ldb.SCOPE_BASE,
313                            attrs=['sidForRestore'])
314         sid = res[0].get('sidForRestore')[0]
315         logger.info('Creating account with SID: ' + str(sid))
316         ctx.join_add_objects(specified_sid=dom_sid(sid))
317
318         m = ldb.Message()
319         m.dn = ldb.Dn(samdb, '@ROOTDSE')
320         ntds_guid = str(ctx.ntds_guid)
321         m["dsServiceName"] = ldb.MessageElement("<GUID=%s>" % ntds_guid,
322                                                 ldb.FLAG_MOD_REPLACE,
323                                                 "dsServiceName")
324         samdb.modify(m)
325
326         secrets_path = os.path.join(private_dir, 'secrets.ldb')
327         secrets_ldb = Ldb(secrets_path, session_info=system_session(), lp=lp)
328         secretsdb_self_join(secrets_ldb, domain=ctx.domain_name,
329                             realm=ctx.realm, dnsdomain=ctx.dnsdomain,
330                             netbiosname=ctx.myname, domainsid=ctx.domsid,
331                             machinepass=ctx.acct_pass,
332                             key_version_number=ctx.key_version_number,
333                             secure_channel_type=misc.SEC_CHAN_BDC)
334
335         # Seize DNS roles
336         domain_dn = samdb.domain_dn()
337         forest_dn = samba.dn_from_dns_name(samdb.forest_dns_name())
338         domaindns_dn = ("CN=Infrastructure,DC=DomainDnsZones,", domain_dn)
339         forestdns_dn = ("CN=Infrastructure,DC=ForestDnsZones,", forest_dn)
340         for dn_prefix, dns_dn in [forestdns_dn, domaindns_dn]:
341             if dns_dn not in ncs:
342                 continue
343             full_dn = dn_prefix + dns_dn
344             m = ldb.Message()
345             m.dn = ldb.Dn(samdb, full_dn)
346             m["fSMORoleOwner"] = ldb.MessageElement(samdb.get_dsServiceName(),
347                                                     ldb.FLAG_MOD_REPLACE,
348                                                     "fSMORoleOwner")
349             samdb.modify(m)
350
351         # Seize other roles
352         for role in ['rid', 'pdc', 'naming', 'infrastructure', 'schema']:
353             self.seize_role(role, samdb, force=True)
354
355         # Get all DCs and remove them (this ensures these DCs cannot
356         # replicate because they will not have a password)
357         search_expr = "(&(objectClass=Server)(serverReference=*))"
358         res = samdb.search(samdb.get_config_basedn(), scope=ldb.SCOPE_SUBTREE,
359                            expression=search_expr)
360         for m in res:
361             cn = m.get('cn')[0]
362             if cn != newservername:
363                 remove_dc(samdb, logger, cn)
364
365         # Remove the repsFrom and repsTo from each NC to ensure we do
366         # not try (and fail) to talk to the old DCs
367         for nc in ncs:
368             msg = ldb.Message()
369             msg.dn = ldb.Dn(samdb, nc)
370
371             msg["repsFrom"] = ldb.MessageElement([],
372                                                  ldb.FLAG_MOD_REPLACE,
373                                                  "repsFrom")
374             msg["repsTo"] = ldb.MessageElement([],
375                                                  ldb.FLAG_MOD_REPLACE,
376                                                  "repsTo")
377             samdb.modify(msg)
378
379         # Update the krbtgt passwords twice, ensuring no tickets from
380         # the old domain are valid
381         update_krbtgt_account_password(samdb)
382         update_krbtgt_account_password(samdb)
383
384         # restore the sysvol directory from the backup tar file, including the
385         # original NTACLs. Note that the backup_restore() will fail if not root
386         sysvol_tar = os.path.join(targetdir, 'sysvol.tar.gz')
387         dest_sysvol_dir = lp.get('path', 'sysvol')
388         if not os.path.exists(dest_sysvol_dir):
389             os.makedirs(dest_sysvol_dir)
390         backup_restore(sysvol_tar, dest_sysvol_dir, samdb, smbconf)
391         os.remove(sysvol_tar)
392
393         # Remove DB markers added by the backup process
394         m = ldb.Message()
395         m.dn = ldb.Dn(samdb, "@SAMBA_DSDB")
396         m["backupDate"] = ldb.MessageElement([], ldb.FLAG_MOD_DELETE,
397                                              "backupDate")
398         m["sidForRestore"] = ldb.MessageElement([], ldb.FLAG_MOD_DELETE,
399                                                 "sidForRestore")
400         samdb.modify(m)
401
402         logger.info("Backup file successfully restored to %s" % targetdir)
403         logger.info("Please check the smb.conf settings are correct before "
404                     "starting samba.")
405
406
407 class cmd_domain_backup(samba.netcmd.SuperCommand):
408     '''Create or restore a backup of the domain.'''
409     subcommands = {'online': cmd_domain_backup_online(),
410                    'restore': cmd_domain_backup_restore()}