#!/usr/bin/env python # # Compute our KCC topology # # Copyright (C) Dave Craft 2011 # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import os import sys import random # ensure we get messages out immediately, so they get in the samba logs, # and don't get swallowed by a timeout os.environ['PYTHONUNBUFFERED'] = '1' # forcing GMT avoids a problem in some timezones with kerberos. Both MIT # heimdal can get mutual authentication errors due to the 24 second difference # between UTC and GMT when using some zone files (eg. the PDT zone from # the US) os.environ["TZ"] = "GMT" # Find right directory when running from source tree sys.path.insert(0, "bin/python") import samba, ldb import optparse import logging from samba import getopt as options from samba.auth import system_session from samba.samdb import SamDB from samba.kcc_utils import * class KCC: """The Knowledge Consistency Checker class. A container for objects and methods allowing a run of the KCC. Produces a set of connections in the samdb for which the Distributed Replication Service can then utilize to replicate naming contexts """ def __init__(self, samdb): """Initializes the partitions class which can hold our local DCs partitions or all the partitions in the forest """ self.dsa_table = {} # dsa objects self.part_table = {} # partition objects self.site_table = {} self.my_dsa_dnstr = None # My dsa DN self.my_site_dnstr = None self.samdb = samdb def load_my_site(self): """Loads the Site class for the local DSA Raises an Exception on error """ self.my_site_dnstr = "CN=%s,CN=Sites,%s" % (samdb.server_site_name(), samdb.get_config_basedn()) site = Site(self.my_site_dnstr) site.load_site(samdb) self.site_table[self.my_site_dnstr] = site def load_my_dsa(self): """Discover my nTDSDSA thru the rootDSE entry and instantiate and load the DSA. The dsa is inserted into the dsa_table by dn string Raises an Exception on error. """ dn = ldb.Dn(self.samdb, "") try: res = samdb.search(base=dn, scope=ldb.SCOPE_BASE, attrs=["dsServiceName"]) except ldb.LdbError, (enum, estr): raise Exception("Unable to find my nTDSDSA - (%s)" % estr) dnstr = res[0]["dsServiceName"][0] # already loaded if dnstr in self.dsa_table.keys(): return self.my_dsa_dnstr = dnstr dsa = DirectoryServiceAgent(dnstr) dsa.load_dsa(samdb) # Assign this dsa to my dsa table # and index by dsa dn self.dsa_table[dnstr] = dsa def load_all_dsa(self): """Discover all nTDSDSA thru the sites entry and instantiate and load the DSAs. Each dsa is inserted into the dsa_table by dn string. Raises an Exception on error. """ try: res = self.samdb.search("CN=Sites,%s" % self.samdb.get_config_basedn(), scope=ldb.SCOPE_SUBTREE, expression="(objectClass=nTDSDSA)") except ldb.LdbError, (enum, estr): raise Exception("Unable to find nTDSDSAs - (%s)" % estr) for msg in res: dnstr = str(msg.dn) # already loaded if dnstr in self.dsa_table.keys(): continue dsa = DirectoryServiceAgent(dnstr) dsa.load_dsa(self.samdb) # Assign this dsa to my dsa table # and index by dsa dn self.dsa_table[dnstr] = dsa def load_all_partitions(self): """Discover all NCs thru the Partitions dn and instantiate and load the NCs. Each NC is inserted into the part_table by partition dn string (not the nCName dn string) Raises an Exception on error """ try: res = self.samdb.search("CN=Partitions,%s" % self.samdb.get_config_basedn(), scope=ldb.SCOPE_SUBTREE, expression="(objectClass=crossRef)") except ldb.LdbError, (enum, estr): raise Exception("Unable to find partitions - (%s)" % estr) for msg in res: partstr = str(msg.dn) # already loaded if partstr in self.part_table.keys(): continue part = Partition(partstr) part.load_partition(self.samdb) self.part_table[partstr] = part def should_be_present_test(self): """Enumerate all loaded partitions and DSAs and test if NC should be present as replica """ for partdn, part in self.part_table.items(): for dsadn, dsa in self.dsa_table.items(): needed, ro, partial = part.should_be_present(dsa) logger.info("dsadn:%s\nncdn:%s\nneeded=%s:ro=%s:partial=%s\n" % \ (dsa.dsa_dnstr, part.nc_dnstr, needed, ro, partial)) def refresh_failed_links_connections(self): # XXX - not implemented yet return def is_stale_link_connection(self, target_dsa): """Returns False if no tuple z exists in the kCCFailedLinks or kCCFailedConnections variables such that z.UUIDDsa is the objectGUID of the target dsa, z.FailureCount > 0, and the current time - z.TimeFirstFailure > 2 hours. """ # XXX - not implemented yet return False def remove_unneeded_failed_links_connections(self): # XXX - not implemented yet return def remove_unneeded_ntds_connections(self): # XXX - not implemented yet return def translate_connections(self): # XXX - not implemented yet return def intersite(self): """The head method for generating the inter-site KCC replica connection graph and attendant nTDSConnection objects in the samdb """ # XXX - not implemented yet def update_rodc_connection(self): """Runs when the local DC is an RODC and updates the RODC NTFRS connection object. """ # Given an nTDSConnection object cn1, such that cn1.options contains # NTDSCONN_OPT_RODC_TOPOLOGY, and another nTDSConnection object cn2, # does not contain NTDSCONN_OPT_RODC_TOPOLOGY, modify cn1 to ensure # that the following is true: # # cn1.fromServer = cn2.fromServer # cn1.schedule = cn2.schedule # # If no such cn2 can be found, cn1 is not modified. # If no such cn1 can be found, nothing is modified by this task. # XXX - not implemented yet def intrasite_max_node_edges(self, node_count): """Returns the maximum number of edges directed to a node in the intrasite replica graph. The KCC does not create more than 50 edges directed to a single DC. To optimize replication, we compute that each node should have n+2 total edges directed to it such that (n) is the smallest non-negative integer satisfying (node_count <= 2*(n*n) + 6*n + 7) :param node_count: total number of nodes in the replica graph """ n = 0 while True: if node_count <= (2 * (n * n) + (6 * n) + 7): break n = n + 1 n = n + 2 if n < 50: return n return 50 def construct_intrasite_graph(self, site_local, dc_local, nc_x, gc_only, detect_stale): # We're using the MS notation names here to allow # correlation back to the published algorithm. # # nc_x - naming context (x) that we are testing if it # "should be present" on the local DC # f_of_x - replica (f) found on a DC (s) for NC (x) # dc_s - DC where f_of_x replica was found # dc_local - local DC that potentially needs a replica # (f_of_x) # r_list - replica list R # p_of_x - replica (p) is partial and found on a DC (s) # for NC (x) # l_of_x - replica (l) is the local replica for NC (x) # that should appear on the local DC # r_len = is length of replica list |R| # # If the DSA doesn't need a replica for this # partition (NC x) then continue needed, ro, partial = nc_x.should_be_present(dc_local) logger.debug("construct_intrasite_graph:\n" + \ "nc_x: %s\ndc_local: %s\n" % \ (nc_x, dc_local) + \ "gc_only: %s\nneeded: %s\nro: %s\npartial: %s" % \ (gc_only, needed, ro, partial)) if needed == False: return # Create a NCReplica that matches what the local replica # should say. We'll use this below in our r_list l_of_x = NCReplica(dc_local.dsa_dnstr, dc_local.dsa_guid, \ nc_x.nc_dnstr, nc_x.nc_guid, nc_x.nc_sid) l_of_x.identify_by_basedn(self.samdb) l_of_x.rep_partial = partial l_of_x.rep_ro = ro # Empty replica sequence list r_list = [] # We'll loop thru all the DSAs looking for # writeable NC replicas that match the naming # context dn for (nc_x) # for dc_s_dn, dc_s in self.dsa_table.items(): # If this partition (nc_x) doesn't appear as a # replica (f_of_x) on (dc_s) then continue if not nc_x.nc_dnstr in dc_s.rep_table.keys(): continue # Pull out the NCReplica (f) of (x) with the dn # that matches NC (x) we are examining. f_of_x = dc_s.rep_table[nc_x.nc_dnstr] # Replica (f) of NC (x) must be writable if f_of_x.is_ro() == True: continue # Replica (f) of NC (x) must satisfy the # "is present" criteria for DC (s) that # it was found on if f_of_x.is_present() == False: continue # DC (s) must be a writable DSA other than # my local DC. In other words we'd only replicate # from other writable DC if dc_s.is_ro() or dc_s is dc_local: continue # Certain replica graphs are produced only # for global catalogs, so test against # method input parameter if gc_only and dc_s.is_gc() == False: continue # DC (s) must be in the same site as the local DC # This is the intra-site algorithm. We are not # replicating across multiple sites if site_local.is_same_site(dc_s) == False: continue # If NC (x) is intended to be read-only full replica # for a domain NC on the target DC then the source # DC should have functional level at minimum WIN2008 # # Effectively we're saying that in order to replicate # to a targeted RODC (which was introduced in Windows 2008) # then we have to replicate from a DC that is also minimally # at that level. # # You can also see this requirement in the MS special # considerations for RODC which state that to deploy # an RODC, at least one writable domain controller in # the domain must be running Windows Server 2008 if ro and partial == False and nc_x.nc_type == NCType.domain: if dc_s.is_minimum_behavior(DS_BEHAVIOR_WIN2008) == False: continue # If we haven't been told to turn off stale connection # detection and this dsa has a stale connection then # continue if detect_stale and self.is_stale_link_connection(dc_s) == True: continue # Replica meets criteria. Add it to table indexed # by the GUID of the DC that it appears on r_list.append(f_of_x) # If a partial (not full) replica of NC (x) "should be present" # on the local DC, append to R each partial replica (p of x) # such that p "is present" on a DC satisfying the same # criteria defined above for full replica DCs. if partial == True: # Now we loop thru all the DSAs looking for # partial NC replicas that match the naming # context dn for (NC x) for dc_s_dn, dc_s in self.dsa_table.items(): # If this partition NC (x) doesn't appear as a # replica (p) of NC (x) on the dsa DC (s) then # continue if not nc_x.nc_dnstr in dc_s.rep_table.keys(): continue # Pull out the NCReplica with the dn that # matches NC (x) we are examining. p_of_x = dsa.rep_table[nc_x.nc_dnstr] # Replica (p) of NC (x) must be partial if p_of_x.is_partial() == False: continue # Replica (p) of NC (x) must satisfy the # "is present" criteria for DC (s) that # it was found on if p_of_x.is_present() == False: continue # DC (s) must be a writable DSA other than # my DSA. In other words we'd only replicate # from other writable DSA if dc_s.is_ro() or dc_s is dc_local: continue # Certain replica graphs are produced only # for global catalogs, so test against # method input parameter if gc_only and dc_s.is_gc() == False: continue # DC (s) must be in the same site as the local DC # This is the intra-site algorithm. We are not # replicating across multiple sites if site_local.is_same_site(dc_s) == False: continue # This criteria is moot (a no-op) for this case # because we are scanning for (partial = True). The # MS algorithm statement says partial replica scans # should adhere to the "same" criteria as full replica # scans so the criteria doesn't change here...its just # rendered pointless. # # The case that is occurring would be a partial domain # replica is needed on a local DC global catalog. There # is no minimum windows behavior for those since GCs # have always been present. if ro and partial == False and nc_x.nc_type == NCType.domain: if dc_s.is_minimum_behavior(DS_BEHAVIOR_WIN2008) == False: continue # If we haven't been told to turn off stale connection # detection and this dsa has a stale connection then # continue if detect_stale and self.is_stale_link_connection(dc_s) == True: continue # Replica meets criteria. Add it to table indexed # by the GUID of the DSA that it appears on r_list.append(p_of_x) # Append to R the NC replica that "should be present" # on the local DC r_list.append(l_of_x) r_list.sort(sort_replica_by_dsa_guid) r_len = len(r_list) max_node_edges = self.intrasite_max_node_edges(r_len) # Add a node for each r_list element to the replica graph graph_list = [] for rep in r_list: node = GraphNode(rep.rep_dsa_dnstr, max_node_edges) graph_list.append(node) # For each r(i) from (0 <= i < |R|-1) i = 0 while i < (r_len-1): # Add an edge from r(i) to r(i+1) if r(i) is a full # replica or r(i+1) is a partial replica if r_list[i].is_partial() == False or \ r_list[i+1].is_partial() == True: graph_list[i+1].add_edge_from(r_list[i].rep_dsa_dnstr) # Add an edge from r(i+1) to r(i) if r(i+1) is a full # replica or ri is a partial replica. if r_list[i+1].is_partial() == False or \ r_list[i].is_partial() == True: graph_list[i].add_edge_from(r_list[i+1].rep_dsa_dnstr) i = i + 1 # Add an edge from r|R|-1 to r0 if r|R|-1 is a full replica # or r0 is a partial replica. if r_list[r_len-1].is_partial() == False or \ r_list[0].is_partial() == True: graph_list[0].add_edge_from(r_list[r_len-1].rep_dsa_dnstr) # Add an edge from r0 to r|R|-1 if r0 is a full replica or # r|R|-1 is a partial replica. if r_list[0].is_partial() == False or \ r_list[r_len-1].is_partial() == True: graph_list[r_len-1].add_edge_from(r_list[0].rep_dsa_dnstr) # For each existing nTDSConnection object implying an edge # from rj of R to ri such that j != i, an edge from rj to ri # is not already in the graph, and the total edges directed # to ri is less than n+2, the KCC adds that edge to the graph. i = 0 while i < r_len: dsa = self.dsa_table[graph_list[i].dsa_dnstr] graph_list[i].add_edges_from_connections(dsa) i = i + 1 i = 0 while i < r_len: tnode = graph_list[i] # To optimize replication latency in sites with many NC replicas, the # KCC adds new edges directed to ri to bring the total edges to n+2, # where the NC replica rk of R from which the edge is directed # is chosen at random such that k != i and an edge from rk to ri # is not already in the graph. # # Note that the KCC tech ref does not give a number for the definition # of "sites with many NC replicas". At a bare minimum to satisfy # n+2 edges directed at a node we have to have at least three replicas # in |R| (i.e. if n is zero then at least replicas from two other graph # nodes may direct edges to us). if r_len >= 3: # pick a random index findex = rindex = random.randint(0, r_len-1) # while this node doesn't have sufficient edges while tnode.has_sufficient_edges() == False: # If this edge can be successfully added (i.e. not # the same node and edge doesn't already exist) then # select a new random index for the next round if tnode.add_edge_from(graph_list[rindex].dsa_dnstr) == True: findex = rindex = random.randint(0, r_len-1) else: # Otherwise continue looking against each node # after the random selection rindex = rindex + 1 if rindex >= r_len: rindex = 0 if rindex == findex: logger.error("Unable to satisfy max edge criteria!") break # Print the graph node in debug mode logger.debug("%s" % tnode) # For each edge directed to the local DC, ensure a nTDSConnection # points to us that satisfies the KCC criteria if graph_list[i].dsa_dnstr == dc_local.dsa_dnstr: graph_list[i].add_connections_from_edges(dc_local) i = i + 1 def intrasite(self): """The head method for generating the intra-site KCC replica connection graph and attendant nTDSConnection objects in the samdb """ # Retrieve my DSA mydsa = self.dsa_table[self.my_dsa_dnstr] logger.debug("intrasite enter:\nmydsa: %s" % mydsa) # Test whether local site has topology disabled mysite = self.site_table[self.my_site_dnstr] if mysite.is_intrasite_topology_disabled(): return detect_stale = mysite.should_detect_stale() # Loop thru all the partitions. for partdn, part in self.part_table.items(): self.construct_intrasite_graph(mysite, mydsa, part, \ False, \ detect_stale) # If the DC is a GC server, the KCC constructs an additional NC # replica graph (and creates nTDSConnection objects) for the # config NC as above, except that only NC replicas that "are present" # on GC servers are added to R. for partdn, part in self.part_table.items(): if part.is_config(): self.construct_intrasite_graph(mysite, mydsa, part, \ True, \ detect_stale) # The DC repeats the NC replica graph computation and nTDSConnection # creation for each of the NC replica graphs, this time assuming # that no DC has failed. It does so by re-executing the steps as # if the bit NTDSSETTINGS_OPT_IS_TOPL_DETECT_STALE_DISABLED were # set in the options attribute of the site settings object for # the local DC's site. (ie. we set "detec_stale" flag to False) # Loop thru all the partitions. for partdn, part in self.part_table.items(): self.construct_intrasite_graph(mysite, mydsa, part, \ False, \ False) # don't detect stale # If the DC is a GC server, the KCC constructs an additional NC # replica graph (and creates nTDSConnection objects) for the # config NC as above, except that only NC replicas that "are present" # on GC servers are added to R. for partdn, part in self.part_table.items(): if part.is_config(): self.construct_intrasite_graph(mysite, mydsa, part, \ True, \ False) # don't detect stale # Commit any newly created connections to the samdb mydsa.commit_connection_table(self.samdb) logger.debug("intrasite exit:\nmydsa: %s" % mydsa) def run(self): """Method to perform a complete run of the KCC and produce an updated topology for subsequent NC replica syncronization between domain controllers """ # Setup try: self.load_my_dsa() self.load_all_dsa() self.load_all_partitions() self.load_my_site() except Exception, estr: logger.error("%s" % estr) return # self.should_be_present_test() # These are the published steps (in order) for the # MS description of the KCC algorithm # Step 1 self.refresh_failed_links_connections() # Step 2 self.intrasite() # Step 3 self.intersite() # Step 4 self.remove_unneeded_ntds_connections() # Step 5 self.translate_connections() # Step 6 self.remove_unneeded_failed_links_connections() # Step 7 self.update_rodc_connection() ################################################## # Global Functions ################################################## def sort_replica_by_dsa_guid(rep1, rep2): return cmp(rep1.rep_dsa_guid, rep2.rep_dsa_guid) ################################################## # samba_kcc entry point ################################################## parser = optparse.OptionParser("samba_kcc [options]") sambaopts = options.SambaOptions(parser) credopts = options.CredentialsOptions(parser) parser.add_option_group(sambaopts) parser.add_option_group(credopts) parser.add_option_group(options.VersionOptions(parser)) parser.add_option("--debug", help="debug output", action="store_true") parser.add_option("--seed", help="random number seed") logger = logging.getLogger("samba_kcc") logger.addHandler(logging.StreamHandler(sys.stdout)) lp = sambaopts.get_loadparm() creds = credopts.get_credentials(lp, fallback_machine=True) opts, args = parser.parse_args() if opts.debug: logger.setLevel(logging.DEBUG) else: logger.setLevel(logging.WARNING) # initialize seed from optional input parameter if opts.seed: random.seed(int(opts.seed)) else: random.seed(0xACE5CA11) private_dir = lp.get("private dir") samdb_path = os.path.join(private_dir, "samdb.ldb") try: samdb = SamDB(url=lp.samdb_url(), session_info=system_session(), credentials=creds, lp=lp) except ldb.LdbError, (num, msg): logger.info("Unable to open sam database %s : %s" % (lp.samdb_url(), msg)) sys.exit(1) # Instantiate Knowledge Consistency Checker and perform run kcc = KCC(samdb) kcc.run()