Comparison tool for LDAP servers (using Ldb)
[kai/samba.git] / source4 / scripting / devel / ldapcmp
1 #!/usr/bin/python
2 #
3 # Unix SMB/CIFS implementation.
4 # A script to compare differences of objects and attributes between
5 # two LDAP servers both running at the same time. It generally compares
6 # one of the three pratitions DOMAIN, CONFIGURATION or SCHEMA. Users
7 # that have to be provided sheould be able to read objects in any of the
8 # above partitions.
9
10 # Copyright (C) Zahari Zahariev <zahari.zahariev@postpath.com> 2009
11 #
12 # This program is free software; you can redistribute it and/or modify
13 # it under the terms of the GNU General Public License as published by
14 # the Free Software Foundation; either version 3 of the License, or
15 # (at your option) any later version.
16 #
17 # This program is distributed in the hope that it will be useful,
18 # but WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 # GNU General Public License for more details.
21 #
22 # You should have received a copy of the GNU General Public License
23 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
24 #
25
26 import os
27 import re
28 import sys
29 from optparse import OptionParser
30
31 sys.path.insert(0, "bin/python")
32
33 import samba
34 import samba.getopt as options
35 from samba import Ldb
36 from samba.ndr import ndr_pack, ndr_unpack
37 from samba.dcerpc import security
38 from ldb import SCOPE_SUBTREE, SCOPE_ONELEVEL, SCOPE_BASE, ERR_NO_SUCH_OBJECT, LdbError
39
40 global summary
41 summary = {}
42
43 class LDAPBase(object):
44
45     def __init__(self, host, creds, lp):
46         if not "://" in host:
47             self.host = "ldap://" + host + ":389"
48         self.ldb = Ldb(self.host, credentials=creds, lp=lp,
49                              options=["modules:paged_searches"])
50         self.base_dn = self.find_basedn()
51         self.netbios_name = self.find_netbios()
52         self.domain_name = re.sub("[Dd][Cc]=", "", self.base_dn).replace(",", ".")
53         self.domain_sid_bin = self.get_object_sid(self.base_dn)
54
55     def find_netbios(self):
56         res = self.ldb.search(base="CN=Partitions,CN=Configuration,%s" % self.base_dn, \
57                 scope=SCOPE_SUBTREE, attrs=["nETBIOSName"])
58         assert len(res) > 0
59         for x in res:
60             if "nETBIOSName" in x.keys():
61                 return x["nETBIOSName"][0]
62
63     def find_basedn(self):
64         res = self.ldb.search(base="", expression="(objectClass=*)", scope=SCOPE_BASE,
65                 attrs=["defaultNamingContext"])
66         assert len(res) == 1
67         return res[0]["defaultNamingContext"][0]
68
69     def object_exists(self, object_dn):
70         res = None
71         try:
72             res = self.ldb.search(base=object_dn, scope=SCOPE_BASE, expression="(objectClass=*)")
73         except LdbError, (ERR_NO_SUCH_OBJECT, _):
74             return False
75         return len(res) == 1
76
77     def get_object_sid(self, object_dn):
78         try:
79             res = self.ldb.search(base=object_dn, expression="(objectClass=*)", scope=SCOPE_BASE, attrs=["objectSid"])
80         except LdbError, (ERR_NO_SUCH_OBJECT, _):
81             raise Exception("DN sintax is wrong or object does't exist: " + object_dn)
82         assert len(res) == 1
83         return res[0]["objectSid"][0]
84
85     def delete_force(self, object_dn):
86         try:
87             self.ldb.delete(object_dn)
88         except Ldb.LdbError, e:
89             assert "No such object" in str(e)
90
91     def get_attributes(self, object_dn):
92         """ Returns dict with all default visible attributes
93         """
94         res = self.ldb.search(base=object_dn, scope=SCOPE_BASE, attrs=["*"])
95         assert len(res) == 1
96         res = dict(res[0])
97         # 'Dn' element is not iterable and we have it as 'distinguishedName'
98         del res["dn"]
99         for key in res.keys():
100             res[key] = list(res[key])
101         return res
102
103     def get_descriptor(self, object_dn):
104         res = self.ldb.search(base=object_dn, scope=SCOPE_BASE, attrs=["nTSecurityDescriptor"])
105         return res[0]["nTSecurityDescriptor"][0]
106
107
108 class AdObject(object):
109     def __init__(self, con, dn, summary):
110         self.con = con
111         self.summary = summary
112         self.dn = dn.replace("${DOMAIN_DN}", self.con.base_dn)
113         self.attributes = self.con.get_attributes(self.dn)
114         # attributes that are considered always to be different e.g based on timestamp etc.
115         self.ignore_attributes =  ["objectCategory", "objectGUID", \
116                 "whenChanged", "objectSid", "whenCreated", "uSNChanged", "pwdLastSet", \
117                 "uSNCreated", "logonCount", "badPasswordTime", "lastLogon", "creationTime", \
118                 "modifiedCount", "priorSetTime", "rIDManagerReference", "gPLink", "ipsecNFAReference", \
119                 "fRSPrimaryMember", "fSMORoleOwner", "masteredBy", "ipsecOwnersReference", "wellKnownObjects", \
120                 "badPwdCount", "ipsecISAKMPReference", "ipsecFilterReference", "msDs-masteredBy", "lastSetTime", \
121                 "ipsecNegotiationPolicyReference", "subRefs", "gPCFileSysPath", "accountExpires", "dSCorePropagationData", \
122                 # After Exchange preps
123                 "targetAddress", "msExchMailboxGuid", "siteFolderGUID"]
124
125         #self.ignore_attributes =  []
126         self.ignore_attributes = [x.upper() for x in self.ignore_attributes]
127         #
128         # Attributes that contain the unique DN tail part e.g. 'DC=samba,DC=org'
129         self.dn_attributes = ["distinguishedName", "defaultObjectCategory", \
130                 "member", "memberOf", "siteList", "nCName", "homeMDB", "homeMTA", "interSiteTopologyGenerator", \
131                 # After Exchange preps
132                 "msExchHomeRoutingGroup", "msExchResponsibleMTAServer", "siteFolderServer", "msExchRoutingMasterDN", \
133                 "msExchRoutingGroupMembersBL", "homeMDBBL", "msExchHomePublicMDB", "msExchOwningServer", "templateRoots", \
134                 "addressBookRoots", "msExchPolicyRoots", "globalAddressList", "msExchOwningPFTree", \
135                 "msExchResponsibleMTAServerBL", "msExchOwningPFTreeBL",]
136         self.dn_attributes = [x.upper() for x in self.dn_attributes]
137         #
138         # Attributes that contain the Domain name e.g. 'samba.org'
139         self.domain_attributes = ["proxyAddresses", "mail", "userPrincipalName", "msExchSmtpFullyQualifiedDomainName", \
140                 "dnsHostName", "networkAddress", "dnsRoot", "servicePrincipalName"]
141         self.domain_attributes = [x.upper() for x in self.domain_attributes]
142
143     def fix_dn(self, s):
144         res = "%s" % s
145         if res.upper().endswith(self.con.base_dn.upper()):
146             res = res[:len(res)-len(self.con.base_dn)] + "${DOMAIN_DN}"
147         return res
148
149     def fix_domain_name(self, s):
150         res = "%s" % s
151         if res.upper().endswith(self.con.domain_name.upper()):
152             res = res[:len(res)-len(self.con.domain_name)] + "${DOMAIN_NAME}"
153         return res
154
155     def fix_netbios_name(self, s):
156         res = "%s" % s
157         if res.upper().endswith(self.con.netbios_name.upper()):
158             res = res[:len(res)-len(self.con.netbios_name)] + "${NETBIOS_NAME}"
159         return res
160
161     def __eq__(self, other):
162         res = True
163         self.unique_attrs = []
164         self.df_value_attrs = []
165         other.unique_attrs = []
166         if self.attributes.keys() != other.attributes.keys():
167             print 4*" " + "Different number of attributes!"
168             #
169             title = 4*" " + "Attributes found only in %s:" % self.con.base_dn
170             for x in self.attributes.keys():
171                 if not x.upper() in [q.upper() for q in other.attributes.keys()]:
172                     if title:
173                         print title
174                         title = None
175                     print 8*" " + x
176                     self.unique_attrs.append(x)
177             #
178             title = 4*" " + "Attributes found only in %s:" % other.con.base_dn
179             for x in other.attributes.keys():
180                 if not x.upper() in [q.upper() for q in self.attributes.keys()]:
181                     if title:
182                         print title
183                         title = None
184                     print 8*" " + x
185                     other.unique_attrs.append(x)
186             #
187             res = False
188         #
189         missing_attrs = [x.upper() for x in self.unique_attrs]
190         missing_attrs += [x.upper() for x in other.unique_attrs]
191         title = 4*" " + "Difference in attribute values:"
192         for x in self.attributes.keys():
193             if x.upper() in self.ignore_attributes or x.upper() in missing_attrs:
194                 continue
195             if isinstance(self.attributes[x], list) and isinstance(other.attributes[x], list):
196                 self.attributes[x] = sorted(self.attributes[x])
197                 other.attributes[x] = sorted(other.attributes[x])
198             if self.attributes[x] != other.attributes[x]:
199                 p = None
200                 q = None
201                 # Attribute values that are list that contain DN based values that may differ
202                 if x.upper() in self.dn_attributes:
203                     p = [self.fix_dn(j) for j in self.attributes[x]]
204                     q = [other.fix_dn(j) for j in other.attributes[x]]
205                     if p == q:
206                         continue
207                 elif x.upper() in ["DC",]:
208                     # Usually displayed as the first part of the Domain DN
209                     p = [self.con.domain_name.split(".")[0] == j for j in self.attributes[x]]
210                     q = [other.con.domain_name.split(".")[0] == j for j in other.attributes[x]]
211                     if p == q:
212                         continue
213                 # Attributes that contain the Domain name in them
214                 elif x.upper() in self.domain_attributes:
215                     p = [self.fix_domain_name(j) for j in self.attributes[x]]
216                     q = [other.fix_domain_name(j) for j in other.attributes[x]]
217                     if p == q:
218                         continue
219                 #
220                 if title:
221                     print title
222                     title = None
223                 if p and q:
224                     print 8*" " + x + " -> \n* %s\n* %s" % (p, q)
225                 else:
226                     print 8*" " + x + " -> \n* %s\n* %s" % (self.attributes[x], other.attributes[x])
227                 self.df_value_attrs.append(x)
228                 res = False
229         #
230         if self.unique_attrs + other.unique_attrs != []:
231             assert self.unique_attrs != other.unique_attrs
232         self.summary["unique_attrs"] += self.unique_attrs
233         self.summary["df_value_attrs"] += self.df_value_attrs
234         other.summary["unique_attrs"] += other.unique_attrs
235         other.summary["df_value_attrs"] += self.df_value_attrs # they are the same
236         #
237         return res
238
239
240 class AdBundel(object):
241     def __init__(self, con, context=None, dn_list=None):
242         self.con = con
243         self.summary = {}
244         self.summary["unique_attrs"] = []
245         self.summary["df_value_attrs"] = []
246         self.summary["known_ignored_dn"] = []
247         self.summary["abnormal_ignored_dn"] = []
248         if dn_list:
249             self.dn_list = dn_list
250         elif context.upper() in ["DOMAIN", "CONFIGURATION", "SCHEMA"]:
251             self.context = context.upper()
252             self.dn_list = self.get_dn_list(context)
253         else:
254             raise Exception("Unknown initialization data for AdBundel().")
255         self.dn_list = [x[:len(x)-len(self.con.base_dn)] + "${DOMAIN_DN}" for x in self.dn_list]
256         self.dn_list = list(set(self.dn_list))
257         self.dn_list = sorted(self.dn_list)
258         self.size = len(self.dn_list)
259
260     def update_size(self):
261         self.size = len(self.dn_list)
262         self.dn_list = sorted(self.dn_list)
263
264     def __eq__(self, other):
265         res = True
266         if self.size != other.size:
267             print "Lists have different size: %s != %s" % (self.size, other.size)
268             res = False
269         #
270         print "\n* DNs found only in %s:" % self.con.base_dn
271         for x in self.dn_list:
272             if not x.upper() in [q.upper() for q in other.dn_list]:
273                 print "    %s" % x
274                 self.dn_list[self.dn_list.index(x)] = ""
275         self.dn_list = [x for x in self.dn_list if x]
276         #
277         print "\n* DNs found only in %s:" % other.con.base_dn
278         for x in other.dn_list:
279             if not x.upper() in [q.upper() for q in self.dn_list]:
280                 print "    %s" % x
281                 other.dn_list[other.dn_list.index(x)] = ""
282         other.dn_list = [x for x in other.dn_list if x]
283         #
284         self.update_size()
285         other.update_size()
286         print "%s == %s" % (self.size, other.size)
287         assert self.size == other.size
288         assert sorted([x.upper() for x in self.dn_list]) == sorted([x.upper() for x in other.dn_list])
289
290         index = 0
291         while index < self.size:
292             skip = False
293             try:
294                 object1 = AdObject(self.con, self.dn_list[index], self.summary)
295             except LdbError, (ERR_NO_SUCH_OBJECT, _):
296                 print "\n!!! Object not found:", self.dn_list[index]
297                 skip = True
298             try:
299                 object2 = AdObject(other.con, other.dn_list[index], other.summary)
300             except LdbError, (ERR_NO_SUCH_OBJECT, _):
301                 print "\n!!! Object not found:", other.dn_list[index]
302                 skip = True
303             if skip:
304                 index += 1
305                 continue
306             print "\nComparing:\n'%s'\n'%s'" % (object1.dn, object2.dn)
307             if object1 == object2:
308                 print 4*" " + "OK"
309             else:
310                 print 4*" " + "FAILED"
311                 res = False
312             self.summary = object1.summary
313             other.summary = object2.summary
314             index += 1
315         #
316         return res
317
318     def is_ignored(self, dn):
319         ignore_list = {
320             "DOMAIN" : [
321                 # Default naming context
322                 "^CN=BCKUPKEY_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} Secret,CN=System,",
323                 "^CN=BCKUPKEY_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} Secret,CN=System,",
324                 "^CN=Domain System Volume (SYSVOL share),CN=NTFRS Subscriptions,CN=.+?,OU=Domain Controllers,",
325                 "^CN=NTFRS Subscriptions,CN=.+?,OU=Domain Controllers,",
326                 "^CN=RID Set,CN=.+?,OU=Domain Controllers,",
327                 "^CN=.+?,CN=Domain System Volume \(SYSVOL share\),CN=File Replication Service,CN=System,",
328                 "^CN=.+?,OU=Domain Controllers,",
329                 # After Exchange preps
330                 "^CN=OWAScratchPad.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.,CN=Microsoft Exchange System Objects,",
331                 "^CN=StoreEvents.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.,CN=Microsoft ExchangeSystem Objects,",
332                 "^CN=SystemMailbox.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.,CN=Microsoft Exchange System Objects,",
333
334             ],
335             # Configuration naming context
336             "CONFIGURATION" : [
337                 "^CN=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12},CN=Partitions,CN=Configuration,",
338                 "^CN=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12},CN=Partitions,CN=Configuration,",
339                 "^CN=NTDS Settings,CN=.+?,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,",
340                 "^CN=.+?,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,",
341                 "^CN=%s,CN=Partitions,CN=Configuration," % self.con.netbios_name,
342                 # This one has to be investigated
343                 "^CN=Default Query Policy,CN=Query-Policies,CN=Directory Service,CN=WindowsNT,CN=Services,CN=Configuration,",
344                 # After Exchange preps
345                 "^CN=SMTP \(.+?-\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}\),CN=Connections,CN=First Organization,CN=Microsoft Exchange,CN=Services,CN=Configuration,", # x 3 times
346             ],
347             "SCHEMA" : [
348             ],
349         }
350         #ignore_list = {}
351         for x in ignore_list[self.context]:
352             if re.match(x.upper(), dn.upper()):
353                 return True
354         return False
355
356     def get_dn_list(self, context):
357         """ Query LDAP server about the DNs of certain naming self.con.ext Domain (or Default), Configuration, Schema.
358             Parse all DNs and filter those that are 'strange' or abnormal.
359         """
360         if context.upper() == "DOMAIN":
361             search_base = "%s" % self.con.base_dn
362         elif context.upper() == "CONFIGURATION":
363             search_base = "CN=Configuration,%s" % self.con.base_dn
364         elif context.upper() == "SCHEMA":
365             search_base = "CN=Schema,CN=Configuration,%s" % self.con.base_dn
366
367         dn_list = []
368         res = self.con.ldb.search(base=search_base, scope=SCOPE_SUBTREE, attrs=["dn"])
369         for x in res:
370            dn_list.append(x["dn"].get_linearized())
371
372         #
373         global summary
374         #
375         print "\nIgnored (strange) DNs in %s:" % self.con.base_dn
376         for x in dn_list:
377             xx = "".join(re.findall("[Cc][Nn]=.*?,", x)) \
378                     + "".join(re.findall("[Oo][Uu]=.*?,", x)) \
379                     + "".join(re.findall("[Dd][Cc]=.*?,", x)) + re.search("([Dd][Cc]=[\w^=]*?$)", x).group()
380             if x != xx:
381                 print 4*" " + x
382                 dn_list[dn_list.index(x)] = ""
383         #
384
385         print "\nKnown DN ignore list for %s" % self.con.base_dn
386         for x in dn_list:
387             if self.is_ignored(x):
388                 print 4*" " + x
389                 dn_list[dn_list.index(x)] = ""
390         #
391         dn_list = [x for x in dn_list if x]
392         return dn_list
393
394     def print_summary(self):
395         self.summary["unique_attrs"] = list(set(self.summary["unique_attrs"]))
396         self.summary["df_value_attrs"] = list(set(self.summary["df_value_attrs"]))
397         #
398         print "\nAttributes found only in %s:" % self.con.base_dn
399         print "".join([str("\n" + 4*" " + x) for x in self.summary["unique_attrs"]])
400         #
401         print "\nAttributes with different values:"
402         print "".join([str("\n" + 4*" " + x) for x in self.summary["df_value_attrs"]])
403         self.summary["df_value_attrs"] = []
404
405 ###
406
407 if __name__ == "__main__":
408     parser = OptionParser("ldapcmp [options] domain|configuration|schema")
409     sambaopts = options.SambaOptions(parser)
410     credopts = options.CredentialsOptionsDouble(parser)
411     parser.add_option_group(credopts)
412
413     lp = sambaopts.get_loadparm()
414     creds = credopts.get_credentials(lp)
415     creds2 = credopts.get_credentials2(lp)
416
417     parser.add_option("", "--host", dest="host",
418                               help="IP of the first LDAP server",)
419     parser.add_option("", "--host2", dest="host2",
420                               help="IP of the second LDAP server",)
421     (options, args) = parser.parse_args()
422
423     if not (len(args) == 1 and args[0].upper() in ["DOMAIN", "CONFIGURATION", "SCHEMA"]):
424         parser.error("Incorrect arguments")
425
426     con1 = LDAPBase(options.host, creds, lp)
427     assert len(con1.base_dn) > 0
428
429     con2 = LDAPBase(options.host2, creds2, lp)
430     assert len(con2.base_dn) > 0
431
432     b1 = AdBundel(con1, args[0])
433     b2 = AdBundel(con2, args[0])
434
435     if b1 == b2:
436         print "\n\nFinal result: SUCCESS!"
437         status = 0
438     else:
439         print "\n\nFinal result: FAILURE!"
440         status = 1
441
442     assert len(b1.summary["df_value_attrs"]) == len(b2.summary["df_value_attrs"])
443
444     print "\nSUMMARY"
445     print "---------"
446     b1.print_summary()
447     b2.print_summary()
448
449     sys.exit(status)