3 # Copyright (C) Andrew Bartlett 2015, 2018
5 # by Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
7 # This program is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 from __future__ import print_function
24 from collections import defaultdict
28 import samba.getopt as options
29 from samba import dsdb
30 from samba import nttime2unix
31 from samba.netcmd import Command, SuperCommand, CommandError, Option
32 from samba.samdb import SamDB
33 from samba.graph import dot_graph
34 from samba.graph import distance_matrix, COLOUR_SETS
35 from samba.graph import full_matrix
36 from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
39 from samba.kcc import KCC, ldif_import_export
40 from samba.kcc.kcc_utils import KCCError
41 from samba.compat import text_type
44 Option("-H", "--URL", help="LDB URL for database or target server",
45 type=str, metavar="URL", dest="H"),
46 Option("-o", "--output", help="write here (default stdout)",
47 type=str, metavar="FILE", default=None),
48 Option("--distance", help="Distance matrix graph output (default)",
49 dest='format', const='distance', action='store_const'),
50 Option("--utf8", help="Use utf-8 Unicode characters",
52 Option("--color", help="use color (yes, no, auto)",
53 choices=['yes', 'no', 'auto']),
54 Option("--color-scheme", help=("use this colour scheme "
55 "(implies --color=yes)"),
56 choices=list(COLOUR_SETS.keys())),
57 Option("-S", "--shorten-names",
58 help="don't print long common suffixes",
59 action='store_true', default=False),
60 Option("-r", "--talk-to-remote", help="query other DCs' databases",
61 action='store_true', default=False),
62 Option("--no-key", help="omit the explanatory key",
63 action='store_false', default=True, dest='key'),
67 Option("--dot", help="Graphviz dot output", dest='format',
68 const='dot', action='store_const'),
69 Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
70 const='xdot', action='store_const'),
73 TEMP_FILE = '__temp__'
76 class GraphCommand(Command):
77 """Base class for graphing commands"""
79 synopsis = "%prog [options]"
80 takes_optiongroups = {
81 "sambaopts": options.SambaOptions,
82 "versionopts": options.VersionOptions,
83 "credopts": options.CredentialsOptions,
85 takes_options = COMMON_OPTIONS + DOT_OPTIONS
88 def get_db(self, H, sambaopts, credopts):
89 lp = sambaopts.get_loadparm()
90 creds = credopts.get_credentials(lp, fallback_machine=True)
91 samdb = SamDB(url=H, credentials=creds, lp=lp)
94 def get_kcc_and_dsas(self, H, lp, creds):
95 """Get a readonly KCC object and the list of DSAs it knows about."""
96 unix_now = int(time.time())
97 kcc = KCC(unix_now, readonly=True)
98 kcc.load_samdb(H, lp, creds)
100 dsa_list = kcc.list_dsas()
102 if len(dsas) != len(dsa_list):
103 print("There seem to be duplicate dsas", file=sys.stderr)
107 def write(self, s, fn=None, suffix='.dot'):
108 """Decide whether we're dealing with a filename, a tempfile, or
109 stdout, and write accordingly.
111 :param s: the string to write
112 :param fn: a destination
113 :param suffix: suffix, if destination is a tempfile
115 If fn is None or "-", write to stdout.
116 If fn is visualize.TEMP_FILE, write to a temporary file
117 Otherwise fn should be a filename to write to.
119 if fn is None or fn == '-':
120 # we're just using stdout (a.k.a self.outf)
121 print(s, file=self.outf)
125 fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
136 def calc_output_format(self, format, output):
137 """Heuristics to work out what output format was wanted."""
139 # They told us nothing! We have to work it out for ourselves.
140 if output and output.lower().endswith('.dot'):
150 def call_xdot(self, s, output):
152 fn = self.write(s, TEMP_FILE)
154 fn = self.write(s, output)
155 xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
156 subprocess.call([xdot, fn])
159 def calc_distance_color_scheme(self, color, color_scheme, output):
160 """Heuristics to work out the colour scheme for distance matrices.
161 Returning None means no colour, otherwise it sould be a colour
162 from graph.COLOUR_SETS"""
167 if isinstance(output, str) and output != '-':
169 if not hasattr(self.outf, 'isatty'):
170 # not a real file, perhaps cStringIO in testing
172 if not self.outf.isatty():
175 if color_scheme is None:
176 if '256color' in os.environ.get('TERM', ''):
177 return 'xterm-256color-heatmap'
183 def get_dnstr_site(dn):
184 """Helper function for sorting and grouping DNs by site, if
186 m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
189 # Oh well, let it sort by DN
193 def get_dnstrlist_site(t):
194 """Helper function for sorting and grouping lists of (DN, ...) tuples
195 by site, if possible."""
196 return get_dnstr_site(t[0])
200 """Generate a randomish but consistent darkish colour based on the
202 from hashlib import md5
204 if isinstance(tmp_str, text_type):
205 tmp_str = tmp_str.encode('utf8')
206 c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
210 def get_partition_maps(samdb):
211 """Generate dictionaries mapping short partition names to the
213 base_dn = samdb.domain_dn()
216 "CONFIGURATION": str(samdb.get_config_basedn()),
217 "SCHEMA": "CN=Schema,%s" % samdb.get_config_basedn(),
218 "DNSDOMAIN": "DC=DomainDnsZones,%s" % base_dn,
219 "DNSFOREST": "DC=ForestDnsZones,%s" % base_dn
223 for s, l in short_to_long.items():
226 return short_to_long, long_to_short
229 def get_partition(samdb, part):
230 # Allow people to say "--partition=DOMAIN" rather than
231 # "--partition=DC=blah,DC=..."
233 short_partitions, long_partitions = get_partition_maps(samdb)
234 part = short_partitions.get(part.upper(), part)
235 if part not in long_partitions:
236 raise CommandError("unknown partition %s" % part)
240 class cmd_reps(GraphCommand):
241 "repsFrom/repsTo from every DSA"
243 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
244 Option("-p", "--partition", help="restrict to this partition",
248 def run(self, H=None, output=None, shorten_names=False,
249 key=True, talk_to_remote=False,
250 sambaopts=None, credopts=None, versionopts=None,
251 mode='self', partition=None, color=None, color_scheme=None,
252 utf8=None, format=None, xdot=False):
253 # We use the KCC libraries in readonly mode to get the
255 lp = sambaopts.get_loadparm()
256 creds = credopts.get_credentials(lp, fallback_machine=True)
257 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
258 unix_now = local_kcc.unix_now
260 partition = get_partition(local_kcc.samdb, partition)
262 # nc_reps is an autovivifying dictionary of dictionaries of lists.
263 # nc_reps[partition]['current' | 'needed'] is a list of
264 # (dsa dn string, repsFromTo object) pairs.
265 nc_reps = defaultdict(lambda: defaultdict(list))
269 # We run a new KCC for each DSA even if we aren't talking to
270 # the remote, because after kcc.run (or kcc.list_dsas) the kcc
271 # ends up in a messy state.
273 kcc = KCC(unix_now, readonly=True)
275 res = local_kcc.samdb.search(dsa_dn,
277 attrs=["dNSHostName"])
278 dns_name = res[0]["dNSHostName"][0]
279 print("Attempting to contact ldap://%s (%s)" %
283 kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
284 except KCCError as e:
285 print("Could not contact ldap://%s (%s)" % (dns_name, e),
289 kcc.run(H, lp, creds)
291 kcc.load_samdb(H, lp, creds)
292 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
294 dsas_from_here = set(kcc.list_dsas())
295 if dsas != dsas_from_here:
296 print("found extra DSAs:", file=sys.stderr)
297 for dsa in (dsas_from_here - dsas):
298 print(" %s" % dsa, file=sys.stderr)
299 print("missing DSAs (known locally, not by %s):" % dsa_dn,
301 for dsa in (dsas - dsas_from_here):
302 print(" %s" % dsa, file=sys.stderr)
304 for remote_dn in dsas_from_here:
305 if mode == 'others' and remote_dn == dsa_dn:
307 elif mode == 'self' and remote_dn != dsa_dn:
310 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
311 kcc.translate_ntdsconn(remote_dsa)
312 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
313 # get_reps_tables() returns two dictionaries mapping
314 # dns to NCReplica objects
315 c, n = remote_dsa.get_rep_tables()
316 for part, rep in c.items():
317 if partition is None or part == partition:
318 nc_reps[part]['current'].append((dsa_dn, rep))
319 for part, rep in n.items():
320 if partition is None or part == partition:
321 nc_reps[part]['needed'].append((dsa_dn, rep))
323 all_edges = {'needed': {'to': [], 'from': []},
324 'current': {'to': [], 'from': []}}
326 short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
328 for partname, part in nc_reps.items():
329 for state, edgelists in all_edges.items():
330 for dsa_dn, rep in part[state]:
331 short_name = long_partitions.get(partname, partname)
332 for r in rep.rep_repsFrom:
333 edgelists['from'].append(
335 guid_to_dnstr[str(r.source_dsa_obj_guid)],
337 for r in rep.rep_repsTo:
338 edgelists['to'].append(
339 (guid_to_dnstr[str(r.source_dsa_obj_guid)],
343 # Here we have the set of edges. From now it is a matter of
344 # interpretation and presentation.
346 if self.calc_output_format(format, output) == 'distance':
347 color_scheme = self.calc_distance_color_scheme(color,
351 'from': "RepsFrom objects for %s",
352 'to': "RepsTo objects for %s",
354 for state, edgelists in all_edges.items():
355 for direction, items in edgelists.items():
356 part_edges = defaultdict(list)
357 for src, dest, part in items:
358 part_edges[part].append((src, dest))
359 for part, edges in part_edges.items():
360 s = distance_matrix(None, edges,
363 shorten_names=shorten_names,
365 grouping_function=get_dnstr_site)
367 s = "\n%s\n%s" % (header_strings[direction] % part, s)
368 self.write(s, output)
377 for state, edgelist in all_edges.items():
378 for direction, items in edgelist.items():
379 for src, dest, part in items:
380 colour = used_colours.setdefault((part),
383 linestyle = 'dotted' if state == 'needed' else 'solid'
384 arrow = 'open' if direction == 'to' else 'empty'
385 dot_vertices.add(src)
386 dot_vertices.add(dest)
387 dot_edges.append((src, dest))
388 edge_colours.append(colour)
389 style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
390 edge_styles.append(style)
391 key_set.add((part, 'reps' + direction.title(),
396 for part, direction, colour, linestyle in sorted(key_set):
397 key_items.append((False,
398 'color="%s"; %s' % (colour, linestyle),
399 "%s %s" % (part, direction)))
400 key_items.append((False,
401 'style="dotted"; arrowhead="open"',
402 "repsFromTo is needed"))
403 key_items.append((False,
404 'style="solid"; arrowhead="open"',
405 "repsFromTo currently exists"))
407 s = dot_graph(dot_vertices, dot_edges,
409 edge_colors=edge_colours,
410 edge_styles=edge_styles,
411 shorten_names=shorten_names,
415 self.call_xdot(s, output)
417 self.write(s, output)
420 class NTDSConn(object):
421 """Collects observation counts for NTDS connections, so we know
422 whether all DSAs agree."""
423 def __init__(self, src, dest):
424 self.observations = 0
425 self.src_attests = False
426 self.dest_attests = False
430 def attest(self, attester):
431 self.observations += 1
432 if attester == self.src:
433 self.src_attests = True
434 if attester == self.dest:
435 self.dest_attests = True
438 class cmd_ntdsconn(GraphCommand):
439 "Draw the NTDSConnection graph"
440 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
441 Option("--importldif", help="graph from samba_kcc generated ldif",
445 def import_ldif_db(self, ldif, lp):
446 d = tempfile.mkdtemp(prefix='samba-tool-visualise')
447 fn = os.path.join(d, 'imported.ldb')
448 self._tmp_fn_to_delete = fn
449 samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
452 def run(self, H=None, output=None, shorten_names=False,
453 key=True, talk_to_remote=False,
454 sambaopts=None, credopts=None, versionopts=None,
455 color=None, color_scheme=None,
456 utf8=None, format=None, importldif=None,
459 lp = sambaopts.get_loadparm()
460 if importldif is None:
461 creds = credopts.get_credentials(lp, fallback_machine=True)
464 H = self.import_ldif_db(importldif, lp)
466 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
467 local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
472 res = local_kcc.samdb.search(dsa_dn,
474 attrs=["dNSHostName"])
475 dns_name = res[0]["dNSHostName"][0]
477 samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
479 except LdbError as e:
480 print("Could not contact ldap://%s (%s)" % (dns_name, e),
484 ntds_dn = samdb.get_dsServiceName()
485 dn = samdb.domain_dn()
487 samdb = self.get_db(H, sambaopts, credopts)
488 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
491 res = samdb.search(ntds_dn,
493 attrs=["msDS-isRODC"])
495 is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
497 vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
498 # XXX we could also look at schedule
499 res = samdb.search(dn,
501 expression="(objectClass=nTDSConnection)",
502 attrs=['fromServer'],
503 # XXX can't be critical for ldif test
504 #controls=["search_options:1:2"],
505 controls=["search_options:0:2"],
510 dest_dn = msgdn[msgdn.index(',') + 1:]
511 attested_edges.append((msg['fromServer'][0],
514 if importldif and H == self._tmp_fn_to_delete:
516 os.rmdir(os.path.dirname(H))
518 # now we overlay all the graphs and generate styles accordingly
520 for src, dest, attester in attested_edges:
529 vertices, rodc_status = zip(*sorted(vertices))
531 if self.calc_output_format(format, output) == 'distance':
532 color_scheme = self.calc_distance_color_scheme(color,
535 colours = COLOUR_SETS[color_scheme]
536 c_header = colours.get('header', '')
537 c_reset = colours.get('reset', '')
540 if 'RODC' in rodc_status:
541 epilog.append('No outbound connections are expected from RODCs')
543 if not talk_to_remote:
544 # If we are not talking to remote servers, we list all
546 graph_edges = edges.keys()
547 title = 'NTDS Connections known to %s' % local_dsa_dn
550 # If we are talking to the remotes, there are
551 # interesting cases we can discover. What matters most
552 # is that the destination (i.e. owner) knowns about
553 # the connection, but it would be worth noting if the
554 # source doesn't. Another strange situation could be
555 # when a DC thinks there is a connection elsewhere,
556 # but the computers allegedly involved don't believe
559 # With limited bandwidth in the table, we mark the
560 # edges known to the destination, and note the other
561 # cases in a list after the diagram.
566 for e, conn in edges.items():
567 if conn.dest_attests:
568 graph_edges.append(e)
569 if not conn.src_attests:
570 source_denies.append(e)
571 elif conn.src_attests:
572 dest_denies.append(e)
576 title = 'NTDS Connections known to each destination DC'
579 epilog.append('The following connections are alleged by '
580 'DCs other than the source and '
583 epilog.append(' %s -> %s\n' % e)
585 epilog.append('The following connections are alleged by '
586 'DCs other than the destination but '
587 'including the source:\n')
588 for e in dest_denies:
589 epilog.append(' %s -> %s\n' % e)
591 epilog.append('The following connections '
592 '(included in the chart) '
593 'are not known to the source DC:\n')
594 for e in source_denies:
595 epilog.append(' %s -> %s\n' % e)
597 s = distance_matrix(vertices, graph_edges,
600 shorten_names=shorten_names,
602 grouping_function=get_dnstrlist_site,
603 row_comments=rodc_status)
605 epilog = ''.join(epilog)
607 epilog = '\n%sNOTES%s\n%s' % (c_header,
611 self.write('\n%s\n\n%s\n%s' % (title,
620 n_servers = len(dsas)
621 for k, e in sorted(edges.items()):
623 if e.observations == n_servers or not talk_to_remote:
624 edge_colours.append('#000000')
625 edge_styles.append('')
627 edge_styles.append('')
629 edge_colours.append('#0000ff')
631 edge_colours.append('#cc00ff')
633 edge_colours.append('#ff0000')
634 edge_styles.append('style=dashed')
636 edge_colours.append('#ff0000')
637 edge_styles.append('style=dotted')
641 key_items.append((False,
644 for colour, desc in (('#0000ff', "missing from some DCs"),
645 ('#cc00ff', "missing from source DC")):
646 if colour in edge_colours:
647 key_items.append((False, 'color="%s"' % colour, desc))
649 for style, desc in (('style=dashed', "unknown to destination"),
651 "unknown to source and destination")):
652 if style in edge_styles:
653 key_items.append((False,
654 'color="#ff0000; %s"' % style,
658 title = 'NTDS Connections'
660 title = 'NTDS Connections known to %s' % local_dsa_dn
662 s = dot_graph(sorted(vertices), dot_edges,
665 edge_colors=edge_colours,
666 edge_labels=edge_labels,
667 edge_styles=edge_styles,
668 shorten_names=shorten_names,
672 self.call_xdot(s, output)
674 self.write(s, output)
677 class cmd_uptodateness(GraphCommand):
678 """visualize uptodateness vectors"""
680 takes_options = COMMON_OPTIONS + [
681 Option("-p", "--partition", help="restrict to this partition",
683 Option("--max-digits", default=3, type=int,
684 help="display this many digits of out-of-date-ness"),
687 def get_utdv(self, samdb, dn):
688 """This finds the uptodateness vector in the database."""
690 config_dn = samdb.get_config_basedn()
691 for c in dsdb._dsdb_load_udv_v2(samdb, dn):
692 inv_id = str(c.source_dsa_invocation_id)
693 res = samdb.search(base=config_dn,
694 expression=("(&(invocationId=%s)"
695 "(objectClass=nTDSDSA))" % inv_id),
696 attrs=["distinguishedName", "invocationId"])
697 settings_dn = res[0]["distinguishedName"][0]
698 prefix, dsa_dn = settings_dn.split(',', 1)
699 if prefix != 'CN=NTDS Settings':
700 raise CommandError("Expected NTDS Settings DN, got %s" %
703 cursors.append((dsa_dn,
706 nttime2unix(c.last_sync_success)))
709 def get_own_cursor(self, samdb):
710 res = samdb.search(base="",
712 attrs=["highestCommittedUSN"])
713 usn = int(res[0]["highestCommittedUSN"][0])
714 now = int(time.time())
717 def run(self, H=None, output=None, shorten_names=False,
718 key=True, talk_to_remote=False,
719 sambaopts=None, credopts=None, versionopts=None,
720 color=None, color_scheme=None,
721 utf8=False, format=None, importldif=None,
722 xdot=False, partition=None, max_digits=3):
723 if not talk_to_remote:
724 print("this won't work without talking to the remote servers "
725 "(use -r)", file=self.outf)
728 # We use the KCC libraries in readonly mode to get the
730 lp = sambaopts.get_loadparm()
731 creds = credopts.get_credentials(lp, fallback_machine=True)
732 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
733 self.samdb = local_kcc.samdb
734 partition = get_partition(self.samdb, partition)
736 short_partitions, long_partitions = get_partition_maps(self.samdb)
737 color_scheme = self.calc_distance_color_scheme(color,
741 for part_name, part_dn in short_partitions.items():
742 if partition not in (part_dn, None):
743 continue # we aren't doing this partition
745 cursors = self.get_utdv(self.samdb, part_dn)
747 # we talk to each remote and make a matrix of the vectors
748 # -- for each partition
749 # normalise by oldest
752 res = local_kcc.samdb.search(dsa_dn,
754 attrs=["dNSHostName"])
755 ldap_url = "ldap://%s" % res[0]["dNSHostName"][0]
757 samdb = self.get_db(ldap_url, sambaopts, credopts)
758 cursors = self.get_utdv(samdb, part_dn)
759 own_usn, own_time = self.get_own_cursor(samdb)
760 remotes = {dsa_dn: own_usn}
761 for dn, guid, usn, t in cursors:
763 except LdbError as e:
764 print("Could not contact %s (%s)" % (ldap_url, e),
767 utdv_edges[dsa_dn] = remotes
773 peak = utdv_edges[dn1][dn1]
774 except KeyError as e:
779 if dn2 in utdv_edges:
780 if dn1 in utdv_edges[dn2]:
781 dist = peak - utdv_edges[dn2][dn1]
783 if dist > max_distance:
786 print("Missing dn %s from UTD vector" % dn1,
789 print("missing dn %s from UTD vector list" % dn2,
792 digits = min(max_digits, len(str(max_distance)))
795 c_scale = 10 ** digits
797 s = full_matrix(distances,
800 shorten_names=shorten_names,
802 grouping_function=get_dnstr_site,
803 colour_scale=c_scale,
806 xlabel='out-of-date-ness')
808 self.write('\n%s\n\n%s' % (part_name, s), output)
811 class cmd_visualize(SuperCommand):
812 """Produces graphical representations of Samba network state"""
815 for k, v in globals().items():
816 if k.startswith('cmd_'):
817 subcommands[k[4:]] = v()