7bd118eabb340aea15ad47915f94ced5806cd946
[metze/samba/wip.git] / python / samba / netcmd / ldapcmp.py
1 # Unix SMB/CIFS implementation.
2 # A command to compare differences of objects and attributes between
3 # two LDAP servers both running at the same time. It generally compares
4 # one of the three pratitions DOMAIN, CONFIGURATION or SCHEMA. Users
5 # that have to be provided sheould be able to read objects in any of the
6 # above partitions.
7
8 # Copyright (C) Zahari Zahariev <zahari.zahariev@postpath.com> 2009, 2010
9 #
10 # This program is free software; you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation; either version 3 of the License, or
13 # (at your option) any later version.
14 #
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 # GNU General Public License for more details.
19 #
20 # You should have received a copy of the GNU General Public License
21 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
22 #
23
24 import os
25 import re
26 import sys
27
28 import samba
29 import samba.getopt as options
30 from samba import Ldb
31 from samba.ndr import ndr_unpack
32 from samba.dcerpc import security
33 from ldb import SCOPE_SUBTREE, SCOPE_ONELEVEL, SCOPE_BASE, ERR_NO_SUCH_OBJECT, LdbError
34 from samba.netcmd import (
35     Command,
36     CommandError,
37     Option,
38     )
39
40 global summary
41 summary = {}
42
43 class LDAPBase(object):
44
45     def __init__(self, host, creds, lp,
46                  two=False, quiet=False, descriptor=False, sort_aces=False, verbose=False,
47                  view="section", base="", scope="SUB",
48                  outf=sys.stdout, errf=sys.stderr, skip_missing_dn=True):
49         ldb_options = []
50         samdb_url = host
51         if not "://" in host:
52             if os.path.isfile(host):
53                 samdb_url = "tdb://%s" % host
54             else:
55                 samdb_url = "ldap://%s" % host
56         # use 'paged_search' module when connecting remotely
57         if samdb_url.lower().startswith("ldap://"):
58             ldb_options = ["modules:paged_searches"]
59         self.outf = outf
60         self.errf = errf
61         self.ldb = Ldb(url=samdb_url,
62                        credentials=creds,
63                        lp=lp,
64                        options=ldb_options)
65         self.search_base = base
66         self.search_scope = scope
67         self.two_domains = two
68         self.quiet = quiet
69         self.descriptor = descriptor
70         self.sort_aces = sort_aces
71         self.view = view
72         self.verbose = verbose
73         self.host = host
74         self.skip_missing_dn = skip_missing_dn
75         self.base_dn = str(self.ldb.get_default_basedn())
76         self.root_dn = str(self.ldb.get_root_basedn())
77         self.config_dn = str(self.ldb.get_config_basedn())
78         self.schema_dn = str(self.ldb.get_schema_basedn())
79         self.domain_netbios = self.find_netbios()
80         self.server_names = self.find_servers()
81         self.domain_name = re.sub("[Dd][Cc]=", "", self.base_dn).replace(",", ".")
82         self.domain_sid = self.find_domain_sid()
83         self.get_sid_map()
84         #
85         # Log some domain controller specific place-holers that are being used
86         # when compare content of two DCs. Uncomment for DEBUG purposes.
87         if self.two_domains and not self.quiet:
88             self.outf.write("\n* Place-holders for %s:\n" % self.host)
89             self.outf.write(4*" " + "${DOMAIN_DN}      => %s\n" %
90                 self.base_dn)
91             self.outf.write(4*" " + "${DOMAIN_NETBIOS} => %s\n" %
92                 self.domain_netbios)
93             self.outf.write(4*" " + "${SERVER_NAME}     => %s\n" %
94                 self.server_names)
95             self.outf.write(4*" " + "${DOMAIN_NAME}    => %s\n" %
96                 self.domain_name)
97
98     def find_domain_sid(self):
99         res = self.ldb.search(base=self.base_dn, expression="(objectClass=*)", scope=SCOPE_BASE)
100         return ndr_unpack(security.dom_sid,res[0]["objectSid"][0])
101
102     def find_servers(self):
103         """
104         """
105         res = self.ldb.search(base="OU=Domain Controllers,%s" % self.base_dn,
106                 scope=SCOPE_SUBTREE, expression="(objectClass=computer)", attrs=["cn"])
107         assert len(res) > 0
108         srv = []
109         for x in res:
110             srv.append(x["cn"][0])
111         return srv
112
113     def find_netbios(self):
114         res = self.ldb.search(base="CN=Partitions,%s" % self.config_dn,
115                 scope=SCOPE_SUBTREE, attrs=["nETBIOSName"])
116         assert len(res) > 0
117         for x in res:
118             if "nETBIOSName" in x.keys():
119                 return x["nETBIOSName"][0]
120
121     def object_exists(self, object_dn):
122         res = None
123         try:
124             res = self.ldb.search(base=object_dn, scope=SCOPE_BASE)
125         except LdbError, (enum, estr):
126             if enum == ERR_NO_SUCH_OBJECT:
127                 return False
128             raise
129         return len(res) == 1
130
131     def delete_force(self, object_dn):
132         try:
133             self.ldb.delete(object_dn)
134         except Ldb.LdbError, e:
135             assert "No such object" in str(e)
136
137     def get_attribute_name(self, key):
138         """ Returns the real attribute name
139             It resolved ranged results e.g. member;range=0-1499
140         """
141
142         r = re.compile("^([^;]+);range=(\d+)-(\d+|\*)$")
143
144         m = r.match(key)
145         if m is None:
146             return key
147
148         return m.group(1)
149
150     def get_attribute_values(self, object_dn, key, vals):
151         """ Returns list with all attribute values
152             It resolved ranged results e.g. member;range=0-1499
153         """
154
155         r = re.compile("^([^;]+);range=(\d+)-(\d+|\*)$")
156
157         m = r.match(key)
158         if m is None:
159             # no range, just return the values
160             return vals
161
162         attr = m.group(1)
163         hi = int(m.group(3))
164
165         # get additional values in a loop
166         # until we get a response with '*' at the end
167         while True:
168
169             n = "%s;range=%d-*" % (attr, hi + 1)
170             res = self.ldb.search(base=object_dn, scope=SCOPE_BASE, attrs=[n])
171             assert len(res) == 1
172             res = dict(res[0])
173             del res["dn"]
174
175             fm = None
176             fvals = None
177
178             for key in res.keys():
179                 m = r.match(key)
180
181                 if m is None:
182                     continue
183
184                 if m.group(1) != attr:
185                     continue
186
187                 fm = m
188                 fvals = list(res[key])
189                 break
190
191             if fm is None:
192                 break
193
194             vals.extend(fvals)
195             if fm.group(3) == "*":
196                 # if we got "*" we're done
197                 break
198
199             assert int(fm.group(2)) == hi + 1
200             hi = int(fm.group(3))
201
202         return vals
203
204     def get_attributes(self, object_dn):
205         """ Returns dict with all default visible attributes
206         """
207         res = self.ldb.search(base=object_dn, scope=SCOPE_BASE, attrs=["*"])
208         assert len(res) == 1
209         res = dict(res[0])
210         # 'Dn' element is not iterable and we have it as 'distinguishedName'
211         del res["dn"]
212         for key in res.keys():
213             vals = list(res[key])
214             del res[key]
215             name = self.get_attribute_name(key)
216             res[name] = self.get_attribute_values(object_dn, key, vals)
217
218         return res
219
220     def get_descriptor_sddl(self, object_dn):
221         res = self.ldb.search(base=object_dn, scope=SCOPE_BASE, attrs=["nTSecurityDescriptor"])
222         desc = res[0]["nTSecurityDescriptor"][0]
223         desc = ndr_unpack(security.descriptor, desc)
224         return desc.as_sddl(self.domain_sid)
225
226     def guid_as_string(self, guid_blob):
227         """ Translate binary representation of schemaIDGUID to standard string representation.
228             @gid_blob: binary schemaIDGUID
229         """
230         blob = "%s" % guid_blob
231         stops = [4, 2, 2, 2, 6]
232         index = 0
233         res = ""
234         x = 0
235         while x < len(stops):
236             tmp = ""
237             y = 0
238             while y < stops[x]:
239                 c = hex(ord(blob[index])).replace("0x", "")
240                 c = [None, "0" + c, c][len(c)]
241                 if 2 * index < len(blob):
242                     tmp = c + tmp
243                 else:
244                     tmp += c
245                 index += 1
246                 y += 1
247             res += tmp + " "
248             x += 1
249         assert index == len(blob)
250         return res.strip().replace(" ", "-")
251
252     def get_sid_map(self):
253         """ Build dictionary that maps GUID to 'name' attribute found in Schema or Extended-Rights.
254         """
255         self.sid_map = {}
256         res = self.ldb.search(base=self.base_dn,
257                               expression="(objectSid=*)", scope=SCOPE_SUBTREE, attrs=["objectSid", "sAMAccountName"])
258         for item in res:
259             try:
260                 self.sid_map["%s" % ndr_unpack(security.dom_sid, item["objectSid"][0])] = item["sAMAccountName"][0]
261             except KeyError:
262                 pass
263
264 class Descriptor(object):
265     def __init__(self, connection, dn, outf=sys.stdout, errf=sys.stderr):
266         self.outf = outf
267         self.errf = errf
268         self.con = connection
269         self.dn = dn
270         self.sddl = self.con.get_descriptor_sddl(self.dn)
271         self.dacl_list = self.extract_dacl()
272         if self.con.sort_aces:
273             self.dacl_list.sort()
274
275     def extract_dacl(self):
276         """ Extracts the DACL as a list of ACE string (with the brakets).
277         """
278         try:
279             if "S:" in self.sddl:
280                 res = re.search("D:(.*?)(\(.*?\))S:", self.sddl).group(2)
281             else:
282                 res = re.search("D:(.*?)(\(.*\))", self.sddl).group(2)
283         except AttributeError:
284             return []
285         return re.findall("(\(.*?\))", res)
286
287     def fix_sid(self, ace):
288         res = "%s" % ace
289         sids = re.findall("S-[-0-9]+", res)
290         # If there are not SIDs to replace return the same ACE
291         if len(sids) == 0:
292             return res
293         for sid in sids:
294             try:
295                 name = self.con.sid_map[sid]
296                 res = res.replace(sid, name)
297             except KeyError:
298                 # Do not bother if the SID is not found in baseDN
299                 pass
300         return res
301
302     def diff_1(self, other):
303         res = ""
304         if len(self.dacl_list) != len(other.dacl_list):
305             res += 4*" " + "Difference in ACE count:\n"
306             res += 8*" " + "=> %s\n" % len(self.dacl_list)
307             res += 8*" " + "=> %s\n" % len(other.dacl_list)
308         #
309         i = 0
310         flag = True
311         while True:
312             self_ace = None
313             other_ace = None
314             try:
315                 self_ace = "%s" % self.dacl_list[i]
316             except IndexError:
317                 self_ace = ""
318             #
319             try:
320                 other_ace = "%s" % other.dacl_list[i]
321             except IndexError:
322                 other_ace = ""
323             if len(self_ace) + len(other_ace) == 0:
324                 break
325             self_ace_fixed = "%s" % self.fix_sid(self_ace)
326             other_ace_fixed = "%s" % other.fix_sid(other_ace)
327             if self_ace_fixed != other_ace_fixed:
328                 res += "%60s * %s\n" % ( self_ace_fixed, other_ace_fixed )
329                 flag = False
330             else:
331                 res += "%60s | %s\n" % ( self_ace_fixed, other_ace_fixed )
332             i += 1
333         return (flag, res)
334
335     def diff_2(self, other):
336         res = ""
337         if len(self.dacl_list) != len(other.dacl_list):
338             res += 4*" " + "Difference in ACE count:\n"
339             res += 8*" " + "=> %s\n" % len(self.dacl_list)
340             res += 8*" " + "=> %s\n" % len(other.dacl_list)
341         #
342         common_aces = []
343         self_aces = []
344         other_aces = []
345         self_dacl_list_fixed = []
346         other_dacl_list_fixed = []
347         [self_dacl_list_fixed.append( self.fix_sid(ace) ) for ace in self.dacl_list]
348         [other_dacl_list_fixed.append( other.fix_sid(ace) ) for ace in other.dacl_list]
349         for ace in self_dacl_list_fixed:
350             try:
351                 other_dacl_list_fixed.index(ace)
352             except ValueError:
353                 self_aces.append(ace)
354             else:
355                 common_aces.append(ace)
356         self_aces = sorted(self_aces)
357         if len(self_aces) > 0:
358             res += 4*" " + "ACEs found only in %s:\n" % self.con.host
359             for ace in self_aces:
360                 res += 8*" " + ace + "\n"
361         #
362         for ace in other_dacl_list_fixed:
363             try:
364                 self_dacl_list_fixed.index(ace)
365             except ValueError:
366                 other_aces.append(ace)
367             else:
368                 common_aces.append(ace)
369         other_aces = sorted(other_aces)
370         if len(other_aces) > 0:
371             res += 4*" " + "ACEs found only in %s:\n" % other.con.host
372             for ace in other_aces:
373                 res += 8*" " + ace + "\n"
374         #
375         common_aces = sorted(list(set(common_aces)))
376         if self.con.verbose:
377             res += 4*" " + "ACEs found in both:\n"
378             for ace in common_aces:
379                 res += 8*" " + ace + "\n"
380         return (self_aces == [] and other_aces == [], res)
381
382 class LDAPObject(object):
383     def __init__(self, connection, dn, summary, filter_list,
384                  outf=sys.stdout, errf=sys.stderr):
385         self.outf = outf
386         self.errf = errf
387         self.con = connection
388         self.two_domains = self.con.two_domains
389         self.quiet = self.con.quiet
390         self.verbose = self.con.verbose
391         self.summary = summary
392         self.dn = dn.replace("${DOMAIN_DN}", self.con.base_dn)
393         self.dn = self.dn.replace("CN=${DOMAIN_NETBIOS}", "CN=%s" % self.con.domain_netbios)
394         for x in self.con.server_names:
395             self.dn = self.dn.replace("CN=${SERVER_NAME}", "CN=%s" % x)
396         self.attributes = self.con.get_attributes(self.dn)
397         # Attributes that are considered always to be different e.g based on timestamp etc.
398         #
399         # One domain - two domain controllers
400         self.ignore_attributes =  [
401                 # Default Naming Context
402                 "lastLogon", "lastLogoff", "badPwdCount", "logonCount", "badPasswordTime", "modifiedCount",
403                 "operatingSystemVersion","oEMInformation",
404                 "ridNextRID", "rIDPreviousAllocationPool",
405                 # Configuration Naming Context
406                 "repsFrom", "dSCorePropagationData", "msExchServer1HighestUSN",
407                 "replUpToDateVector", "repsTo", "whenChanged", "uSNChanged", "uSNCreated",
408                 # Schema Naming Context
409                 "prefixMap"]
410         if filter_list:
411             self.ignore_attributes += filter_list
412
413         self.dn_attributes = []
414         self.domain_attributes = []
415         self.servername_attributes = []
416         self.netbios_attributes = []
417         self.other_attributes = []
418         # Two domains - two domain controllers
419
420         if self.two_domains:
421             self.ignore_attributes +=  [
422                 "objectCategory", "objectGUID", "objectSid", "whenCreated", "pwdLastSet", "uSNCreated", "creationTime",
423                 "modifiedCount", "priorSetTime", "rIDManagerReference", "gPLink", "ipsecNFAReference",
424                 "fRSPrimaryMember", "fSMORoleOwner", "masteredBy", "ipsecOwnersReference", "wellKnownObjects",
425                 "badPwdCount", "ipsecISAKMPReference", "ipsecFilterReference", "msDs-masteredBy", "lastSetTime",
426                 "ipsecNegotiationPolicyReference", "subRefs", "gPCFileSysPath", "accountExpires", "invocationId",
427                 # After Exchange preps
428                 "targetAddress", "msExchMailboxGuid", "siteFolderGUID"]
429             #
430             # Attributes that contain the unique DN tail part e.g. 'DC=samba,DC=org'
431             self.dn_attributes = [
432                 "distinguishedName", "defaultObjectCategory", "member", "memberOf", "siteList", "nCName",
433                 "homeMDB", "homeMTA", "interSiteTopologyGenerator", "serverReference",
434                 "msDS-HasInstantiatedNCs", "hasMasterNCs", "msDS-hasMasterNCs", "msDS-HasDomainNCs", "dMDLocation",
435                 "msDS-IsDomainFor", "rIDSetReferences", "serverReferenceBL",
436                 # After Exchange preps
437                 "msExchHomeRoutingGroup", "msExchResponsibleMTAServer", "siteFolderServer", "msExchRoutingMasterDN",
438                 "msExchRoutingGroupMembersBL", "homeMDBBL", "msExchHomePublicMDB", "msExchOwningServer", "templateRoots",
439                 "addressBookRoots", "msExchPolicyRoots", "globalAddressList", "msExchOwningPFTree",
440                 "msExchResponsibleMTAServerBL", "msExchOwningPFTreeBL",]
441             self.dn_attributes = [x.upper() for x in self.dn_attributes]
442             #
443             # Attributes that contain the Domain name e.g. 'samba.org'
444             self.domain_attributes = [
445                 "proxyAddresses", "mail", "userPrincipalName", "msExchSmtpFullyQualifiedDomainName",
446                 "dnsHostName", "networkAddress", "dnsRoot", "servicePrincipalName",]
447             self.domain_attributes = [x.upper() for x in self.domain_attributes]
448             #
449             # May contain DOMAIN_NETBIOS and SERVER_NAME
450             self.servername_attributes = [ "distinguishedName", "name", "CN", "sAMAccountName", "dNSHostName",
451                 "servicePrincipalName", "rIDSetReferences", "serverReference", "serverReferenceBL",
452                 "msDS-IsDomainFor", "interSiteTopologyGenerator",]
453             self.servername_attributes = [x.upper() for x in self.servername_attributes]
454             #
455             self.netbios_attributes = [ "servicePrincipalName", "CN", "distinguishedName", "nETBIOSName", "name",]
456             self.netbios_attributes = [x.upper() for x in self.netbios_attributes]
457             #
458             self.other_attributes = [ "name", "DC",]
459             self.other_attributes = [x.upper() for x in self.other_attributes]
460         #
461         self.ignore_attributes = [x.upper() for x in self.ignore_attributes]
462
463     def log(self, msg):
464         """
465         Log on the screen if there is no --quiet oprion set
466         """
467         if not self.quiet:
468             self.outf.write(msg+"\n")
469
470     def fix_dn(self, s):
471         res = "%s" % s
472         if not self.two_domains:
473             return res
474         if res.upper().endswith(self.con.base_dn.upper()):
475             res = res[:len(res)-len(self.con.base_dn)] + "${DOMAIN_DN}"
476         return res
477
478     def fix_domain_name(self, s):
479         res = "%s" % s
480         if not self.two_domains:
481             return res
482         res = res.replace(self.con.domain_name.lower(), self.con.domain_name.upper())
483         res = res.replace(self.con.domain_name.upper(), "${DOMAIN_NAME}")
484         return res
485
486     def fix_domain_netbios(self, s):
487         res = "%s" % s
488         if not self.two_domains:
489             return res
490         res = res.replace(self.con.domain_netbios.lower(), self.con.domain_netbios.upper())
491         res = res.replace(self.con.domain_netbios.upper(), "${DOMAIN_NETBIOS}")
492         return res
493
494     def fix_server_name(self, s):
495         res = "%s" % s
496         if not self.two_domains or len(self.con.server_names) > 1:
497             return res
498         for x in self.con.server_names:
499             res = res.upper().replace(x, "${SERVER_NAME}")
500         return res
501
502     def __eq__(self, other):
503         if self.con.descriptor:
504             return self.cmp_desc(other)
505         return self.cmp_attrs(other)
506
507     def cmp_desc(self, other):
508         d1 = Descriptor(self.con, self.dn, outf=self.outf, errf=self.errf)
509         d2 = Descriptor(other.con, other.dn, outf=self.outf, errf=self.errf)
510         if self.con.view == "section":
511             res = d1.diff_2(d2)
512         elif self.con.view == "collision":
513             res = d1.diff_1(d2)
514         else:
515             raise Exception("Unknown --view option value.")
516         #
517         self.screen_output = res[1][:-1]
518         other.screen_output = res[1][:-1]
519         #
520         return res[0]
521
522     def cmp_attrs(self, other):
523         res = ""
524         self.unique_attrs = []
525         self.df_value_attrs = []
526         other.unique_attrs = []
527         if self.attributes.keys() != other.attributes.keys():
528             #
529             title = 4*" " + "Attributes found only in %s:" % self.con.host
530             for x in self.attributes.keys():
531                 if not x in other.attributes.keys() and \
532                 not x.upper() in [q.upper() for q in other.ignore_attributes]:
533                     if title:
534                         res += title + "\n"
535                         title = None
536                     res += 8*" " + x + "\n"
537                     self.unique_attrs.append(x)
538             #
539             title = 4*" " + "Attributes found only in %s:" % other.con.host
540             for x in other.attributes.keys():
541                 if not x in self.attributes.keys() and \
542                 not x.upper() in [q.upper() for q in self.ignore_attributes]:
543                     if title:
544                         res += title + "\n"
545                         title = None
546                     res += 8*" " + x + "\n"
547                     other.unique_attrs.append(x)
548         #
549         missing_attrs = [x.upper() for x in self.unique_attrs]
550         missing_attrs += [x.upper() for x in other.unique_attrs]
551         title = 4*" " + "Difference in attribute values:"
552         for x in self.attributes.keys():
553             if x.upper() in self.ignore_attributes or x.upper() in missing_attrs:
554                 continue
555             if isinstance(self.attributes[x], list) and isinstance(other.attributes[x], list):
556                 self.attributes[x] = sorted(self.attributes[x])
557                 other.attributes[x] = sorted(other.attributes[x])
558             if self.attributes[x] != other.attributes[x]:
559                 p = None
560                 q = None
561                 m = None
562                 n = None
563                 # First check if the difference can be fixed but shunting the first part
564                 # of the DomainHostName e.g. 'mysamba4.test.local' => 'mysamba4'
565                 if x.upper() in self.other_attributes:
566                     p = [self.con.domain_name.split(".")[0] == j for j in self.attributes[x]]
567                     q = [other.con.domain_name.split(".")[0] == j for j in other.attributes[x]]
568                     if p == q:
569                         continue
570                 # Attribute values that are list that contain DN based values that may differ
571                 elif x.upper() in self.dn_attributes:
572                     m = p
573                     n = q
574                     if not p and not q:
575                         m = self.attributes[x]
576                         n = other.attributes[x]
577                     p = [self.fix_dn(j) for j in m]
578                     q = [other.fix_dn(j) for j in n]
579                     if p == q:
580                         continue
581                 # Attributes that contain the Domain name in them
582                 if x.upper() in self.domain_attributes:
583                     m = p
584                     n = q
585                     if not p and not q:
586                         m = self.attributes[x]
587                         n = other.attributes[x]
588                     p = [self.fix_domain_name(j) for j in m]
589                     q = [other.fix_domain_name(j) for j in n]
590                     if p == q:
591                         continue
592                 #
593                 if x.upper() in self.servername_attributes:
594                     # Attributes with SERVER_NAME
595                     m = p
596                     n = q
597                     if not p and not q:
598                         m = self.attributes[x]
599                         n = other.attributes[x]
600                     p = [self.fix_server_name(j) for j in m]
601                     q = [other.fix_server_name(j) for j in n]
602                     if p == q:
603                         continue
604                 #
605                 if x.upper() in self.netbios_attributes:
606                     # Attributes with NETBIOS Domain name
607                     m = p
608                     n = q
609                     if not p and not q:
610                         m = self.attributes[x]
611                         n = other.attributes[x]
612                     p = [self.fix_domain_netbios(j) for j in m]
613                     q = [other.fix_domain_netbios(j) for j in n]
614                     if p == q:
615                         continue
616                 #
617                 if title:
618                     res += title + "\n"
619                     title = None
620                 if p and q:
621                     res += 8*" " + x + " => \n%s\n%s" % (p, q) + "\n"
622                 else:
623                     res += 8*" " + x + " => \n%s\n%s" % (self.attributes[x], other.attributes[x]) + "\n"
624                 self.df_value_attrs.append(x)
625         #
626         if self.unique_attrs + other.unique_attrs != []:
627             assert self.unique_attrs != other.unique_attrs
628         self.summary["unique_attrs"] += self.unique_attrs
629         self.summary["df_value_attrs"] += self.df_value_attrs
630         other.summary["unique_attrs"] += other.unique_attrs
631         other.summary["df_value_attrs"] += self.df_value_attrs # they are the same
632         #
633         self.screen_output = res[:-1]
634         other.screen_output = res[:-1]
635         #
636         return res == ""
637
638
639 class LDAPBundel(object):
640
641     def __init__(self, connection, context, dn_list=None, filter_list=None,
642                  outf=sys.stdout, errf=sys.stderr):
643         self.outf = outf
644         self.errf = errf
645         self.con = connection
646         self.two_domains = self.con.two_domains
647         self.quiet = self.con.quiet
648         self.verbose = self.con.verbose
649         self.search_base = self.con.search_base
650         self.search_scope = self.con.search_scope
651         self.skip_missing_dn = self.con.skip_missing_dn
652         self.summary = {}
653         self.summary["unique_attrs"] = []
654         self.summary["df_value_attrs"] = []
655         self.summary["known_ignored_dn"] = []
656         self.summary["abnormal_ignored_dn"] = []
657         self.filter_list = filter_list
658         if dn_list:
659             self.dn_list = dn_list
660         elif context.upper() in ["DOMAIN", "CONFIGURATION", "SCHEMA", "DNSDOMAIN", "DNSFOREST"]:
661             self.context = context.upper()
662             self.dn_list = self.get_dn_list(context)
663         else:
664             raise Exception("Unknown initialization data for LDAPBundel().")
665         counter = 0
666         while counter < len(self.dn_list) and self.two_domains:
667             # Use alias reference
668             tmp = self.dn_list[counter]
669             tmp = tmp[:len(tmp)-len(self.con.base_dn)] + "${DOMAIN_DN}"
670             tmp = tmp.replace("CN=%s" % self.con.domain_netbios, "CN=${DOMAIN_NETBIOS}")
671             if len(self.con.server_names) == 1:
672                 for x in self.con.server_names:
673                     tmp = tmp.replace("CN=%s" % x, "CN=${SERVER_NAME}")
674             self.dn_list[counter] = tmp
675             counter += 1
676         self.dn_list = list(set(self.dn_list))
677         self.dn_list = sorted(self.dn_list)
678         self.size = len(self.dn_list)
679
680     def log(self, msg):
681         """
682         Log on the screen if there is no --quiet oprion set
683         """
684         if not self.quiet:
685             self.outf.write(msg+"\n")
686
687     def update_size(self):
688         self.size = len(self.dn_list)
689         self.dn_list = sorted(self.dn_list)
690
691     def __eq__(self, other):
692         res = True
693         if self.size != other.size:
694             self.log( "\n* DN lists have different size: %s != %s" % (self.size, other.size) )
695             if not self.skip_missing_dn:
696                 res = False
697         #
698         # This is the case where we want to explicitly compare two objects with different DNs.
699         # It does not matter if they are in the same DC, in two DC in one domain or in two
700         # different domains.
701         if self.search_scope != SCOPE_BASE:
702             title= "\n* DNs found only in %s:" % self.con.host
703             for x in self.dn_list:
704                 if not x.upper() in [q.upper() for q in other.dn_list]:
705                     if title and not self.skip_missing_dn:
706                         self.log( title )
707                         title = None
708                         res = False
709                     self.log( 4*" " + x )
710                     self.dn_list[self.dn_list.index(x)] = ""
711             self.dn_list = [x for x in self.dn_list if x]
712             #
713             title= "\n* DNs found only in %s:" % other.con.host
714             for x in other.dn_list:
715                 if not x.upper() in [q.upper() for q in self.dn_list]:
716                     if title and not self.skip_missing_dn:
717                         self.log( title )
718                         title = None
719                         res = False
720                     self.log( 4*" " + x )
721                     other.dn_list[other.dn_list.index(x)] = ""
722             other.dn_list = [x for x in other.dn_list if x]
723             #
724             self.update_size()
725             other.update_size()
726             assert self.size == other.size
727             assert sorted([x.upper() for x in self.dn_list]) == sorted([x.upper() for x in other.dn_list])
728         self.log( "\n* Objects to be compared: %s" % self.size )
729
730         index = 0
731         while index < self.size:
732             skip = False
733             try:
734                 object1 = LDAPObject(connection=self.con,
735                                      dn=self.dn_list[index],
736                                      summary=self.summary,
737                                      filter_list=self.filter_list,
738                                      outf=self.outf, errf=self.errf)
739             except LdbError, (enum, estr):
740                 if enum == ERR_NO_SUCH_OBJECT:
741                     self.log( "\n!!! Object not found: %s" % self.dn_list[index] )
742                     skip = True
743                 raise
744             try:
745                 object2 = LDAPObject(connection=other.con,
746                         dn=other.dn_list[index],
747                         summary=other.summary,
748                         filter_list=self.filter_list,
749                         outf=self.outf, errf=self.errf)
750             except LdbError, (enum, estr):
751                 if enum == ERR_NO_SUCH_OBJECT:
752                     self.log( "\n!!! Object not found: %s" % other.dn_list[index] )
753                     skip = True
754                 raise
755             if skip:
756                 index += 1
757                 continue
758             if object1 == object2:
759                 if self.con.verbose:
760                     self.log( "\nComparing:" )
761                     self.log( "'%s' [%s]" % (object1.dn, object1.con.host) )
762                     self.log( "'%s' [%s]" % (object2.dn, object2.con.host) )
763                     self.log( 4*" " + "OK" )
764             else:
765                 self.log( "\nComparing:" )
766                 self.log( "'%s' [%s]" % (object1.dn, object1.con.host) )
767                 self.log( "'%s' [%s]" % (object2.dn, object2.con.host) )
768                 self.log( object1.screen_output )
769                 self.log( 4*" " + "FAILED" )
770                 res = False
771             self.summary = object1.summary
772             other.summary = object2.summary
773             index += 1
774         #
775         return res
776
777     def get_dn_list(self, context):
778         """ Query LDAP server about the DNs of certain naming self.con.ext Domain (or Default), Configuration, Schema.
779             Parse all DNs and filter those that are 'strange' or abnormal.
780         """
781         if context.upper() == "DOMAIN":
782             search_base = self.con.base_dn
783         elif context.upper() == "CONFIGURATION":
784             search_base = self.con.config_dn
785         elif context.upper() == "SCHEMA":
786             search_base = self.con.schema_dn
787         elif context.upper() == "DNSDOMAIN":
788             search_base = "DC=DomainDnsZones,%s" % self.con.base_dn
789         elif context.upper() == "DNSFOREST":
790             search_base = "DC=ForestDnsZones,%s" % self.con.root_dn
791
792         dn_list = []
793         if not self.search_base:
794             self.search_base = search_base
795         self.search_scope = self.search_scope.upper()
796         if self.search_scope == "SUB":
797             self.search_scope = SCOPE_SUBTREE
798         elif self.search_scope == "BASE":
799             self.search_scope = SCOPE_BASE
800         elif self.search_scope == "ONE":
801             self.search_scope = SCOPE_ONELEVEL
802         else:
803             raise StandardError("Wrong 'scope' given. Choose from: SUB, ONE, BASE")
804         try:
805             res = self.con.ldb.search(base=self.search_base, scope=self.search_scope, attrs=["dn"])
806         except LdbError, (enum, estr):
807             self.outf.write("Failed search of base=%s\n" % self.search_base)
808             raise
809         for x in res:
810            dn_list.append(x["dn"].get_linearized())
811         #
812         global summary
813         #
814         return dn_list
815
816     def print_summary(self):
817         self.summary["unique_attrs"] = list(set(self.summary["unique_attrs"]))
818         self.summary["df_value_attrs"] = list(set(self.summary["df_value_attrs"]))
819         #
820         if self.summary["unique_attrs"]:
821             self.log( "\nAttributes found only in %s:" % self.con.host )
822             self.log( "".join([str("\n" + 4*" " + x) for x in self.summary["unique_attrs"]]) )
823         #
824         if self.summary["df_value_attrs"]:
825             self.log( "\nAttributes with different values:" )
826             self.log( "".join([str("\n" + 4*" " + x) for x in self.summary["df_value_attrs"]]) )
827             self.summary["df_value_attrs"] = []
828
829
830 class cmd_ldapcmp(Command):
831     """Compare two ldap databases."""
832     synopsis = "%prog <URL1> <URL2> (domain|configuration|schema|dnsdomain|dnsforest) [options]"
833
834     takes_optiongroups = {
835         "sambaopts": options.SambaOptions,
836         "versionopts": options.VersionOptions,
837         "credopts": options.CredentialsOptionsDouble,
838     }
839
840     takes_optiongroups = {
841         "sambaopts": options.SambaOptions,
842         "versionopts": options.VersionOptions,
843         "credopts": options.CredentialsOptionsDouble,
844     }
845
846     takes_args = ["URL1", "URL2", "context1?", "context2?", "context3?", "context4?", "context5?"]
847
848     takes_options = [
849         Option("-w", "--two", dest="two", action="store_true", default=False,
850             help="Hosts are in two different domains"),
851         Option("-q", "--quiet", dest="quiet", action="store_true", default=False,
852             help="Do not print anything but relay on just exit code"),
853         Option("-v", "--verbose", dest="verbose", action="store_true", default=False,
854             help="Print all DN pairs that have been compared"),
855         Option("--sd", dest="descriptor", action="store_true", default=False,
856             help="Compare nTSecurityDescriptor attibutes only"),
857         Option("--sort-aces", dest="sort_aces", action="store_true", default=False,
858             help="Sort ACEs before comparison of nTSecurityDescriptor attribute"),
859         Option("--view", dest="view", default="section",
860             help="Display mode for nTSecurityDescriptor results. Possible values: section or collision."),
861         Option("--base", dest="base", default="",
862             help="Pass search base that will build DN list for the first DC."),
863         Option("--base2", dest="base2", default="",
864             help="Pass search base that will build DN list for the second DC. Used when --two or when compare two different DNs."),
865         Option("--scope", dest="scope", default="SUB",
866             help="Pass search scope that builds DN list. Options: SUB, ONE, BASE"),
867         Option("--filter", dest="filter", default="",
868             help="List of comma separated attributes to ignore in the comparision"),
869         Option("--skip-missing-dn", dest="skip_missing_dn", action="store_true", default=False,
870             help="Skip report and failure due to missing DNs in one server or another"),
871         ]
872
873     def run(self, URL1, URL2,
874             context1=None, context2=None, context3=None, context4=None, context5=None,
875             two=False, quiet=False, verbose=False, descriptor=False, sort_aces=False,
876             view="section", base="", base2="", scope="SUB", filter="",
877             credopts=None, sambaopts=None, versionopts=None, skip_missing_dn=False):
878
879         lp = sambaopts.get_loadparm()
880
881         using_ldap = URL1.startswith("ldap") or URL2.startswith("ldap")
882
883         if using_ldap:
884             creds = credopts.get_credentials(lp, fallback_machine=True)
885         else:
886             creds = None
887         creds2 = credopts.get_credentials2(lp, guess=False)
888         if creds2.is_anonymous():
889             creds2 = creds
890         else:
891             creds2.set_domain("")
892             creds2.set_workstation("")
893         if using_ldap and not creds.authentication_requested():
894             raise CommandError("You must supply at least one username/password pair")
895
896         # make a list of contexts to compare in
897         contexts = []
898         if context1 is None:
899             if base and base2:
900                 # If search bases are specified context is defaulted to
901                 # DOMAIN so the given search bases can be verified.
902                 contexts = ["DOMAIN"]
903             else:
904                 # if no argument given, we compare all contexts
905                 contexts = ["DOMAIN", "CONFIGURATION", "SCHEMA", "DNSDOMAIN", "DNSFOREST"]
906         else:
907             for c in [context1, context2, context3, context4, context5]:
908                 if c is None:
909                     continue
910                 if not c.upper() in ["DOMAIN", "CONFIGURATION", "SCHEMA", "DNSDOMAIN", "DNSFOREST"]:
911                     raise CommandError("Incorrect argument: %s" % c)
912                 contexts.append(c.upper())
913
914         if verbose and quiet:
915             raise CommandError("You cannot set --verbose and --quiet together")
916         if (not base and base2) or (base and not base2):
917             raise CommandError("You need to specify both --base and --base2 at the same time")
918         if descriptor and view.upper() not in ["SECTION", "COLLISION"]:
919             raise CommandError("Invalid --view value. Choose from: section or collision")
920         if not scope.upper() in ["SUB", "ONE", "BASE"]:
921             raise CommandError("Invalid --scope value. Choose from: SUB, ONE, BASE")
922
923         con1 = LDAPBase(URL1, creds, lp,
924                         two=two, quiet=quiet, descriptor=descriptor, sort_aces=sort_aces,
925                         verbose=verbose,view=view, base=base, scope=scope,
926                         outf=self.outf, errf=self.errf)
927         assert len(con1.base_dn) > 0
928
929         con2 = LDAPBase(URL2, creds2, lp,
930                         two=two, quiet=quiet, descriptor=descriptor, sort_aces=sort_aces,
931                         verbose=verbose, view=view, base=base2, scope=scope,
932                         outf=self.outf, errf=self.errf)
933         assert len(con2.base_dn) > 0
934
935         filter_list = filter.split(",")
936
937         status = 0
938         for context in contexts:
939             if not quiet:
940                 self.outf.write("\n* Comparing [%s] context...\n" % context)
941
942             b1 = LDAPBundel(con1, context=context, filter_list=filter_list,
943                             outf=self.outf, errf=self.errf)
944             b2 = LDAPBundel(con2, context=context, filter_list=filter_list,
945                             outf=self.outf, errf=self.errf)
946
947             if b1 == b2:
948                 if not quiet:
949                     self.outf.write("\n* Result for [%s]: SUCCESS\n" %
950                         context)
951             else:
952                 if not quiet:
953                     self.outf.write("\n* Result for [%s]: FAILURE\n" % context)
954                     if not descriptor:
955                         assert len(b1.summary["df_value_attrs"]) == len(b2.summary["df_value_attrs"])
956                         b2.summary["df_value_attrs"] = []
957                         self.outf.write("\nSUMMARY\n")
958                         self.outf.write("---------\n")
959                         b1.print_summary()
960                         b2.print_summary()
961                 # mark exit status as FAILURE if a least one comparison failed
962                 status = -1
963         if status != 0:
964             raise CommandError("Compare failed: %d" % status)