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