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
10 # Copyright (C) Zahari Zahariev <zahari.zahariev@postpath.com> 2009
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.
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.
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/>.
29 from optparse import OptionParser
31 sys.path.insert(0, "bin/python")
34 import samba.getopt as options
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
43 class LDAPBase(object):
45 def __init__(self, host, creds, lp):
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)
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"])
60 if "nETBIOSName" in x.keys():
61 return x["nETBIOSName"][0]
63 def find_basedn(self):
64 res = self.ldb.search(base="", expression="(objectClass=*)", scope=SCOPE_BASE,
65 attrs=["defaultNamingContext"])
67 return res[0]["defaultNamingContext"][0]
69 def object_exists(self, object_dn):
72 res = self.ldb.search(base=object_dn, scope=SCOPE_BASE, expression="(objectClass=*)")
73 except LdbError, (ERR_NO_SUCH_OBJECT, _):
77 def get_object_sid(self, object_dn):
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)
83 return res[0]["objectSid"][0]
85 def delete_force(self, object_dn):
87 self.ldb.delete(object_dn)
88 except Ldb.LdbError, e:
89 assert "No such object" in str(e)
91 def get_attributes(self, object_dn):
92 """ Returns dict with all default visible attributes
94 res = self.ldb.search(base=object_dn, scope=SCOPE_BASE, attrs=["*"])
97 # 'Dn' element is not iterable and we have it as 'distinguishedName'
99 for key in res.keys():
100 res[key] = list(res[key])
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]
108 class AdObject(object):
109 def __init__(self, con, dn, summary):
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"]
125 #self.ignore_attributes = []
126 self.ignore_attributes = [x.upper() for x in self.ignore_attributes]
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]
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]
145 if res.upper().endswith(self.con.base_dn.upper()):
146 res = res[:len(res)-len(self.con.base_dn)] + "${DOMAIN_DN}"
149 def fix_domain_name(self, s):
151 if res.upper().endswith(self.con.domain_name.upper()):
152 res = res[:len(res)-len(self.con.domain_name)] + "${DOMAIN_NAME}"
155 def fix_netbios_name(self, s):
157 if res.upper().endswith(self.con.netbios_name.upper()):
158 res = res[:len(res)-len(self.con.netbios_name)] + "${NETBIOS_NAME}"
161 def __eq__(self, other):
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!"
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()]:
176 self.unique_attrs.append(x)
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()]:
185 other.unique_attrs.append(x)
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:
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]:
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]]
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]]
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]]
224 print 8*" " + x + " -> \n* %s\n* %s" % (p, q)
226 print 8*" " + x + " -> \n* %s\n* %s" % (self.attributes[x], other.attributes[x])
227 self.df_value_attrs.append(x)
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
240 class AdBundel(object):
241 def __init__(self, con, context=None, dn_list=None):
244 self.summary["unique_attrs"] = []
245 self.summary["df_value_attrs"] = []
246 self.summary["known_ignored_dn"] = []
247 self.summary["abnormal_ignored_dn"] = []
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)
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)
260 def update_size(self):
261 self.size = len(self.dn_list)
262 self.dn_list = sorted(self.dn_list)
264 def __eq__(self, other):
266 if self.size != other.size:
267 print "Lists have different size: %s != %s" % (self.size, other.size)
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]:
274 self.dn_list[self.dn_list.index(x)] = ""
275 self.dn_list = [x for x in self.dn_list if x]
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]:
281 other.dn_list[other.dn_list.index(x)] = ""
282 other.dn_list = [x for x in other.dn_list if x]
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])
291 while index < self.size:
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]
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]
306 print "\nComparing:\n'%s'\n'%s'" % (object1.dn, object2.dn)
307 if object1 == object2:
310 print 4*" " + "FAILED"
312 self.summary = object1.summary
313 other.summary = object2.summary
318 def is_ignored(self, dn):
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,",
335 # Configuration naming context
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
351 for x in ignore_list[self.context]:
352 if re.match(x.upper(), dn.upper()):
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.
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
368 res = self.con.ldb.search(base=search_base, scope=SCOPE_SUBTREE, attrs=["dn"])
370 dn_list.append(x["dn"].get_linearized())
375 print "\nIgnored (strange) DNs in %s:" % self.con.base_dn
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()
382 dn_list[dn_list.index(x)] = ""
385 print "\nKnown DN ignore list for %s" % self.con.base_dn
387 if self.is_ignored(x):
389 dn_list[dn_list.index(x)] = ""
391 dn_list = [x for x in dn_list if x]
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"]))
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"]])
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"] = []
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)
413 lp = sambaopts.get_loadparm()
414 creds = credopts.get_credentials(lp)
415 creds2 = credopts.get_credentials2(lp)
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()
423 if not (len(args) == 1 and args[0].upper() in ["DOMAIN", "CONFIGURATION", "SCHEMA"]):
424 parser.error("Incorrect arguments")
426 con1 = LDAPBase(options.host, creds, lp)
427 assert len(con1.base_dn) > 0
429 con2 = LDAPBase(options.host2, creds2, lp)
430 assert len(con2.base_dn) > 0
432 b1 = AdBundel(con1, args[0])
433 b2 = AdBundel(con2, args[0])
436 print "\n\nFinal result: SUCCESS!"
439 print "\n\nFinal result: FAILURE!"
442 assert len(b1.summary["df_value_attrs"]) == len(b2.summary["df_value_attrs"])