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 (
51 Option("-H", "--URL", help="LDB URL for database or target server",
52 type=str, metavar="URL", dest="H"),
53 Option("-o", "--output", help="write here (default stdout)",
54 type=str, metavar="FILE", default=None),
55 Option("--distance", help="Distance matrix graph output (default)",
56 dest='format', const='distance', action='store_const'),
57 Option("--utf8", help="Use utf-8 Unicode characters",
59 Option("--color", help="use color (yes, no, auto)",
60 choices=['yes', 'no', 'auto']),
61 Option("--color-scheme", help=("use this colour scheme "
62 "(implies --color=yes)"),
63 choices=list(COLOUR_SETS.keys())),
64 Option("-S", "--shorten-names",
65 help="don't print long common suffixes",
66 action='store_true', default=False),
67 Option("-r", "--talk-to-remote", help="query other DCs' databases",
68 action='store_true', default=False),
69 Option("--no-key", help="omit the explanatory key",
70 action='store_false', default=True, dest='key'),
74 Option("--dot", help="Graphviz dot output", dest='format',
75 const='dot', action='store_const'),
76 Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
77 const='xdot', action='store_const'),
80 TEMP_FILE = '__temp__'
83 class GraphCommand(Command):
84 """Base class for graphing commands"""
86 synopsis = "%prog [options]"
87 takes_optiongroups = {
88 "sambaopts": options.SambaOptions,
89 "versionopts": options.VersionOptions,
90 "credopts": options.CredentialsOptions,
92 takes_options = COMMON_OPTIONS + DOT_OPTIONS
95 def get_db(self, H, sambaopts, credopts):
96 lp = sambaopts.get_loadparm()
97 creds = credopts.get_credentials(lp, fallback_machine=True)
98 samdb = SamDB(url=H, credentials=creds, lp=lp)
101 def get_kcc_and_dsas(self, H, lp, creds):
102 """Get a readonly KCC object and the list of DSAs it knows about."""
103 unix_now = int(time.time())
104 kcc = KCC(unix_now, readonly=True)
105 kcc.load_samdb(H, lp, creds)
107 dsa_list = kcc.list_dsas()
109 if len(dsas) != len(dsa_list):
110 print("There seem to be duplicate dsas", file=sys.stderr)
114 def write(self, s, fn=None, suffix='.dot'):
115 """Decide whether we're dealing with a filename, a tempfile, or
116 stdout, and write accordingly.
118 :param s: the string to write
119 :param fn: a destination
120 :param suffix: suffix, if destination is a tempfile
122 If fn is None or "-", write to stdout.
123 If fn is visualize.TEMP_FILE, write to a temporary file
124 Otherwise fn should be a filename to write to.
126 if fn is None or fn == '-':
127 # we're just using stdout (a.k.a self.outf)
128 print(s, file=self.outf)
132 fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
143 def calc_output_format(self, format, output):
144 """Heuristics to work out what output format was wanted."""
146 # They told us nothing! We have to work it out for ourselves.
147 if output and output.lower().endswith('.dot'):
157 def call_xdot(self, s, output):
159 fn = self.write(s, TEMP_FILE)
161 fn = self.write(s, output)
162 xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
163 subprocess.call([xdot, fn])
166 def calc_distance_color_scheme(self, color, color_scheme, output):
167 """Heuristics to work out the colour scheme for distance matrices.
168 Returning None means no colour, otherwise it sould be a colour
169 from graph.COLOUR_SETS"""
174 if isinstance(output, str) and output != '-':
176 if not hasattr(self.outf, 'isatty'):
177 # not a real file, perhaps cStringIO in testing
179 if not self.outf.isatty():
182 if color_scheme is None:
183 if '256color' in os.environ.get('TERM', ''):
184 return 'xterm-256color-heatmap'
190 def get_dnstr_site(dn):
191 """Helper function for sorting and grouping DNs by site, if
193 m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
196 # Oh well, let it sort by DN
200 def get_dnstrlist_site(t):
201 """Helper function for sorting and grouping lists of (DN, ...) tuples
202 by site, if possible."""
203 return get_dnstr_site(t[0])
207 """Generate a randomish but consistent darkish colour based on the
209 from hashlib import md5
211 if isinstance(tmp_str, text_type):
212 tmp_str = tmp_str.encode('utf8')
213 c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
217 class cmd_reps(GraphCommand):
218 "repsFrom/repsTo from every DSA"
220 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
221 Option("-p", "--partition", help="restrict to this partition",
225 def run(self, H=None, output=None, shorten_names=False,
226 key=True, talk_to_remote=False,
227 sambaopts=None, credopts=None, versionopts=None,
228 mode='self', partition=None, color=None, color_scheme=None,
229 utf8=None, format=None, xdot=False):
230 # We use the KCC libraries in readonly mode to get the
232 lp = sambaopts.get_loadparm()
233 creds = credopts.get_credentials(lp, fallback_machine=True)
234 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
235 unix_now = local_kcc.unix_now
237 partition = get_partition(local_kcc.samdb, partition)
239 # nc_reps is an autovivifying dictionary of dictionaries of lists.
240 # nc_reps[partition]['current' | 'needed'] is a list of
241 # (dsa dn string, repsFromTo object) pairs.
242 nc_reps = defaultdict(lambda: defaultdict(list))
246 # We run a new KCC for each DSA even if we aren't talking to
247 # the remote, because after kcc.run (or kcc.list_dsas) the kcc
248 # ends up in a messy state.
250 kcc = KCC(unix_now, readonly=True)
252 res = local_kcc.samdb.search(dsa_dn,
254 attrs=["dNSHostName"])
255 dns_name = str(res[0]["dNSHostName"][0])
256 print("Attempting to contact ldap://%s (%s)" %
260 kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
261 except KCCError as e:
262 print("Could not contact ldap://%s (%s)" % (dns_name, e),
266 kcc.run(H, lp, creds)
268 kcc.load_samdb(H, lp, creds)
269 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
271 dsas_from_here = set(kcc.list_dsas())
272 if dsas != dsas_from_here:
273 print("found extra DSAs:", file=sys.stderr)
274 for dsa in (dsas_from_here - dsas):
275 print(" %s" % dsa, file=sys.stderr)
276 print("missing DSAs (known locally, not by %s):" % dsa_dn,
278 for dsa in (dsas - dsas_from_here):
279 print(" %s" % dsa, file=sys.stderr)
281 for remote_dn in dsas_from_here:
282 if mode == 'others' and remote_dn == dsa_dn:
284 elif mode == 'self' and remote_dn != dsa_dn:
287 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
288 kcc.translate_ntdsconn(remote_dsa)
289 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
290 # get_reps_tables() returns two dictionaries mapping
291 # dns to NCReplica objects
292 c, n = remote_dsa.get_rep_tables()
293 for part, rep in c.items():
294 if partition is None or part == partition:
295 nc_reps[part]['current'].append((dsa_dn, rep))
296 for part, rep in n.items():
297 if partition is None or part == partition:
298 nc_reps[part]['needed'].append((dsa_dn, rep))
300 all_edges = {'needed': {'to': [], 'from': []},
301 'current': {'to': [], 'from': []}}
303 short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
305 for partname, part in nc_reps.items():
306 for state, edgelists in all_edges.items():
307 for dsa_dn, rep in part[state]:
308 short_name = long_partitions.get(partname, partname)
309 for r in rep.rep_repsFrom:
310 edgelists['from'].append(
312 guid_to_dnstr[str(r.source_dsa_obj_guid)],
314 for r in rep.rep_repsTo:
315 edgelists['to'].append(
316 (guid_to_dnstr[str(r.source_dsa_obj_guid)],
320 # Here we have the set of edges. From now it is a matter of
321 # interpretation and presentation.
323 if self.calc_output_format(format, output) == 'distance':
324 color_scheme = self.calc_distance_color_scheme(color,
328 'from': "RepsFrom objects for %s",
329 'to': "RepsTo objects for %s",
331 for state, edgelists in all_edges.items():
332 for direction, items in edgelists.items():
333 part_edges = defaultdict(list)
334 for src, dest, part in items:
335 part_edges[part].append((src, dest))
336 for part, edges in part_edges.items():
337 s = distance_matrix(None, edges,
340 shorten_names=shorten_names,
342 grouping_function=get_dnstr_site)
344 s = "\n%s\n%s" % (header_strings[direction] % part, s)
345 self.write(s, output)
354 for state, edgelist in all_edges.items():
355 for direction, items in edgelist.items():
356 for src, dest, part in items:
357 colour = used_colours.setdefault((part),
360 linestyle = 'dotted' if state == 'needed' else 'solid'
361 arrow = 'open' if direction == 'to' else 'empty'
362 dot_vertices.add(src)
363 dot_vertices.add(dest)
364 dot_edges.append((src, dest))
365 edge_colours.append(colour)
366 style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
367 edge_styles.append(style)
368 key_set.add((part, 'reps' + direction.title(),
373 for part, direction, colour, linestyle in sorted(key_set):
374 key_items.append((False,
375 'color="%s"; %s' % (colour, linestyle),
376 "%s %s" % (part, direction)))
377 key_items.append((False,
378 'style="dotted"; arrowhead="open"',
379 "repsFromTo is needed"))
380 key_items.append((False,
381 'style="solid"; arrowhead="open"',
382 "repsFromTo currently exists"))
384 s = dot_graph(dot_vertices, dot_edges,
386 edge_colors=edge_colours,
387 edge_styles=edge_styles,
388 shorten_names=shorten_names,
392 self.call_xdot(s, output)
394 self.write(s, output)
397 class NTDSConn(object):
398 """Collects observation counts for NTDS connections, so we know
399 whether all DSAs agree."""
400 def __init__(self, src, dest):
401 self.observations = 0
402 self.src_attests = False
403 self.dest_attests = False
407 def attest(self, attester):
408 self.observations += 1
409 if attester == self.src:
410 self.src_attests = True
411 if attester == self.dest:
412 self.dest_attests = True
415 class cmd_ntdsconn(GraphCommand):
416 "Draw the NTDSConnection graph"
417 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
418 Option("--importldif", help="graph from samba_kcc generated ldif",
422 def import_ldif_db(self, ldif, lp):
423 d = tempfile.mkdtemp(prefix='samba-tool-visualise')
424 fn = os.path.join(d, 'imported.ldb')
425 self._tmp_fn_to_delete = fn
426 samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
429 def run(self, H=None, output=None, shorten_names=False,
430 key=True, talk_to_remote=False,
431 sambaopts=None, credopts=None, versionopts=None,
432 color=None, color_scheme=None,
433 utf8=None, format=None, importldif=None,
436 lp = sambaopts.get_loadparm()
437 if importldif is None:
438 creds = credopts.get_credentials(lp, fallback_machine=True)
441 H = self.import_ldif_db(importldif, lp)
443 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
444 local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
449 res = local_kcc.samdb.search(dsa_dn,
451 attrs=["dNSHostName"])
452 dns_name = res[0]["dNSHostName"][0]
454 samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
456 except LdbError as e:
457 print("Could not contact ldap://%s (%s)" % (dns_name, e),
461 ntds_dn = samdb.get_dsServiceName()
462 dn = samdb.domain_dn()
464 samdb = self.get_db(H, sambaopts, credopts)
465 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
468 res = samdb.search(ntds_dn,
470 attrs=["msDS-isRODC"])
472 is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
474 vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
475 # XXX we could also look at schedule
476 res = samdb.search(dn,
478 expression="(objectClass=nTDSConnection)",
479 attrs=['fromServer'],
480 # XXX can't be critical for ldif test
481 # controls=["search_options:1:2"],
482 controls=["search_options:0:2"],
487 dest_dn = msgdn[msgdn.index(',') + 1:]
488 attested_edges.append((str(msg['fromServer'][0]),
491 if importldif and H == self._tmp_fn_to_delete:
493 os.rmdir(os.path.dirname(H))
495 # now we overlay all the graphs and generate styles accordingly
497 for src, dest, attester in attested_edges:
506 vertices, rodc_status = zip(*sorted(vertices))
508 if self.calc_output_format(format, output) == 'distance':
509 color_scheme = self.calc_distance_color_scheme(color,
512 colours = COLOUR_SETS[color_scheme]
513 c_header = colours.get('header', '')
514 c_reset = colours.get('reset', '')
517 if 'RODC' in rodc_status:
518 epilog.append('No outbound connections are expected from RODCs')
520 if not talk_to_remote:
521 # If we are not talking to remote servers, we list all
523 graph_edges = edges.keys()
524 title = 'NTDS Connections known to %s' % local_dsa_dn
527 # If we are talking to the remotes, there are
528 # interesting cases we can discover. What matters most
529 # is that the destination (i.e. owner) knowns about
530 # the connection, but it would be worth noting if the
531 # source doesn't. Another strange situation could be
532 # when a DC thinks there is a connection elsewhere,
533 # but the computers allegedly involved don't believe
536 # With limited bandwidth in the table, we mark the
537 # edges known to the destination, and note the other
538 # cases in a list after the diagram.
543 for e, conn in edges.items():
544 if conn.dest_attests:
545 graph_edges.append(e)
546 if not conn.src_attests:
547 source_denies.append(e)
548 elif conn.src_attests:
549 dest_denies.append(e)
553 title = 'NTDS Connections known to each destination DC'
556 epilog.append('The following connections are alleged by '
557 'DCs other than the source and '
560 epilog.append(' %s -> %s\n' % e)
562 epilog.append('The following connections are alleged by '
563 'DCs other than the destination but '
564 'including the source:\n')
565 for e in dest_denies:
566 epilog.append(' %s -> %s\n' % e)
568 epilog.append('The following connections '
569 '(included in the chart) '
570 'are not known to the source DC:\n')
571 for e in source_denies:
572 epilog.append(' %s -> %s\n' % e)
574 s = distance_matrix(vertices, graph_edges,
577 shorten_names=shorten_names,
579 grouping_function=get_dnstrlist_site,
580 row_comments=rodc_status)
582 epilog = ''.join(epilog)
584 epilog = '\n%sNOTES%s\n%s' % (c_header,
588 self.write('\n%s\n\n%s\n%s' % (title,
597 n_servers = len(dsas)
598 for k, e in sorted(edges.items()):
600 if e.observations == n_servers or not talk_to_remote:
601 edge_colours.append('#000000')
602 edge_styles.append('')
604 edge_styles.append('')
606 edge_colours.append('#0000ff')
608 edge_colours.append('#cc00ff')
610 edge_colours.append('#ff0000')
611 edge_styles.append('style=dashed')
613 edge_colours.append('#ff0000')
614 edge_styles.append('style=dotted')
618 key_items.append((False,
621 for colour, desc in (('#0000ff', "missing from some DCs"),
622 ('#cc00ff', "missing from source DC")):
623 if colour in edge_colours:
624 key_items.append((False, 'color="%s"' % colour, desc))
626 for style, desc in (('style=dashed', "unknown to destination"),
628 "unknown to source and destination")):
629 if style in edge_styles:
630 key_items.append((False,
631 'color="#ff0000; %s"' % style,
635 title = 'NTDS Connections'
637 title = 'NTDS Connections known to %s' % local_dsa_dn
639 s = dot_graph(sorted(vertices), dot_edges,
642 edge_colors=edge_colours,
643 edge_labels=edge_labels,
644 edge_styles=edge_styles,
645 shorten_names=shorten_names,
649 self.call_xdot(s, output)
651 self.write(s, output)
654 class cmd_uptodateness(GraphCommand):
655 """visualize uptodateness vectors"""
657 takes_options = COMMON_OPTIONS + [
658 Option("-p", "--partition", help="restrict to this partition",
660 Option("--max-digits", default=3, type=int,
661 help="display this many digits of out-of-date-ness"),
664 def run(self, H=None, output=None, shorten_names=False,
665 key=True, talk_to_remote=False,
666 sambaopts=None, credopts=None, versionopts=None,
667 color=None, color_scheme=None,
668 utf8=False, format=None, importldif=None,
669 xdot=False, partition=None, max_digits=3):
670 if not talk_to_remote:
671 print("this won't work without talking to the remote servers "
672 "(use -r)", file=self.outf)
675 # We use the KCC libraries in readonly mode to get the
677 lp = sambaopts.get_loadparm()
678 creds = credopts.get_credentials(lp, fallback_machine=True)
679 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
680 self.samdb = local_kcc.samdb
681 partition = get_partition(self.samdb, partition)
683 short_partitions, long_partitions = get_partition_maps(self.samdb)
684 color_scheme = self.calc_distance_color_scheme(color,
688 for part_name, part_dn in short_partitions.items():
689 if partition not in (part_dn, None):
690 continue # we aren't doing this partition
692 utdv_edges = get_utdv_edges(local_kcc, dsas, part_dn, lp, creds)
698 peak = utdv_edges[dn1][dn1]
699 except KeyError as e:
704 if dn2 in utdv_edges:
705 if dn1 in utdv_edges[dn2]:
706 dist = peak - utdv_edges[dn2][dn1]
708 if dist > max_distance:
711 print("Missing dn %s from UTD vector" % dn1,
714 print("missing dn %s from UTD vector list" % dn2,
717 digits = min(max_digits, len(str(max_distance)))
720 c_scale = 10 ** digits
722 s = full_matrix(distances,
725 shorten_names=shorten_names,
727 grouping_function=get_dnstr_site,
728 colour_scale=c_scale,
731 xlabel='out-of-date-ness')
733 self.write('\n%s\n\n%s' % (part_name, s), output)
736 class cmd_visualize(SuperCommand):
737 """Produces graphical representations of Samba network state"""
740 for k, v in globals().items():
741 if k.startswith('cmd_'):
742 subcommands[k[4:]] = v()