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
42 from samba.uptodateness import (
52 Option("-H", "--URL", help="LDB URL for database or target server",
53 type=str, metavar="URL", dest="H"),
54 Option("-o", "--output", help="write here (default stdout)",
55 type=str, metavar="FILE", default=None),
56 Option("--distance", help="Distance matrix graph output (default)",
57 dest='format', const='distance', action='store_const'),
58 Option("--utf8", help="Use utf-8 Unicode characters",
60 Option("--color", help="use color (yes, no, auto)",
61 choices=['yes', 'no', 'auto']),
62 Option("--color-scheme", help=("use this colour scheme "
63 "(implies --color=yes)"),
64 choices=list(COLOUR_SETS.keys())),
65 Option("-S", "--shorten-names",
66 help="don't print long common suffixes",
67 action='store_true', default=False),
68 Option("-r", "--talk-to-remote", help="query other DCs' databases",
69 action='store_true', default=False),
70 Option("--no-key", help="omit the explanatory key",
71 action='store_false', default=True, dest='key'),
75 Option("--dot", help="Graphviz dot output", dest='format',
76 const='dot', action='store_const'),
77 Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
78 const='xdot', action='store_const'),
81 TEMP_FILE = '__temp__'
84 class GraphCommand(Command):
85 """Base class for graphing commands"""
87 synopsis = "%prog [options]"
88 takes_optiongroups = {
89 "sambaopts": options.SambaOptions,
90 "versionopts": options.VersionOptions,
91 "credopts": options.CredentialsOptions,
93 takes_options = COMMON_OPTIONS + DOT_OPTIONS
96 def get_db(self, H, sambaopts, credopts):
97 lp = sambaopts.get_loadparm()
98 creds = credopts.get_credentials(lp, fallback_machine=True)
99 samdb = SamDB(url=H, credentials=creds, lp=lp)
102 def get_kcc_and_dsas(self, H, lp, creds):
103 """Get a readonly KCC object and the list of DSAs it knows about."""
104 unix_now = int(time.time())
105 kcc = KCC(unix_now, readonly=True)
106 kcc.load_samdb(H, lp, creds)
108 dsa_list = kcc.list_dsas()
110 if len(dsas) != len(dsa_list):
111 print("There seem to be duplicate dsas", file=sys.stderr)
115 def write(self, s, fn=None, suffix='.dot'):
116 """Decide whether we're dealing with a filename, a tempfile, or
117 stdout, and write accordingly.
119 :param s: the string to write
120 :param fn: a destination
121 :param suffix: suffix, if destination is a tempfile
123 If fn is None or "-", write to stdout.
124 If fn is visualize.TEMP_FILE, write to a temporary file
125 Otherwise fn should be a filename to write to.
127 if fn is None or fn == '-':
128 # we're just using stdout (a.k.a self.outf)
129 print(s, file=self.outf)
133 fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
144 def calc_output_format(self, format, output):
145 """Heuristics to work out what output format was wanted."""
147 # They told us nothing! We have to work it out for ourselves.
148 if output and output.lower().endswith('.dot'):
158 def call_xdot(self, s, output):
160 fn = self.write(s, TEMP_FILE)
162 fn = self.write(s, output)
163 xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
164 subprocess.call([xdot, fn])
167 def calc_distance_color_scheme(self, color, color_scheme, output):
168 """Heuristics to work out the colour scheme for distance matrices.
169 Returning None means no colour, otherwise it sould be a colour
170 from graph.COLOUR_SETS"""
175 if isinstance(output, str) and output != '-':
177 if not hasattr(self.outf, 'isatty'):
178 # not a real file, perhaps cStringIO in testing
180 if not self.outf.isatty():
183 if color_scheme is None:
184 if '256color' in os.environ.get('TERM', ''):
185 return 'xterm-256color-heatmap'
191 def get_dnstr_site(dn):
192 """Helper function for sorting and grouping DNs by site, if
194 m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
197 # Oh well, let it sort by DN
201 def get_dnstrlist_site(t):
202 """Helper function for sorting and grouping lists of (DN, ...) tuples
203 by site, if possible."""
204 return get_dnstr_site(t[0])
208 """Generate a randomish but consistent darkish colour based on the
210 from hashlib import md5
212 if isinstance(tmp_str, text_type):
213 tmp_str = tmp_str.encode('utf8')
214 c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
218 class cmd_reps(GraphCommand):
219 "repsFrom/repsTo from every DSA"
221 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
222 Option("-p", "--partition", help="restrict to this partition",
226 def run(self, H=None, output=None, shorten_names=False,
227 key=True, talk_to_remote=False,
228 sambaopts=None, credopts=None, versionopts=None,
229 mode='self', partition=None, color=None, color_scheme=None,
230 utf8=None, format=None, xdot=False):
231 # We use the KCC libraries in readonly mode to get the
233 lp = sambaopts.get_loadparm()
234 creds = credopts.get_credentials(lp, fallback_machine=True)
235 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
236 unix_now = local_kcc.unix_now
238 partition = get_partition(local_kcc.samdb, partition)
240 # nc_reps is an autovivifying dictionary of dictionaries of lists.
241 # nc_reps[partition]['current' | 'needed'] is a list of
242 # (dsa dn string, repsFromTo object) pairs.
243 nc_reps = defaultdict(lambda: defaultdict(list))
247 # We run a new KCC for each DSA even if we aren't talking to
248 # the remote, because after kcc.run (or kcc.list_dsas) the kcc
249 # ends up in a messy state.
251 kcc = KCC(unix_now, readonly=True)
253 res = local_kcc.samdb.search(dsa_dn,
255 attrs=["dNSHostName"])
256 dns_name = str(res[0]["dNSHostName"][0])
257 print("Attempting to contact ldap://%s (%s)" %
261 kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
262 except KCCError as e:
263 print("Could not contact ldap://%s (%s)" % (dns_name, e),
267 kcc.run(H, lp, creds)
269 kcc.load_samdb(H, lp, creds)
270 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
272 dsas_from_here = set(kcc.list_dsas())
273 if dsas != dsas_from_here:
274 print("found extra DSAs:", file=sys.stderr)
275 for dsa in (dsas_from_here - dsas):
276 print(" %s" % dsa, file=sys.stderr)
277 print("missing DSAs (known locally, not by %s):" % dsa_dn,
279 for dsa in (dsas - dsas_from_here):
280 print(" %s" % dsa, file=sys.stderr)
282 for remote_dn in dsas_from_here:
283 if mode == 'others' and remote_dn == dsa_dn:
285 elif mode == 'self' and remote_dn != dsa_dn:
288 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
289 kcc.translate_ntdsconn(remote_dsa)
290 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
291 # get_reps_tables() returns two dictionaries mapping
292 # dns to NCReplica objects
293 c, n = remote_dsa.get_rep_tables()
294 for part, rep in c.items():
295 if partition is None or part == partition:
296 nc_reps[part]['current'].append((dsa_dn, rep))
297 for part, rep in n.items():
298 if partition is None or part == partition:
299 nc_reps[part]['needed'].append((dsa_dn, rep))
301 all_edges = {'needed': {'to': [], 'from': []},
302 'current': {'to': [], 'from': []}}
304 short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
306 for partname, part in nc_reps.items():
307 for state, edgelists in all_edges.items():
308 for dsa_dn, rep in part[state]:
309 short_name = long_partitions.get(partname, partname)
310 for r in rep.rep_repsFrom:
311 edgelists['from'].append(
313 guid_to_dnstr[str(r.source_dsa_obj_guid)],
315 for r in rep.rep_repsTo:
316 edgelists['to'].append(
317 (guid_to_dnstr[str(r.source_dsa_obj_guid)],
321 # Here we have the set of edges. From now it is a matter of
322 # interpretation and presentation.
324 if self.calc_output_format(format, output) == 'distance':
325 color_scheme = self.calc_distance_color_scheme(color,
329 'from': "RepsFrom objects for %s",
330 'to': "RepsTo objects for %s",
332 for state, edgelists in all_edges.items():
333 for direction, items in edgelists.items():
334 part_edges = defaultdict(list)
335 for src, dest, part in items:
336 part_edges[part].append((src, dest))
337 for part, edges in part_edges.items():
338 s = distance_matrix(None, edges,
341 shorten_names=shorten_names,
343 grouping_function=get_dnstr_site)
345 s = "\n%s\n%s" % (header_strings[direction] % part, s)
346 self.write(s, output)
355 for state, edgelist in all_edges.items():
356 for direction, items in edgelist.items():
357 for src, dest, part in items:
358 colour = used_colours.setdefault((part),
361 linestyle = 'dotted' if state == 'needed' else 'solid'
362 arrow = 'open' if direction == 'to' else 'empty'
363 dot_vertices.add(src)
364 dot_vertices.add(dest)
365 dot_edges.append((src, dest))
366 edge_colours.append(colour)
367 style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
368 edge_styles.append(style)
369 key_set.add((part, 'reps' + direction.title(),
374 for part, direction, colour, linestyle in sorted(key_set):
375 key_items.append((False,
376 'color="%s"; %s' % (colour, linestyle),
377 "%s %s" % (part, direction)))
378 key_items.append((False,
379 'style="dotted"; arrowhead="open"',
380 "repsFromTo is needed"))
381 key_items.append((False,
382 'style="solid"; arrowhead="open"',
383 "repsFromTo currently exists"))
385 s = dot_graph(dot_vertices, dot_edges,
387 edge_colors=edge_colours,
388 edge_styles=edge_styles,
389 shorten_names=shorten_names,
393 self.call_xdot(s, output)
395 self.write(s, output)
398 class NTDSConn(object):
399 """Collects observation counts for NTDS connections, so we know
400 whether all DSAs agree."""
401 def __init__(self, src, dest):
402 self.observations = 0
403 self.src_attests = False
404 self.dest_attests = False
408 def attest(self, attester):
409 self.observations += 1
410 if attester == self.src:
411 self.src_attests = True
412 if attester == self.dest:
413 self.dest_attests = True
416 class cmd_ntdsconn(GraphCommand):
417 "Draw the NTDSConnection graph"
418 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
419 Option("--importldif", help="graph from samba_kcc generated ldif",
423 def import_ldif_db(self, ldif, lp):
424 d = tempfile.mkdtemp(prefix='samba-tool-visualise')
425 fn = os.path.join(d, 'imported.ldb')
426 self._tmp_fn_to_delete = fn
427 samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
430 def run(self, H=None, output=None, shorten_names=False,
431 key=True, talk_to_remote=False,
432 sambaopts=None, credopts=None, versionopts=None,
433 color=None, color_scheme=None,
434 utf8=None, format=None, importldif=None,
437 lp = sambaopts.get_loadparm()
438 if importldif is None:
439 creds = credopts.get_credentials(lp, fallback_machine=True)
442 H = self.import_ldif_db(importldif, lp)
444 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
445 local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
450 res = local_kcc.samdb.search(dsa_dn,
452 attrs=["dNSHostName"])
453 dns_name = res[0]["dNSHostName"][0]
455 samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
457 except LdbError as e:
458 print("Could not contact ldap://%s (%s)" % (dns_name, e),
462 ntds_dn = samdb.get_dsServiceName()
463 dn = samdb.domain_dn()
465 samdb = self.get_db(H, sambaopts, credopts)
466 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
469 res = samdb.search(ntds_dn,
471 attrs=["msDS-isRODC"])
473 is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
475 vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
476 # XXX we could also look at schedule
477 res = samdb.search(dn,
479 expression="(objectClass=nTDSConnection)",
480 attrs=['fromServer'],
481 # XXX can't be critical for ldif test
482 # controls=["search_options:1:2"],
483 controls=["search_options:0:2"],
488 dest_dn = msgdn[msgdn.index(',') + 1:]
489 attested_edges.append((str(msg['fromServer'][0]),
492 if importldif and H == self._tmp_fn_to_delete:
494 os.rmdir(os.path.dirname(H))
496 # now we overlay all the graphs and generate styles accordingly
498 for src, dest, attester in attested_edges:
507 vertices, rodc_status = zip(*sorted(vertices))
509 if self.calc_output_format(format, output) == 'distance':
510 color_scheme = self.calc_distance_color_scheme(color,
513 colours = COLOUR_SETS[color_scheme]
514 c_header = colours.get('header', '')
515 c_reset = colours.get('reset', '')
518 if 'RODC' in rodc_status:
519 epilog.append('No outbound connections are expected from RODCs')
521 if not talk_to_remote:
522 # If we are not talking to remote servers, we list all
524 graph_edges = edges.keys()
525 title = 'NTDS Connections known to %s' % local_dsa_dn
528 # If we are talking to the remotes, there are
529 # interesting cases we can discover. What matters most
530 # is that the destination (i.e. owner) knowns about
531 # the connection, but it would be worth noting if the
532 # source doesn't. Another strange situation could be
533 # when a DC thinks there is a connection elsewhere,
534 # but the computers allegedly involved don't believe
537 # With limited bandwidth in the table, we mark the
538 # edges known to the destination, and note the other
539 # cases in a list after the diagram.
544 for e, conn in edges.items():
545 if conn.dest_attests:
546 graph_edges.append(e)
547 if not conn.src_attests:
548 source_denies.append(e)
549 elif conn.src_attests:
550 dest_denies.append(e)
554 title = 'NTDS Connections known to each destination DC'
557 epilog.append('The following connections are alleged by '
558 'DCs other than the source and '
561 epilog.append(' %s -> %s\n' % e)
563 epilog.append('The following connections are alleged by '
564 'DCs other than the destination but '
565 'including the source:\n')
566 for e in dest_denies:
567 epilog.append(' %s -> %s\n' % e)
569 epilog.append('The following connections '
570 '(included in the chart) '
571 'are not known to the source DC:\n')
572 for e in source_denies:
573 epilog.append(' %s -> %s\n' % e)
575 s = distance_matrix(vertices, graph_edges,
578 shorten_names=shorten_names,
580 grouping_function=get_dnstrlist_site,
581 row_comments=rodc_status)
583 epilog = ''.join(epilog)
585 epilog = '\n%sNOTES%s\n%s' % (c_header,
589 self.write('\n%s\n\n%s\n%s' % (title,
598 n_servers = len(dsas)
599 for k, e in sorted(edges.items()):
601 if e.observations == n_servers or not talk_to_remote:
602 edge_colours.append('#000000')
603 edge_styles.append('')
605 edge_styles.append('')
607 edge_colours.append('#0000ff')
609 edge_colours.append('#cc00ff')
611 edge_colours.append('#ff0000')
612 edge_styles.append('style=dashed')
614 edge_colours.append('#ff0000')
615 edge_styles.append('style=dotted')
619 key_items.append((False,
622 for colour, desc in (('#0000ff', "missing from some DCs"),
623 ('#cc00ff', "missing from source DC")):
624 if colour in edge_colours:
625 key_items.append((False, 'color="%s"' % colour, desc))
627 for style, desc in (('style=dashed', "unknown to destination"),
629 "unknown to source and destination")):
630 if style in edge_styles:
631 key_items.append((False,
632 'color="#ff0000; %s"' % style,
636 title = 'NTDS Connections'
638 title = 'NTDS Connections known to %s' % local_dsa_dn
640 s = dot_graph(sorted(vertices), dot_edges,
643 edge_colors=edge_colours,
644 edge_labels=edge_labels,
645 edge_styles=edge_styles,
646 shorten_names=shorten_names,
650 self.call_xdot(s, output)
652 self.write(s, output)
655 class cmd_uptodateness(GraphCommand):
656 """visualize uptodateness vectors"""
658 takes_options = COMMON_OPTIONS + [
659 Option("-p", "--partition", help="restrict to this partition",
661 Option("--max-digits", default=3, type=int,
662 help="display this many digits of out-of-date-ness"),
665 def run(self, H=None, output=None, shorten_names=False,
666 key=True, talk_to_remote=False,
667 sambaopts=None, credopts=None, versionopts=None,
668 color=None, color_scheme=None,
669 utf8=False, format=None, importldif=None,
670 xdot=False, partition=None, max_digits=3):
671 if not talk_to_remote:
672 print("this won't work without talking to the remote servers "
673 "(use -r)", file=self.outf)
676 # We use the KCC libraries in readonly mode to get the
678 lp = sambaopts.get_loadparm()
679 creds = credopts.get_credentials(lp, fallback_machine=True)
680 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
681 self.samdb = local_kcc.samdb
682 partition = get_partition(self.samdb, partition)
684 short_partitions, long_partitions = get_partition_maps(self.samdb)
685 color_scheme = self.calc_distance_color_scheme(color,
689 for part_name, part_dn in short_partitions.items():
690 if partition not in (part_dn, None):
691 continue # we aren't doing this partition
693 utdv_edges = get_utdv_edges(local_kcc, dsas, part_dn, lp, creds)
695 distances, max_distance = get_utdv_distances(utdv_edges, dsas)
697 digits = min(max_digits, len(str(max_distance)))
700 c_scale = 10 ** digits
702 s = full_matrix(distances,
705 shorten_names=shorten_names,
707 grouping_function=get_dnstr_site,
708 colour_scale=c_scale,
711 xlabel='out-of-date-ness')
713 self.write('\n%s\n\n%s' % (part_name, s), output)
716 class cmd_visualize(SuperCommand):
717 """Produces graphical representations of Samba network state"""
720 for k, v in globals().items():
721 if k.startswith('cmd_'):
722 subcommands[k[4:]] = v()