From: Douglas Bagnall Date: Wed, 9 Aug 2017 23:57:24 +0000 (+1200) Subject: samba-tool visualize for understanding AD DC behaviour X-Git-Url: http://git.samba.org/?p=metze%2Fsamba%2Fwip.git;a=commitdiff_plain;h=c6294c3c7b6c97f15daad7d463bda267726245c7 samba-tool visualize for understanding AD DC behaviour 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 Reviewed-by: Andrew Bartlett --- diff --git a/python/samba/netcmd/main.py b/python/samba/netcmd/main.py index cc16e4a3fe5d..7f94f8978979 100644 --- a/python/samba/netcmd/main.py +++ b/python/samba/netcmd/main.py @@ -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 index 000000000000..473872a7d728 --- /dev/null +++ b/python/samba/netcmd/visualize.py @@ -0,0 +1,574 @@ +# 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 . + +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 index 000000000000..292d4961f45a --- /dev/null +++ b/python/samba/tests/samba_tool/visualize.py @@ -0,0 +1,466 @@ +# -*- coding: utf-8 -*- +# Tests for samba-tool visualize +# 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 . +# +"""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 index 000000000000..7da0a4b10839 --- /dev/null +++ b/python/samba/tests/samba_tool/visualize_drs.py @@ -0,0 +1,110 @@ +# Originally based on tests for samba.kcc.ldif_import_export. +# 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 . +# + +"""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) diff --git a/source4/selftest/tests.py b/source4/selftest/tests.py index 73bdce61873b..f5ff906a4bcd 100755 --- a/source4/selftest/tests.py +++ b/source4/selftest/tests.py @@ -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",