samba-tool visualize for understanding AD DC behaviour
authorDouglas Bagnall <douglas.bagnall@catalyst.net.nz>
Wed, 9 Aug 2017 23:57:24 +0000 (11:57 +1200)
committerKarolin Seeger <kseeger@samba.org>
Sat, 13 Jan 2018 16:37:07 +0000 (17:37 +0100)
To work out what is happening in a replication graph, it is sometimes
helpful to use visualisations. We introduce a samba-tool subcommand to
write Graphviz dot output and generate text-based heatmaps of the
distance in hops between DCs.

There are two subcommands, two graphical modes, and (roughly) two modes of
operation with respect to the location of authority.

`samba-tool visualize ntdsconn` looks at NTDS Connections.
`samba-tool visualize reps` looks at repsTo and repsFrom objects.

In '--distance' mode (default), the distances between DCs are shown in
a matrix in the terminal. With '--color=yes', this is depicted as a
heatmap. With '--utf8' it is a lttle prettier.

In '--dot' mode, Graphviz dot output is generated. When viewed using
dot or xdot, this shows the network as a graph with DCs as vertices
and connections edges. Certain types of degenerate edges are shown in
different colours or line-styles.

Normally samba-tool talks to one database; with the '-r' (a.k.a.
'--talk-to-remote') option attempts are made to contact all the DCs
known to the first database. This is necessary to get sensible results
from `samba-tool visualize reps` because the repsFrom/To objects are
not replicated, and it can reveal replication issues in other modes.

Signed-off-by: Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet@samba.org>
python/samba/netcmd/main.py
python/samba/netcmd/visualize.py [new file with mode: 0644]
python/samba/tests/samba_tool/visualize.py [new file with mode: 0644]
python/samba/tests/samba_tool/visualize_drs.py [new file with mode: 0644]
source4/selftest/tests.py

index cc16e4a..7f94f89 100644 (file)
@@ -76,3 +76,4 @@ class cmd_sambatool(SuperCommand):
     subcommands["time"] = None
     subcommands["user"] = None
     subcommands["processes"] = None
+    subcommands["visualize"] = None
diff --git a/python/samba/netcmd/visualize.py b/python/samba/netcmd/visualize.py
new file mode 100644 (file)
index 0000000..473872a
--- /dev/null
@@ -0,0 +1,574 @@
+# Visualisation tools
+#
+# Copyright (C) Andrew Bartlett 2015, 2018
+#
+# by Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+from __future__ import print_function
+
+import os
+import sys
+from collections import defaultdict
+
+import tempfile
+import samba
+import samba.getopt as options
+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 ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
+import time
+from samba.kcc import KCC
+from samba.kcc.kcc_utils import KCCError
+
+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("--dot", help="Graphviz dot output", dest='format',
+           const='dot', action='store_const'),
+    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", help="use color (yes, no, auto)",
+           choices=['yes', 'no', 'auto']),
+    Option("--color-scheme", help=("use this colour scheme "
+                                   "(implies --color=yes)"),
+           choices=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'),
+]
+
+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
+    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 get_kcc_and_dsas(self, H, lp, creds):
+        """Get a readonly KCC object and the list of DSAs it knows about."""
+        unix_now = int(time.time())
+        kcc = KCC(unix_now, readonly=True)
+        kcc.load_samdb(H, lp, creds)
+
+        dsa_list = kcc.list_dsas()
+        dsas = set(dsa_list)
+        if len(dsas) != len(dsa_list):
+            print("There seem to be duplicate dsas", file=sys.stderr)
+
+        return kcc, dsas
+
+    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'
+        return format
+
+    def calc_distance_color_scheme(self, color, color_scheme, output):
+        """Heuristics to work out the colour scheme for distance matrices.
+        Returning None means no colour, otherwise it sould be a colour
+        from graph.COLOUR_SETS"""
+        if color == 'no':
+            return None
+
+        if color == 'auto':
+            if isinstance(output, str) and output != '-':
+                return None
+            if not hasattr(self.outf, 'isatty'):
+                # not a real file, perhaps cStringIO in testing
+                return None
+            if not self.outf.isatty():
+                return None
+
+        if color_scheme is None:
+            if '256color' in os.environ.get('TERM', ''):
+                return 'xterm-256color-heatmap'
+            return 'ansi'
+
+        return color_scheme
+
+
+def colour_hash(x):
+    """Generate a randomish but consistent darkish colour based on the
+    given object."""
+    from hashlib import md5
+    c = int(md5(str(x)).hexdigest()[:6], base=16) & 0x7f7f7f
+    return '#%06x' % c
+
+
+def get_partition_maps(samdb):
+    """Generate dictionaries mapping short partition names to the
+    appropriate DNs."""
+    base_dn = samdb.domain_dn()
+    short_to_long = {
+        "DOMAIN": base_dn,
+        "CONFIGURATION": str(samdb.get_config_basedn()),
+        "SCHEMA": "CN=Schema,%s" % samdb.get_config_basedn(),
+        "DNSDOMAIN": "DC=DomainDnsZones,%s" % base_dn,
+        "DNSFOREST": "DC=ForestDnsZones,%s" % base_dn
+    }
+    long_to_short = {v: k for k, v in short_to_long.iteritems()}
+    return short_to_long, long_to_short
+
+
+class cmd_reps(GraphCommand):
+    "repsFrom/repsTo from every DSA"
+
+    takes_options = COMMON_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=None, color_scheme=None,
+            utf8=None, format=None):
+        # 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 = self.get_kcc_and_dsas(H, lp, creds)
+        unix_now = local_kcc.unix_now
+
+        # Allow people to say "--partition=DOMAIN" rather than
+        # "--partition=DC=blah,DC=..."
+        short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
+        if partition is not None:
+            partition = short_partitions.get(partition.upper(), partition)
+            if partition not in long_partitions:
+                raise CommandError("unknown partition %s" % 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 = 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.iteritems():
+                    if partition is None or part == partition:
+                        nc_reps[part]['current'].append((dsa_dn, rep))
+                for part, rep in n.iteritems():
+                    if partition is None or part == partition:
+                        nc_reps[part]['needed'].append((dsa_dn, rep))
+
+        all_edges = {'needed':  {'to': [], 'from': []},
+                     'current': {'to': [], 'from': []}}
+
+        for partname, part in nc_reps.iteritems():
+            for state, edgelists in all_edges.iteritems():
+                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,
+                                                           color_scheme,
+                                                           output)
+            header_strings = {
+                'from': "RepsFrom objects for %s",
+                'to': "RepsTo objects for %s",
+            }
+            for state, edgelists in all_edges.iteritems():
+                for direction, items in edgelists.iteritems():
+                    part_edges = defaultdict(list)
+                    for src, dest, part in items:
+                        part_edges[part].append((src, dest))
+                    for part, edges in part_edges.iteritems():
+                        s = distance_matrix(None, edges,
+                                            utf8=utf8,
+                                            colour=color_scheme,
+                                            shorten_names=shorten_names,
+                                            generate_key=key)
+
+                        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.iteritems():
+            for direction, items in edgelist.iteritems():
+                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)
+
+        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"
+    def run(self, H=None, output=None, shorten_names=False,
+            key=True, talk_to_remote=False,
+            sambaopts=None, credopts=None, versionopts=None,
+            color=None, color_scheme=None,
+            utf8=None, format=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp, fallback_machine=True)
+        local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
+
+        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
+
+            vertices.add(ntds_dn)
+            # 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((msg['fromServer'][0],
+                                       dest_dn, ntds_dn))
+
+        # 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)
+
+        if self.calc_output_format(format, output) == 'distance':
+            color_scheme = self.calc_distance_color_scheme(color,
+                                                           color_scheme,
+                                                           output)
+            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' % dsa_dn
+                epilog = ''
+
+            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.iteritems():
+                    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'
+                epilog = []
+                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)
+                epilog = ''.join(epilog)
+
+            s = distance_matrix(sorted(vertices), graph_edges,
+                                utf8=utf8,
+                                colour=color_scheme,
+                                shorten_names=shorten_names,
+                                generate_key=key)
+            self.write('\n%s\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.iteritems()):
+            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' % 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)
+        self.write(s, output)
+
+
+class cmd_visualize(SuperCommand):
+    """Produces graphical representations of Samba network state"""
+    subcommands = {}
+
+    for k, v in globals().iteritems():
+        if k.startswith('cmd_'):
+            subcommands[k[4:]] = v()
diff --git a/python/samba/tests/samba_tool/visualize.py b/python/samba/tests/samba_tool/visualize.py
new file mode 100644 (file)
index 0000000..292d496
--- /dev/null
@@ -0,0 +1,466 @@
+# -*- coding: utf-8 -*-
+# Tests for samba-tool visualize
+# Copyright (C) Andrew Bartlett 2015, 2018
+#
+# by Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
+#
+# 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 <http://www.gnu.org/licenses/>.
+#
+"""Tests for samba-tool visualize ntdsconn using the test ldif
+topologies.
+
+We don't test samba-tool visualize reps here because repsTo and
+repsFrom are not replicated, and there are actual remote servers to
+query.
+"""
+
+import samba
+import os
+import re
+from samba.tests.samba_tool.base import SambaToolCmdTest
+from samba.kcc import ldif_import_export
+from samba.graph import COLOUR_SETS
+from samba.param import LoadParm
+
+MULTISITE_LDIF = os.path.join(os.environ['SRCDIR_ABS'],
+                              "testdata/ldif-utils-test-multisite.ldif")
+
+# UNCONNECTED_LDIF is a single site, unconnected 5DC database that was
+# created using samba-tool domain join in testenv.
+UNCONNECTED_LDIF = os.path.join(os.environ['SRCDIR_ABS'],
+                                "testdata/unconnected-intrasite.ldif")
+
+DOMAIN = "DC=ad,DC=samba,DC=example,DC=com"
+DN_TEMPLATE = "CN=%s,CN=Servers,CN=%s,CN=Sites,CN=Configuration," + DOMAIN
+
+MULTISITE_LDIF_DSAS = [
+    ("WIN01", "Default-First-Site-Name"),
+    ("WIN08", "Site-4"),
+    ("WIN07", "Site-4"),
+    ("WIN06", "Site-3"),
+    ("WIN09", "Site-5"),
+    ("WIN10", "Site-5"),
+    ("WIN02", "Site-2"),
+    ("WIN04", "Site-2"),
+    ("WIN03", "Site-2"),
+    ("WIN05", "Site-2"),
+]
+
+
+def samdb_from_ldif(ldif, tempdir, lp, dsa=None, tag=''):
+    if dsa is None:
+        dsa_name = 'default-DSA'
+    else:
+        dsa_name = dsa[:5]
+    dburl = os.path.join(tempdir,
+                         ("ldif-to-sambdb-%s-%s" %
+                          (tag, dsa_name)))
+    samdb = ldif_import_export.ldif_to_samdb(dburl, lp, ldif,
+                                             forced_local_dsa=dsa)
+    return (samdb, dburl)
+
+
+def collapse_space(s):
+    lines = []
+    for line in s.splitlines():
+        line = ' '.join(line.strip().split())
+        lines.append(line)
+    return '\n'.join(lines)
+
+
+class SambaToolVisualizeLdif(SambaToolCmdTest):
+    def setUp(self):
+        super(SambaToolVisualizeLdif, self).setUp()
+        self.lp = LoadParm()
+        self.samdb, self.dbfile = samdb_from_ldif(MULTISITE_LDIF,
+                                                  self.tempdir,
+                                                  self.lp)
+        self.dburl = 'tdb://' + self.dbfile
+
+    def tearDown(self):
+        self.remove_files(self.dbfile)
+        super(SambaToolVisualizeLdif, self).tearDown()
+
+    def remove_files(self, *files):
+        for f in files:
+            self.assertTrue(f.startswith(self.tempdir))
+            os.unlink(f)
+
+    def test_colour(self):
+        """Ensure the colour output is the same as the monochrome output
+        EXCEPT for the colours, of which the monochrome one should
+        know nothing."""
+        colour_re = re.compile('\033' r'\[[\d;]+m')
+        result, monochrome, err = self.runsubcmd("visualize", "ntdsconn",
+                                                 '-H', self.dburl,
+                                                 '--color=no', '-S')
+        self.assertCmdSuccess(result, monochrome, err)
+        self.assertFalse(colour_re.findall(monochrome))
+
+        colour_args = [['--color=yes']]
+        colour_args += [['--color-scheme', x] for x in COLOUR_SETS
+                        if x is not None]
+
+        for args in colour_args:
+            result, out, err = self.runsubcmd("visualize", "ntdsconn",
+                                              '-H', self.dburl,
+                                              '-S', *args)
+            self.assertCmdSuccess(result, out, err)
+            self.assertTrue(colour_re.search(out))
+            uncoloured = colour_re.sub('', out)
+
+            self.assertStringsEqual(monochrome, uncoloured, strip=True)
+
+    def test_output_file(self):
+        """Check that writing to a file works, with and without
+        --color=auto."""
+        # NOTE, we can't really test --color=auto works with a TTY.
+        colour_re = re.compile('\033' r'\[[\d;]+m')
+        result, expected, err = self.runsubcmd("visualize", "ntdsconn",
+                                               '-H', self.dburl,
+                                               '--color=auto', '-S')
+        self.assertCmdSuccess(result, expected, err)
+        # Not a TTY, so stdout output should be colourless
+        self.assertFalse(colour_re.search(expected))
+        expected = expected.strip()
+
+        color_auto_file = os.path.join(self.tempdir, 'color-auto')
+
+        result, out, err = self.runsubcmd("visualize", "ntdsconn",
+                                          '-H', self.dburl,
+                                          '--color=auto', '-S',
+                                          '-o', color_auto_file)
+        self.assertCmdSuccess(result, out, err)
+        # We wrote to file, so stdout should be empty
+        self.assertEqual(out, '')
+        f = open(color_auto_file)
+        color_auto = f.read()
+        f.close()
+        self.assertStringsEqual(color_auto, expected, strip=True)
+        self.remove_files(color_auto_file)
+
+        color_no_file = os.path.join(self.tempdir, 'color-no')
+        result, out, err = self.runsubcmd("visualize", "ntdsconn",
+                                          '-H', self.dburl,
+                                          '--color=no', '-S',
+                                          '-o', color_no_file)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEqual(out, '')
+        f = open(color_no_file)
+        color_no = f.read()
+        f.close()
+        self.remove_files(color_no_file)
+
+        self.assertStringsEqual(color_no, expected, strip=True)
+
+        color_yes_file = os.path.join(self.tempdir, 'color-no')
+        result, out, err = self.runsubcmd("visualize", "ntdsconn",
+                                          '-H', self.dburl,
+                                          '--color=yes', '-S',
+                                          '-o', color_yes_file)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEqual(out, '')
+        f = open(color_yes_file)
+        colour_yes = f.read()
+        f.close()
+        self.assertNotEqual(colour_yes.strip(), expected)
+
+        self.remove_files(color_yes_file)
+
+        # Try the magic filename "-", meaning stdout.
+        # This doesn't exercise the case when stdout is a TTY
+        for c, equal in [('no', True), ('auto', True), ('yes', False)]:
+            result, out, err = self.runsubcmd("visualize", "ntdsconn",
+                                              '-H', self.dburl,
+                                              '--color', c,
+                                              '-S', '-o', '-')
+            self.assertCmdSuccess(result, out, err)
+            self.assertEqual((out.strip() == expected), equal)
+
+    def test_utf8(self):
+        """Ensure that --utf8 adds at least some expected utf-8, and that it
+        isn't there without --utf8."""
+        result, utf8, err = self.runsubcmd("visualize", "ntdsconn",
+                                           '-H', self.dburl,
+                                           '--color=no', '-S', '--utf8')
+        self.assertCmdSuccess(result, utf8, err)
+
+        result, ascii, err = self.runsubcmd("visualize", "ntdsconn",
+                                            '-H', self.dburl,
+                                            '--color=no', '-S')
+        self.assertCmdSuccess(result, ascii, err)
+        for c in ('│', '─', '╭'):
+            self.assertTrue(c in utf8, 'UTF8 should contain %s' % c)
+            self.assertTrue(c not in ascii, 'ASCII should not contain %s' % c)
+
+    def test_forced_local_dsa(self):
+        # the forced_local_dsa shouldn't make any difference
+        result, target, err = self.runsubcmd("visualize", "ntdsconn",
+                                             '-H', self.dburl,
+                                             '--color=no', '-S')
+        self.assertCmdSuccess(result, target, err)
+        files = []
+        for cn, site in MULTISITE_LDIF_DSAS:
+            dsa = DN_TEMPLATE % (cn, site)
+            samdb, dbfile = samdb_from_ldif(MULTISITE_LDIF,
+                                            self.tempdir,
+                                            self.lp, dsa,
+                                            tag=cn)
+
+            result, out, err = self.runsubcmd("visualize", "ntdsconn",
+                                              '-H', 'tdb://' + dbfile,
+                                              '--color=no', '-S')
+            self.assertCmdSuccess(result, out, err)
+            self.assertStringsEqual(target, out)
+            files.append(dbfile)
+        self.remove_files(*files)
+
+    def test_short_names(self):
+        """Ensure the colour ones are the same as the monochrome ones EXCEPT
+        for the colours, of which the monochrome one should know nothing"""
+        result, short, err = self.runsubcmd("visualize", "ntdsconn",
+                                            '-H', self.dburl,
+                                            '--color=no', '-S', '--no-key')
+        self.assertCmdSuccess(result, short, err)
+        result, long, err = self.runsubcmd("visualize", "ntdsconn",
+                                           '-H', self.dburl,
+                                           '--color=no', '--no-key')
+        self.assertCmdSuccess(result, long, err)
+
+        lines = short.split('\n')
+        replacements = []
+        key_lines = ['']
+        short_without_key = []
+        for line in lines:
+            m = re.match(r"'(.{1,2})' stands for '(.+)'", line)
+            if m:
+                a, b = m.groups()
+                replacements.append((len(a), a, b))
+                key_lines.append(line)
+            else:
+                short_without_key.append(line)
+
+        short = '\n'.join(short_without_key)
+        # we need to replace longest strings first
+        replacements.sort(reverse=True)
+        short2long = short
+        # we don't want to shorten the DC name in the header line.
+        long_header, long2short = long.strip().split('\n', 1)
+        for _, a, b in replacements:
+            short2long = short2long.replace(a, b)
+            long2short = long2short.replace(b, a)
+
+        long2short = '%s\n%s' % (long_header, long2short)
+
+        # The white space is going to be all wacky, so lets squish it down
+        short2long = collapse_space(short2long)
+        long2short = collapse_space(long2short)
+        short = collapse_space(short)
+        long = collapse_space(long)
+
+        self.assertStringsEqual(short2long, long, strip=True)
+        self.assertStringsEqual(short, long2short, strip=True)
+
+    def test_disconnected_ldif_with_key(self):
+        """Test that the 'unconnected' ldif shows up and exactly matches the
+        expected output."""
+        # This is not truly a disconnected graph because the
+        # vampre/local/promoted DCs are in there and they have
+        # relationships, and SERVER2 and SERVER3 for some reason refer
+        # to them.
+
+        samdb, dbfile = samdb_from_ldif(UNCONNECTED_LDIF,
+                                        self.tempdir,
+                                        self.lp, tag='disconnected')
+        dburl = 'tdb://' + dbfile
+        print dbfile
+        result, output, err = self.runsubcmd("visualize", "ntdsconn",
+                                             '-H', dburl,
+                                             '--color=no', '-S')
+        self.remove_files(dbfile)
+        self.assertCmdSuccess(result, output, err)
+        self.assertStringsEqual(output,
+                                EXPECTED_DISTANCE_GRAPH_WITH_KEY)
+
+    def test_dot_ntdsconn(self):
+        """Graphviz NTDS Connection output"""
+        result, dot, err = self.runsubcmd("visualize", "ntdsconn",
+                                          '-H', self.dburl,
+                                          '--color=no', '-S', '--dot',
+                                          '--no-key')
+        self.assertCmdSuccess(result, dot, err)
+        self.assertStringsEqual(EXPECTED_DOT_MULTISITE_NO_KEY, dot)
+
+    def test_dot_ntdsconn_disconnected(self):
+        """Graphviz NTDS Connection output from disconnected graph"""
+        samdb, dbfile = samdb_from_ldif(UNCONNECTED_LDIF,
+                                        self.tempdir,
+                                        self.lp, tag='disconnected')
+
+        result, dot, err = self.runsubcmd("visualize", "ntdsconn",
+                                          '-H', 'tdb://' + dbfile,
+                                          '--color=no', '-S', '--dot',
+                                          '-o', '-')
+        self.assertCmdSuccess(result, dot, err)
+        self.remove_files(dbfile)
+        print dot
+
+        self.assertStringsEqual(EXPECTED_DOT_NTDSCONN_DISCONNECTED, dot,
+                                strip=True)
+
+    def test_dot_ntdsconn_disconnected_to_file(self):
+        """Graphviz NTDS Connection output into a file"""
+        samdb, dbfile = samdb_from_ldif(UNCONNECTED_LDIF,
+                                        self.tempdir,
+                                        self.lp, tag='disconnected')
+
+        dot_file = os.path.join(self.tempdir, 'dotfile')
+
+        result, dot, err = self.runsubcmd("visualize", "ntdsconn",
+                                          '-H', 'tdb://' + dbfile,
+                                          '--color=no', '-S', '--dot',
+                                          '-o', dot_file)
+        self.assertCmdSuccess(result, dot, err)
+        f = open(dot_file)
+        dot = f.read()
+        f.close()
+        self.assertStringsEqual(EXPECTED_DOT_NTDSCONN_DISCONNECTED, dot)
+
+        self.remove_files(dbfile, dot_file)
+        print dot
+
+EXPECTED_DOT_MULTISITE_NO_KEY = r"""/* generated by samba */
+digraph A_samba_tool_production {
+label="NTDS Connections known to CN=WIN07,CN=Servers,CN=Site-4,CN=Sites,CN=Configuration,DC=ad,DC=samba,DC=example,DC=com";
+fontsize=10;
+
+node[fontname=Helvetica; fontsize=10];
+
+"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n...";
+"CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n...";
+"CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n...";
+"CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n...";
+"CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n...";
+"CN=NTDS Settings,\nCN=WIN06,\nCN=Servers,\nCN=Site-3,\n...";
+"CN=NTDS Settings,\nCN=WIN07,\nCN=Servers,\nCN=Site-4,\n...";
+"CN=NTDS Settings,\nCN=WIN08,\nCN=Servers,\nCN=Site-4,\n...";
+"CN=NTDS Settings,\nCN=WIN09,\nCN=Servers,\nCN=Site-5,\n...";
+"CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n...";
+"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN06,\nCN=Servers,\nCN=Site-3,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN07,\nCN=Servers,\nCN=Site-4,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN08,\nCN=Servers,\nCN=Site-4,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN07,\nCN=Servers,\nCN=Site-4,\n..." -> "CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN09,\nCN=Servers,\nCN=Site-5,\n..." -> "CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..." -> "CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..." -> "CN=NTDS Settings,\nCN=WIN09,\nCN=Servers,\nCN=Site-5,\n..." [color="#000000", ];
+}
+
+"""
+
+
+EXPECTED_DOT_NTDSCONN_DISCONNECTED = r"""/* generated by samba */
+digraph A_samba_tool_production {
+label="NTDS Connections known to CN=SERVER2,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com";
+fontsize=10;
+
+node[fontname=Helvetica; fontsize=10];
+
+"CN=NTDS Settings,\nCN=CLIENT,\n...";
+"CN=NTDS Settings,\nCN=LOCALDC,\n...";
+"CN=NTDS Settings,\nCN=PROMOTEDVDC,\n...";
+"CN=NTDS Settings,\nCN=SERVER1,\n...";
+"CN=NTDS Settings,\nCN=SERVER2,\n...";
+"CN=NTDS Settings,\nCN=SERVER3,\n...";
+"CN=NTDS Settings,\nCN=SERVER4,\n...";
+"CN=NTDS Settings,\nCN=SERVER5,\n...";
+"CN=NTDS Settings,\nCN=LOCALDC,\n..." -> "CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..." -> "CN=NTDS Settings,\nCN=LOCALDC,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=SERVER2,\n..." -> "CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..." [color="#000000", ];
+"CN=NTDS Settings,\nCN=SERVER3,\n..." -> "CN=NTDS Settings,\nCN=LOCALDC,\n..." [color="#000000", ];
+subgraph cluster_key {
+label="Key";
+subgraph cluster_key_nodes {
+label="";
+color = "invis";
+
+}
+subgraph cluster_key_edges {
+label="";
+color = "invis";
+subgraph cluster_key_0_ {
+key_0_e1[label=src; color="#000000"; group="key_0__g"]
+key_0_e2[label=dest; color="#000000"; group="key_0__g"]
+key_0_e1 -> key_0_e2 [constraint = false; color="#000000"]
+key_0__label[shape=plaintext; style=solid; width=2.000000; label="NTDS Connection\r"]
+}
+{key_0__label}
+}
+
+elision0[shape=plaintext; style=solid; label="\“...”  means  “CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com”\r"]
+
+}
+"CN=NTDS Settings,\nCN=CLIENT,\n..." -> key_0__label [style=invis];
+"CN=NTDS Settings,\nCN=LOCALDC,\n..." -> key_0__label [style=invis];
+"CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..." -> key_0__label [style=invis];
+"CN=NTDS Settings,\nCN=SERVER1,\n..." -> key_0__label [style=invis];
+"CN=NTDS Settings,\nCN=SERVER2,\n..." -> key_0__label [style=invis];
+"CN=NTDS Settings,\nCN=SERVER3,\n..." -> key_0__label [style=invis];
+"CN=NTDS Settings,\nCN=SERVER4,\n..." -> key_0__label [style=invis];
+"CN=NTDS Settings,\nCN=SERVER5,\n..." -> key_0__label [style=invis]
+key_0__label -> elision0 [style=invis; weight=9]
+
+}
+"""
+
+EXPECTED_DISTANCE_GRAPH_WITH_KEY = """
+NTDS Connections known to CN=SERVER2,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com
+                            destination
+                  ,-------- *,CN=CLIENT+
+                  |,------- *,CN=LOCALDC+
+                  ||,------ *,CN=PROMOTEDVDC+
+                  |||,----- *,CN=SERVER1+
+                  ||||,---- *,CN=SERVER2+
+                  |||||,--- *,CN=SERVER3+
+                  ||||||,-- *,CN=SERVER4+
+           source |||||||,- *,CN=SERVER5+
+     *,CN=CLIENT+ 0-------
+    *,CN=LOCALDC+ -01-----
+*,CN=PROMOTEDVDC+ -10-----
+    *,CN=SERVER1+ ---0----
+    *,CN=SERVER2+ -21-0---
+    *,CN=SERVER3+ -12--0--
+    *,CN=SERVER4+ ------0-
+    *,CN=SERVER5+ -------0
+
+'*' stands for 'CN=NTDS Settings'
+'+' stands for ',CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com'
+
+Data can get from source to destination in the indicated number of steps.
+0 means zero steps (it is the same DC)
+1 means a direct link
+2 means a transitive link involving two steps (i.e. one intermediate DC)
+- means there is no connection, even through other DCs
+
+"""
diff --git a/python/samba/tests/samba_tool/visualize_drs.py b/python/samba/tests/samba_tool/visualize_drs.py
new file mode 100644 (file)
index 0000000..7da0a4b
--- /dev/null
@@ -0,0 +1,110 @@
+# Originally based on tests for samba.kcc.ldif_import_export.
+# Copyright (C) Andrew Bartlett 2015, 2018
+#
+# by Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
+#
+# 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 <http://www.gnu.org/licenses/>.
+#
+
+"""Tests for samba-tool visualize using the vampire DC and promoted DC
+environments. We can't assert much about what state they are in, so we
+mainly check for cmmand failure.
+"""
+
+import os
+from samba.tests.samba_tool.base import SambaToolCmdTest
+
+ENV_DSAS = {
+    'promoted_dc': ['CN=PROMOTEDVDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com',
+                    'CN=LOCALDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com'],
+    'vampire_dc': ['CN=LOCALDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com',
+                   'CN=LOCALVAMPIREDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com'],
+}
+
+
+class SambaToolVisualizeDrsTest(SambaToolCmdTest):
+    def setUp(self):
+        super(SambaToolVisualizeDrsTest, self).setUp()
+
+    def test_ntdsconn(self):
+        server = "ldap://%s" % os.environ["SERVER"]
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        (result, out, err) = self.runsubcmd("visualize", "ntdsconn",
+                                            '-H', server,
+                                            '-U', creds,
+                                            '--color=no', '-S')
+        self.assertCmdSuccess(result, out, err)
+
+    def test_ntdsconn_remote(self):
+        server = "ldap://%s" % os.environ["SERVER"]
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        (result, out, err) = self.runsubcmd("visualize", "ntdsconn",
+                                            '-H', server,
+                                            '-U', creds,
+                                            '--color=no', '-S', '-r')
+        self.assertCmdSuccess(result, out, err)
+
+    def test_reps(self):
+        server = "ldap://%s" % os.environ["SERVER"]
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        (result, out, err) = self.runsubcmd("visualize", "reps",
+                                            '-H', server,
+                                            '-U', creds,
+                                            '--color=no', '-S')
+        self.assertCmdSuccess(result, out, err)
+
+    def test_reps_remote(self):
+        server = "ldap://%s" % os.environ["SERVER"]
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        (result, out, err) = self.runsubcmd("visualize", "reps",
+                                            '-H', server,
+                                            '-U', creds,
+                                            '--color=no', '-S', '-r')
+        self.assertCmdSuccess(result, out, err)
+
+    def test_ntdsconn_dot(self):
+        server = "ldap://%s" % os.environ["SERVER"]
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        (result, out, err) = self.runsubcmd("visualize", "ntdsconn",
+                                            '-H', server,
+                                            '-U', creds, '--dot',
+                                            '--color=no', '-S')
+        self.assertCmdSuccess(result, out, err)
+
+    def test_ntdsconn_remote_dot(self):
+        server = "ldap://%s" % os.environ["SERVER"]
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        (result, out, err) = self.runsubcmd("visualize", "ntdsconn",
+                                            '-H', server,
+                                            '-U', creds, '--dot',
+                                            '--color=no', '-S', '-r')
+        self.assertCmdSuccess(result, out, err)
+
+    def test_reps_dot(self):
+        server = "ldap://%s" % os.environ["SERVER"]
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        (result, out, err) = self.runsubcmd("visualize", "reps",
+                                            '-H', server,
+                                            '-U', creds, '--dot',
+                                            '--color=no', '-S')
+        self.assertCmdSuccess(result, out, err)
+
+    def test_reps_remote_dot(self):
+        server = "ldap://%s" % os.environ["SERVER"]
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        (result, out, err) = self.runsubcmd("visualize", "reps",
+                                            '-H', server,
+                                            '-U', creds, '--dot',
+                                            '--color=no', '-S', '-r')
+        self.assertCmdSuccess(result, out, err)
index 73bdce6..f5ff906 100755 (executable)
@@ -584,6 +584,9 @@ planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.dcerpc.srvsvc")
 planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.samba_tool.timecmd")
 planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.samba_tool.join")
 
+planpythontestsuite("none", "samba.tests.samba_tool.visualize")
+
+
 # test fsmo show
 for env in ["ad_dc_ntvfs", "fl2000dc", "fl2003dc", "fl2008r2dc"]:
     planpythontestsuite(env + ":local", "samba.tests.samba_tool.fsmo")
@@ -936,7 +939,7 @@ for env in ["ad_dc_ntvfs", "s4member", "rodc", "promoted_dc", "ad_dc", "ad_membe
 #
 # KDC Tests
 #
-    
+
 # This test is for users cached at the RODC
 plansmbtorture4testsuite('krb5.kdc', "rodc", ['ncacn_np:$SERVER_IP', "-k", "yes", '-Utestdenied%$PASSWORD',
                                               '--workgroup=$DOMAIN', '--realm=$REALM',
@@ -984,6 +987,7 @@ for env in [
                                 },
                            extra_path=[os.path.join(srcdir(), "samba/python"), ]
                            )
+    planpythontestsuite(env, "samba.tests.samba_tool.visualize_drs")
 
 for env in [ "simpleserver", "fileserver", "nt4_dc", "ad_dc", "ad_dc_ntvfs", "ad_member"]:
     planoldpythontestsuite(env, "netlogonsvc",