# Visualisation tools # # Copyright (C) Andrew Bartlett 2015, 2018 # # by Douglas Bagnall # # 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 from collections import defaultdict import subprocess import tempfile import samba.getopt as options from samba import dsdb from samba import nttime2unix from samba.netcmd import Command, SuperCommand, CommandError, Option from samba.samdb import SamDB from samba.graph import dot_graph from samba.graph import distance_matrix, COLOUR_SETS from samba.graph import full_matrix from samba.colour import is_colour_wanted from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError import time import re from samba.kcc import KCC, ldif_import_export from samba.kcc.kcc_utils import KCCError from samba.uptodateness import ( get_partition_maps, get_partition, get_own_cursor, get_utdv, get_utdv_edges, get_utdv_distances, get_utdv_max_distance, get_kcc_and_dsas, ) COMMON_OPTIONS = [ Option("-H", "--URL", help="LDB URL for database or target server", type=str, metavar="URL", dest="H"), Option("-o", "--output", help="write here (default stdout)", type=str, metavar="FILE", default=None), Option("--distance", help="Distance matrix graph output (default)", dest='format', const='distance', action='store_const'), Option("--utf8", help="Use utf-8 Unicode characters", action='store_true'), Option("--color-scheme", help=("use this colour scheme " "(implies --color=yes)"), choices=list(COLOUR_SETS.keys())), Option("-S", "--shorten-names", help="don't print long common suffixes", action='store_true', default=False), Option("-r", "--talk-to-remote", help="query other DCs' databases", action='store_true', default=False), Option("--no-key", help="omit the explanatory key", action='store_false', default=True, dest='key'), ] DOT_OPTIONS = [ Option("--dot", help="Graphviz dot output", dest='format', const='dot', action='store_const'), Option("--xdot", help="attempt to call Graphviz xdot", dest='format', const='xdot', action='store_const'), ] TEMP_FILE = '__temp__' class GraphCommand(Command): """Base class for graphing commands""" synopsis = "%prog [options]" takes_optiongroups = { "sambaopts": options.SambaOptions, "versionopts": options.VersionOptions, "credopts": options.CredentialsOptions, } takes_options = COMMON_OPTIONS + DOT_OPTIONS takes_args = () def get_db(self, H, sambaopts, credopts): lp = sambaopts.get_loadparm() creds = credopts.get_credentials(lp, fallback_machine=True) samdb = SamDB(url=H, credentials=creds, lp=lp) return samdb def write(self, s, fn=None, suffix='.dot'): """Decide whether we're dealing with a filename, a tempfile, or stdout, and write accordingly. :param s: the string to write :param fn: a destination :param suffix: suffix, if destination is a tempfile If fn is None or "-", write to stdout. If fn is visualize.TEMP_FILE, write to a temporary file Otherwise fn should be a filename to write to. """ if fn is None or fn == '-': # we're just using stdout (a.k.a self.outf) print(s, file=self.outf) return if fn is TEMP_FILE: fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise', suffix=suffix) f = open(fn, 'w') os.close(fd) else: f = open(fn, 'w') f.write(s) f.close() return fn def calc_output_format(self, format, output): """Heuristics to work out what output format was wanted.""" if not format: # They told us nothing! We have to work it out for ourselves. if output and output.lower().endswith('.dot'): return 'dot' else: return 'distance' if format == 'xdot': return 'dot' return format def call_xdot(self, s, output): if output is None: fn = self.write(s, TEMP_FILE) else: fn = self.write(s, output) xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot') subprocess.call([xdot, fn]) os.remove(fn) def calc_distance_color_scheme(self, color_scheme, output): """Heuristics to work out the colour scheme for distance matrices. Returning None means no colour, otherwise it should be a colour from graph.COLOUR_SETS""" if color_scheme is not None: # --color-scheme implies --color=yes for *this* purpose. return color_scheme if output in ('-', None): output = self.outf want_colour = is_colour_wanted(output, hint=self.requested_colour) if not want_colour: return None # if we got to here, we are using colour according to the # --color/NO_COLOR rules, but no colour scheme has been # specified, so we choose some defaults. if '256color' in os.environ.get('TERM', ''): return 'xterm-256color-heatmap' return 'ansi' def get_dnstr_site(dn): """Helper function for sorting and grouping DNs by site, if possible.""" m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn) if m: return m.group(1) # Oh well, let it sort by DN return dn def get_dnstrlist_site(t): """Helper function for sorting and grouping lists of (DN, ...) tuples by site, if possible.""" return get_dnstr_site(t[0]) def colour_hash(x): """Generate a randomish but consistent darkish colour based on the given object.""" from hashlib import md5 tmp_str = str(x) if isinstance(tmp_str, str): tmp_str = tmp_str.encode('utf8') c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f return '#%06x' % c class cmd_reps(GraphCommand): "repsFrom/repsTo from every DSA" takes_options = COMMON_OPTIONS + DOT_OPTIONS + [ Option("-p", "--partition", help="restrict to this partition", default=None), ] def run(self, H=None, output=None, shorten_names=False, key=True, talk_to_remote=False, sambaopts=None, credopts=None, versionopts=None, mode='self', partition=None, color_scheme=None, utf8=None, format=None, xdot=False): # We use the KCC libraries in readonly mode to get the # replication graph. lp = sambaopts.get_loadparm() creds = credopts.get_credentials(lp, fallback_machine=True) local_kcc, dsas = get_kcc_and_dsas(H, lp, creds) unix_now = local_kcc.unix_now partition = get_partition(local_kcc.samdb, partition) # nc_reps is an autovivifying dictionary of dictionaries of lists. # nc_reps[partition]['current' | 'needed'] is a list of # (dsa dn string, repsFromTo object) pairs. nc_reps = defaultdict(lambda: defaultdict(list)) guid_to_dnstr = {} # We run a new KCC for each DSA even if we aren't talking to # the remote, because after kcc.run (or kcc.list_dsas) the kcc # ends up in a messy state. for dsa_dn in dsas: kcc = KCC(unix_now, readonly=True) if talk_to_remote: res = local_kcc.samdb.search(dsa_dn, scope=SCOPE_BASE, attrs=["dNSHostName"]) dns_name = str(res[0]["dNSHostName"][0]) print("Attempting to contact ldap://%s (%s)" % (dns_name, dsa_dn), file=sys.stderr) try: kcc.load_samdb("ldap://%s" % dns_name, lp, creds) except KCCError as e: print("Could not contact ldap://%s (%s)" % (dns_name, e), file=sys.stderr) continue kcc.run(H, lp, creds) else: kcc.load_samdb(H, lp, creds) kcc.run(H, lp, creds, forced_local_dsa=dsa_dn) dsas_from_here = set(kcc.list_dsas()) if dsas != dsas_from_here: print("found extra DSAs:", file=sys.stderr) for dsa in (dsas_from_here - dsas): print(" %s" % dsa, file=sys.stderr) print("missing DSAs (known locally, not by %s):" % dsa_dn, file=sys.stderr) for dsa in (dsas - dsas_from_here): print(" %s" % dsa, file=sys.stderr) for remote_dn in dsas_from_here: if mode == 'others' and remote_dn == dsa_dn: continue elif mode == 'self' and remote_dn != dsa_dn: continue remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn) kcc.translate_ntdsconn(remote_dsa) guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn # get_reps_tables() returns two dictionaries mapping # dns to NCReplica objects c, n = remote_dsa.get_rep_tables() for part, rep in c.items(): if partition is None or part == partition: nc_reps[part]['current'].append((dsa_dn, rep)) for part, rep in n.items(): if partition is None or part == partition: nc_reps[part]['needed'].append((dsa_dn, rep)) all_edges = {'needed': {'to': [], 'from': []}, 'current': {'to': [], 'from': []}} short_partitions, long_partitions = get_partition_maps(local_kcc.samdb) for partname, part in nc_reps.items(): for state, edgelists in all_edges.items(): for dsa_dn, rep in part[state]: short_name = long_partitions.get(partname, partname) for r in rep.rep_repsFrom: edgelists['from'].append( (dsa_dn, guid_to_dnstr[str(r.source_dsa_obj_guid)], short_name)) for r in rep.rep_repsTo: edgelists['to'].append( (guid_to_dnstr[str(r.source_dsa_obj_guid)], dsa_dn, short_name)) # Here we have the set of edges. From now it is a matter of # interpretation and presentation. if self.calc_output_format(format, output) == 'distance': color_scheme = self.calc_distance_color_scheme(color_scheme, output) header_strings = { 'from': "RepsFrom objects for %s", 'to': "RepsTo objects for %s", } for state, edgelists in all_edges.items(): for direction, items in edgelists.items(): part_edges = defaultdict(list) for src, dest, part in items: part_edges[part].append((src, dest)) for part, edges in part_edges.items(): s = distance_matrix(None, edges, utf8=utf8, colour=color_scheme, shorten_names=shorten_names, generate_key=key, grouping_function=get_dnstr_site) s = "\n%s\n%s" % (header_strings[direction] % part, s) self.write(s, output) return edge_colours = [] edge_styles = [] dot_edges = [] dot_vertices = set() used_colours = {} key_set = set() for state, edgelist in all_edges.items(): for direction, items in edgelist.items(): for src, dest, part in items: colour = used_colours.setdefault((part), colour_hash((part, direction))) linestyle = 'dotted' if state == 'needed' else 'solid' arrow = 'open' if direction == 'to' else 'empty' dot_vertices.add(src) dot_vertices.add(dest) dot_edges.append((src, dest)) edge_colours.append(colour) style = 'style="%s"; arrowhead=%s' % (linestyle, arrow) edge_styles.append(style) key_set.add((part, 'reps' + direction.title(), colour, style)) key_items = [] if key: for part, direction, colour, linestyle in sorted(key_set): key_items.append((False, 'color="%s"; %s' % (colour, linestyle), "%s %s" % (part, direction))) key_items.append((False, 'style="dotted"; arrowhead="open"', "repsFromTo is needed")) key_items.append((False, 'style="solid"; arrowhead="open"', "repsFromTo currently exists")) s = dot_graph(dot_vertices, dot_edges, directed=True, edge_colors=edge_colours, edge_styles=edge_styles, shorten_names=shorten_names, key_items=key_items) if format == 'xdot': self.call_xdot(s, output) else: self.write(s, output) class NTDSConn(object): """Collects observation counts for NTDS connections, so we know whether all DSAs agree.""" def __init__(self, src, dest): self.observations = 0 self.src_attests = False self.dest_attests = False self.src = src self.dest = dest def attest(self, attester): self.observations += 1 if attester == self.src: self.src_attests = True if attester == self.dest: self.dest_attests = True class cmd_ntdsconn(GraphCommand): "Draw the NTDSConnection graph" takes_options = COMMON_OPTIONS + DOT_OPTIONS + [ Option("--importldif", help="graph from samba_kcc generated ldif", default=None), ] def import_ldif_db(self, ldif, lp): d = tempfile.mkdtemp(prefix='samba-tool-visualise') fn = os.path.join(d, 'imported.ldb') self._tmp_fn_to_delete = fn samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif) return fn def run(self, H=None, output=None, shorten_names=False, key=True, talk_to_remote=False, sambaopts=None, credopts=None, versionopts=None, color_scheme=None, utf8=None, format=None, importldif=None, xdot=False): lp = sambaopts.get_loadparm() if importldif is None: creds = credopts.get_credentials(lp, fallback_machine=True) else: creds = None H = self.import_ldif_db(importldif, lp) local_kcc, dsas = get_kcc_and_dsas(H, lp, creds) local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1] vertices = set() attested_edges = [] for dsa_dn in dsas: if talk_to_remote: res = local_kcc.samdb.search(dsa_dn, scope=SCOPE_BASE, attrs=["dNSHostName"]) dns_name = res[0]["dNSHostName"][0] try: samdb = self.get_db("ldap://%s" % dns_name, sambaopts, credopts) except LdbError as e: print("Could not contact ldap://%s (%s)" % (dns_name, e), file=sys.stderr) continue ntds_dn = samdb.get_dsServiceName() dn = samdb.domain_dn() else: samdb = self.get_db(H, sambaopts, credopts) ntds_dn = 'CN=NTDS Settings,' + dsa_dn dn = dsa_dn res = samdb.search(ntds_dn, scope=SCOPE_BASE, attrs=["msDS-isRODC"]) is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE' vertices.add((ntds_dn, 'RODC' if is_rodc else '')) # XXX we could also look at schedule res = samdb.search(dn, scope=SCOPE_SUBTREE, expression="(objectClass=nTDSConnection)", attrs=['fromServer'], # XXX can't be critical for ldif test # controls=["search_options:1:2"], controls=["search_options:0:2"], ) for msg in res: msgdn = str(msg.dn) dest_dn = msgdn[msgdn.index(',') + 1:] attested_edges.append((str(msg['fromServer'][0]), dest_dn, ntds_dn)) if importldif and H == self._tmp_fn_to_delete: os.remove(H) os.rmdir(os.path.dirname(H)) # now we overlay all the graphs and generate styles accordingly edges = {} for src, dest, attester in attested_edges: k = (src, dest) if k in edges: e = edges[k] else: e = NTDSConn(*k) edges[k] = e e.attest(attester) vertices, rodc_status = zip(*sorted(vertices)) if self.calc_output_format(format, output) == 'distance': color_scheme = self.calc_distance_color_scheme(color_scheme, output) colours = COLOUR_SETS[color_scheme] c_header = colours.get('header', '') c_reset = colours.get('reset', '') epilog = [] if 'RODC' in rodc_status: epilog.append('No outbound connections are expected from RODCs') if not talk_to_remote: # If we are not talking to remote servers, we list all # the connections. graph_edges = edges.keys() title = 'NTDS Connections known to %s' % local_dsa_dn else: # If we are talking to the remotes, there are # interesting cases we can discover. What matters most # is that the destination (i.e. owner) knowns about # the connection, but it would be worth noting if the # source doesn't. Another strange situation could be # when a DC thinks there is a connection elsewhere, # but the computers allegedly involved don't believe # it exists. # # With limited bandwidth in the table, we mark the # edges known to the destination, and note the other # cases in a list after the diagram. graph_edges = [] source_denies = [] dest_denies = [] both_deny = [] for e, conn in edges.items(): if conn.dest_attests: graph_edges.append(e) if not conn.src_attests: source_denies.append(e) elif conn.src_attests: dest_denies.append(e) else: both_deny.append(e) title = 'NTDS Connections known to each destination DC' if both_deny: epilog.append('The following connections are alleged by ' 'DCs other than the source and ' 'destination:\n') for e in both_deny: epilog.append(' %s -> %s\n' % e) if dest_denies: epilog.append('The following connections are alleged by ' 'DCs other than the destination but ' 'including the source:\n') for e in dest_denies: epilog.append(' %s -> %s\n' % e) if source_denies: epilog.append('The following connections ' '(included in the chart) ' 'are not known to the source DC:\n') for e in source_denies: epilog.append(' %s -> %s\n' % e) s = distance_matrix(vertices, graph_edges, utf8=utf8, colour=color_scheme, shorten_names=shorten_names, generate_key=key, grouping_function=get_dnstrlist_site, row_comments=rodc_status) epilog = ''.join(epilog) if epilog: epilog = '\n%sNOTES%s\n%s' % (c_header, c_reset, epilog) self.write('\n%s\n\n%s\n%s' % (title, s, epilog), output) return dot_edges = [] edge_colours = [] edge_styles = [] edge_labels = [] n_servers = len(dsas) for k, e in sorted(edges.items()): dot_edges.append(k) if e.observations == n_servers or not talk_to_remote: edge_colours.append('#000000') edge_styles.append('') elif e.dest_attests: edge_styles.append('') if e.src_attests: edge_colours.append('#0000ff') else: edge_colours.append('#cc00ff') elif e.src_attests: edge_colours.append('#ff0000') edge_styles.append('style=dashed') else: edge_colours.append('#ff0000') edge_styles.append('style=dotted') key_items = [] if key: key_items.append((False, 'color="#000000"', "NTDS Connection")) for colour, desc in (('#0000ff', "missing from some DCs"), ('#cc00ff', "missing from source DC")): if colour in edge_colours: key_items.append((False, 'color="%s"' % colour, desc)) for style, desc in (('style=dashed', "unknown to destination"), ('style=dotted', "unknown to source and destination")): if style in edge_styles: key_items.append((False, 'color="#ff0000; %s"' % style, desc)) if talk_to_remote: title = 'NTDS Connections' else: title = 'NTDS Connections known to %s' % local_dsa_dn s = dot_graph(sorted(vertices), dot_edges, directed=True, title=title, edge_colors=edge_colours, edge_labels=edge_labels, edge_styles=edge_styles, shorten_names=shorten_names, key_items=key_items) if format == 'xdot': self.call_xdot(s, output) else: self.write(s, output) class cmd_uptodateness(GraphCommand): """visualize uptodateness vectors""" takes_options = COMMON_OPTIONS + [ Option("-p", "--partition", help="restrict to this partition", default=None), Option("--max-digits", default=3, type=int, help="display this many digits of out-of-date-ness"), ] def run(self, H=None, output=None, shorten_names=False, key=True, talk_to_remote=False, sambaopts=None, credopts=None, versionopts=None, color_scheme=None, utf8=False, format=None, importldif=None, xdot=False, partition=None, max_digits=3): if not talk_to_remote: print("this won't work without talking to the remote servers " "(use -r)", file=self.outf) return # We use the KCC libraries in readonly mode to get the # replication graph. lp = sambaopts.get_loadparm() creds = credopts.get_credentials(lp, fallback_machine=True) local_kcc, dsas = get_kcc_and_dsas(H, lp, creds) self.samdb = local_kcc.samdb partition = get_partition(self.samdb, partition) short_partitions, long_partitions = get_partition_maps(self.samdb) color_scheme = self.calc_distance_color_scheme(color_scheme, output) for part_name, part_dn in short_partitions.items(): if partition not in (part_dn, None): continue # we aren't doing this partition utdv_edges = get_utdv_edges(local_kcc, dsas, part_dn, lp, creds) distances = get_utdv_distances(utdv_edges, dsas) max_distance = get_utdv_max_distance(distances) digits = min(max_digits, len(str(max_distance))) if digits < 1: digits = 1 c_scale = 10 ** digits s = full_matrix(distances, utf8=utf8, colour=color_scheme, shorten_names=shorten_names, generate_key=key, grouping_function=get_dnstr_site, colour_scale=c_scale, digits=digits, ylabel='DC', xlabel='out-of-date-ness') self.write('\n%s\n\n%s' % (part_name, s), output) class cmd_visualize(SuperCommand): """Produces graphical representations of Samba network state.""" subcommands = {} for k, v in globals().items(): if k.startswith('cmd_'): subcommands[k[4:]] = v()