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/>.
22 from collections import defaultdict
25 import samba.getopt as options
26 from samba import dsdb
27 from samba import nttime2unix
28 from samba.netcmd import Command, SuperCommand, CommandError, Option
29 from samba.samdb import SamDB
30 from samba.graph import dot_graph
31 from samba.graph import distance_matrix, COLOUR_SETS
32 from samba.graph import full_matrix
33 from samba.colour import is_colour_wanted
35 from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
38 from samba.kcc import KCC, ldif_import_export
39 from samba.kcc.kcc_utils import KCCError
40 from samba.uptodateness import (
47 get_utdv_max_distance,
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-scheme", help=("use this colour scheme "
61 "(implies --color=yes)"),
62 choices=list(COLOUR_SETS.keys())),
63 Option("-S", "--shorten-names",
64 help="don't print long common suffixes",
65 action='store_true', default=False),
66 Option("-r", "--talk-to-remote", help="query other DCs' databases",
67 action='store_true', default=False),
68 Option("--no-key", help="omit the explanatory key",
69 action='store_false', default=True, dest='key'),
73 Option("--dot", help="Graphviz dot output", dest='format',
74 const='dot', action='store_const'),
75 Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
76 const='xdot', action='store_const'),
79 TEMP_FILE = '__temp__'
82 class GraphCommand(Command):
83 """Base class for graphing commands"""
85 synopsis = "%prog [options]"
86 takes_optiongroups = {
87 "sambaopts": options.SambaOptions,
88 "versionopts": options.VersionOptions,
89 "credopts": options.CredentialsOptions,
91 takes_options = COMMON_OPTIONS + DOT_OPTIONS
94 def get_db(self, H, sambaopts, credopts):
95 lp = sambaopts.get_loadparm()
96 creds = credopts.get_credentials(lp, fallback_machine=True)
97 samdb = SamDB(url=H, credentials=creds, lp=lp)
100 def write(self, s, fn=None, suffix='.dot'):
101 """Decide whether we're dealing with a filename, a tempfile, or
102 stdout, and write accordingly.
104 :param s: the string to write
105 :param fn: a destination
106 :param suffix: suffix, if destination is a tempfile
108 If fn is None or "-", write to stdout.
109 If fn is visualize.TEMP_FILE, write to a temporary file
110 Otherwise fn should be a filename to write to.
112 if fn is None or fn == '-':
113 # we're just using stdout (a.k.a self.outf)
114 print(s, file=self.outf)
118 fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
129 def calc_output_format(self, format, output):
130 """Heuristics to work out what output format was wanted."""
132 # They told us nothing! We have to work it out for ourselves.
133 if output and output.lower().endswith('.dot'):
143 def call_xdot(self, s, output):
145 fn = self.write(s, TEMP_FILE)
147 fn = self.write(s, output)
148 xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
149 subprocess.call([xdot, fn])
152 def calc_distance_color_scheme(self, color_scheme, output):
153 """Heuristics to work out the colour scheme for distance matrices.
154 Returning None means no colour, otherwise it should be a colour
155 from graph.COLOUR_SETS"""
156 if color_scheme is not None:
157 # --color-scheme implies --color=yes for *this* purpose.
160 if output in ('-', None):
163 want_colour = is_colour_wanted(output, hint=self.requested_colour)
167 # if we got to here, we are using colour according to the
168 # --color/NO_COLOR rules, but no colour scheme has been
169 # specified, so we choose some defaults.
170 if '256color' in os.environ.get('TERM', ''):
171 return 'xterm-256color-heatmap'
175 def get_dnstr_site(dn):
176 """Helper function for sorting and grouping DNs by site, if
178 m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
181 # Oh well, let it sort by DN
185 def get_dnstrlist_site(t):
186 """Helper function for sorting and grouping lists of (DN, ...) tuples
187 by site, if possible."""
188 return get_dnstr_site(t[0])
192 """Generate a randomish but consistent darkish colour based on the
194 from hashlib import md5
196 if isinstance(tmp_str, str):
197 tmp_str = tmp_str.encode('utf8')
198 c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
202 class cmd_reps(GraphCommand):
203 "repsFrom/repsTo from every DSA"
205 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
206 Option("-p", "--partition", help="restrict to this partition",
210 def run(self, H=None, output=None, shorten_names=False,
211 key=True, talk_to_remote=False,
212 sambaopts=None, credopts=None, versionopts=None,
213 mode='self', partition=None, color_scheme=None,
214 utf8=None, format=None, xdot=False):
215 # We use the KCC libraries in readonly mode to get the
217 lp = sambaopts.get_loadparm()
218 creds = credopts.get_credentials(lp, fallback_machine=True)
219 local_kcc, dsas = get_kcc_and_dsas(H, lp, creds)
220 unix_now = local_kcc.unix_now
222 partition = get_partition(local_kcc.samdb, partition)
224 # nc_reps is an autovivifying dictionary of dictionaries of lists.
225 # nc_reps[partition]['current' | 'needed'] is a list of
226 # (dsa dn string, repsFromTo object) pairs.
227 nc_reps = defaultdict(lambda: defaultdict(list))
231 # We run a new KCC for each DSA even if we aren't talking to
232 # the remote, because after kcc.run (or kcc.list_dsas) the kcc
233 # ends up in a messy state.
235 kcc = KCC(unix_now, readonly=True)
237 res = local_kcc.samdb.search(dsa_dn,
239 attrs=["dNSHostName"])
240 dns_name = str(res[0]["dNSHostName"][0])
241 print("Attempting to contact ldap://%s (%s)" %
245 kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
246 except KCCError as e:
247 print("Could not contact ldap://%s (%s)" % (dns_name, e),
251 kcc.run(H, lp, creds)
253 kcc.load_samdb(H, lp, creds)
254 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
256 dsas_from_here = set(kcc.list_dsas())
257 if dsas != dsas_from_here:
258 print("found extra DSAs:", file=sys.stderr)
259 for dsa in (dsas_from_here - dsas):
260 print(" %s" % dsa, file=sys.stderr)
261 print("missing DSAs (known locally, not by %s):" % dsa_dn,
263 for dsa in (dsas - dsas_from_here):
264 print(" %s" % dsa, file=sys.stderr)
266 for remote_dn in dsas_from_here:
267 if mode == 'others' and remote_dn == dsa_dn:
269 elif mode == 'self' and remote_dn != dsa_dn:
272 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
273 kcc.translate_ntdsconn(remote_dsa)
274 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
275 # get_reps_tables() returns two dictionaries mapping
276 # dns to NCReplica objects
277 c, n = remote_dsa.get_rep_tables()
278 for part, rep in c.items():
279 if partition is None or part == partition:
280 nc_reps[part]['current'].append((dsa_dn, rep))
281 for part, rep in n.items():
282 if partition is None or part == partition:
283 nc_reps[part]['needed'].append((dsa_dn, rep))
285 all_edges = {'needed': {'to': [], 'from': []},
286 'current': {'to': [], 'from': []}}
288 short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
290 for partname, part in nc_reps.items():
291 for state, edgelists in all_edges.items():
292 for dsa_dn, rep in part[state]:
293 short_name = long_partitions.get(partname, partname)
294 for r in rep.rep_repsFrom:
295 edgelists['from'].append(
297 guid_to_dnstr[str(r.source_dsa_obj_guid)],
299 for r in rep.rep_repsTo:
300 edgelists['to'].append(
301 (guid_to_dnstr[str(r.source_dsa_obj_guid)],
305 # Here we have the set of edges. From now it is a matter of
306 # interpretation and presentation.
308 if self.calc_output_format(format, output) == 'distance':
309 color_scheme = self.calc_distance_color_scheme(color_scheme,
312 'from': "RepsFrom objects for %s",
313 'to': "RepsTo objects for %s",
315 for state, edgelists in all_edges.items():
316 for direction, items in edgelists.items():
317 part_edges = defaultdict(list)
318 for src, dest, part in items:
319 part_edges[part].append((src, dest))
320 for part, edges in part_edges.items():
321 s = distance_matrix(None, edges,
324 shorten_names=shorten_names,
326 grouping_function=get_dnstr_site)
328 s = "\n%s\n%s" % (header_strings[direction] % part, s)
329 self.write(s, output)
338 for state, edgelist in all_edges.items():
339 for direction, items in edgelist.items():
340 for src, dest, part in items:
341 colour = used_colours.setdefault((part),
344 linestyle = 'dotted' if state == 'needed' else 'solid'
345 arrow = 'open' if direction == 'to' else 'empty'
346 dot_vertices.add(src)
347 dot_vertices.add(dest)
348 dot_edges.append((src, dest))
349 edge_colours.append(colour)
350 style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
351 edge_styles.append(style)
352 key_set.add((part, 'reps' + direction.title(),
357 for part, direction, colour, linestyle in sorted(key_set):
358 key_items.append((False,
359 'color="%s"; %s' % (colour, linestyle),
360 "%s %s" % (part, direction)))
361 key_items.append((False,
362 'style="dotted"; arrowhead="open"',
363 "repsFromTo is needed"))
364 key_items.append((False,
365 'style="solid"; arrowhead="open"',
366 "repsFromTo currently exists"))
368 s = dot_graph(dot_vertices, dot_edges,
370 edge_colors=edge_colours,
371 edge_styles=edge_styles,
372 shorten_names=shorten_names,
376 self.call_xdot(s, output)
378 self.write(s, output)
381 class NTDSConn(object):
382 """Collects observation counts for NTDS connections, so we know
383 whether all DSAs agree."""
384 def __init__(self, src, dest):
385 self.observations = 0
386 self.src_attests = False
387 self.dest_attests = False
391 def attest(self, attester):
392 self.observations += 1
393 if attester == self.src:
394 self.src_attests = True
395 if attester == self.dest:
396 self.dest_attests = True
399 class cmd_ntdsconn(GraphCommand):
400 "Draw the NTDSConnection graph"
401 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
402 Option("--importldif", help="graph from samba_kcc generated ldif",
406 def import_ldif_db(self, ldif, lp):
407 d = tempfile.mkdtemp(prefix='samba-tool-visualise')
408 fn = os.path.join(d, 'imported.ldb')
409 self._tmp_fn_to_delete = fn
410 samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
413 def run(self, H=None, output=None, shorten_names=False,
414 key=True, talk_to_remote=False,
415 sambaopts=None, credopts=None, versionopts=None,
417 utf8=None, format=None, importldif=None,
420 lp = sambaopts.get_loadparm()
421 if importldif is None:
422 creds = credopts.get_credentials(lp, fallback_machine=True)
425 H = self.import_ldif_db(importldif, lp)
427 local_kcc, dsas = get_kcc_and_dsas(H, lp, creds)
428 local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
433 res = local_kcc.samdb.search(dsa_dn,
435 attrs=["dNSHostName"])
436 dns_name = res[0]["dNSHostName"][0]
438 samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
440 except LdbError as e:
441 print("Could not contact ldap://%s (%s)" % (dns_name, e),
445 ntds_dn = samdb.get_dsServiceName()
446 dn = samdb.domain_dn()
448 samdb = self.get_db(H, sambaopts, credopts)
449 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
452 res = samdb.search(ntds_dn,
454 attrs=["msDS-isRODC"])
456 is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
458 vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
459 # XXX we could also look at schedule
460 res = samdb.search(dn,
462 expression="(objectClass=nTDSConnection)",
463 attrs=['fromServer'],
464 # XXX can't be critical for ldif test
465 # controls=["search_options:1:2"],
466 controls=["search_options:0:2"],
471 dest_dn = msgdn[msgdn.index(',') + 1:]
472 attested_edges.append((str(msg['fromServer'][0]),
475 if importldif and H == self._tmp_fn_to_delete:
477 os.rmdir(os.path.dirname(H))
479 # now we overlay all the graphs and generate styles accordingly
481 for src, dest, attester in attested_edges:
490 vertices, rodc_status = zip(*sorted(vertices))
492 if self.calc_output_format(format, output) == 'distance':
493 color_scheme = self.calc_distance_color_scheme(color_scheme,
495 colours = COLOUR_SETS[color_scheme]
496 c_header = colours.get('header', '')
497 c_reset = colours.get('reset', '')
500 if 'RODC' in rodc_status:
501 epilog.append('No outbound connections are expected from RODCs')
503 if not talk_to_remote:
504 # If we are not talking to remote servers, we list all
506 graph_edges = edges.keys()
507 title = 'NTDS Connections known to %s' % local_dsa_dn
510 # If we are talking to the remotes, there are
511 # interesting cases we can discover. What matters most
512 # is that the destination (i.e. owner) knowns about
513 # the connection, but it would be worth noting if the
514 # source doesn't. Another strange situation could be
515 # when a DC thinks there is a connection elsewhere,
516 # but the computers allegedly involved don't believe
519 # With limited bandwidth in the table, we mark the
520 # edges known to the destination, and note the other
521 # cases in a list after the diagram.
526 for e, conn in edges.items():
527 if conn.dest_attests:
528 graph_edges.append(e)
529 if not conn.src_attests:
530 source_denies.append(e)
531 elif conn.src_attests:
532 dest_denies.append(e)
536 title = 'NTDS Connections known to each destination DC'
539 epilog.append('The following connections are alleged by '
540 'DCs other than the source and '
543 epilog.append(' %s -> %s\n' % e)
545 epilog.append('The following connections are alleged by '
546 'DCs other than the destination but '
547 'including the source:\n')
548 for e in dest_denies:
549 epilog.append(' %s -> %s\n' % e)
551 epilog.append('The following connections '
552 '(included in the chart) '
553 'are not known to the source DC:\n')
554 for e in source_denies:
555 epilog.append(' %s -> %s\n' % e)
557 s = distance_matrix(vertices, graph_edges,
560 shorten_names=shorten_names,
562 grouping_function=get_dnstrlist_site,
563 row_comments=rodc_status)
565 epilog = ''.join(epilog)
567 epilog = '\n%sNOTES%s\n%s' % (c_header,
571 self.write('\n%s\n\n%s\n%s' % (title,
580 n_servers = len(dsas)
581 for k, e in sorted(edges.items()):
583 if e.observations == n_servers or not talk_to_remote:
584 edge_colours.append('#000000')
585 edge_styles.append('')
587 edge_styles.append('')
589 edge_colours.append('#0000ff')
591 edge_colours.append('#cc00ff')
593 edge_colours.append('#ff0000')
594 edge_styles.append('style=dashed')
596 edge_colours.append('#ff0000')
597 edge_styles.append('style=dotted')
601 key_items.append((False,
604 for colour, desc in (('#0000ff', "missing from some DCs"),
605 ('#cc00ff', "missing from source DC")):
606 if colour in edge_colours:
607 key_items.append((False, 'color="%s"' % colour, desc))
609 for style, desc in (('style=dashed', "unknown to destination"),
611 "unknown to source and destination")):
612 if style in edge_styles:
613 key_items.append((False,
614 'color="#ff0000; %s"' % style,
618 title = 'NTDS Connections'
620 title = 'NTDS Connections known to %s' % local_dsa_dn
622 s = dot_graph(sorted(vertices), dot_edges,
625 edge_colors=edge_colours,
626 edge_labels=edge_labels,
627 edge_styles=edge_styles,
628 shorten_names=shorten_names,
632 self.call_xdot(s, output)
634 self.write(s, output)
637 class cmd_uptodateness(GraphCommand):
638 """visualize uptodateness vectors"""
640 takes_options = COMMON_OPTIONS + [
641 Option("-p", "--partition", help="restrict to this partition",
643 Option("--max-digits", default=3, type=int,
644 help="display this many digits of out-of-date-ness"),
647 def run(self, H=None, output=None, shorten_names=False,
648 key=True, talk_to_remote=False,
649 sambaopts=None, credopts=None, versionopts=None,
651 utf8=False, format=None, importldif=None,
652 xdot=False, partition=None, max_digits=3):
653 if not talk_to_remote:
654 print("this won't work without talking to the remote servers "
655 "(use -r)", file=self.outf)
658 # We use the KCC libraries in readonly mode to get the
660 lp = sambaopts.get_loadparm()
661 creds = credopts.get_credentials(lp, fallback_machine=True)
662 local_kcc, dsas = get_kcc_and_dsas(H, lp, creds)
663 self.samdb = local_kcc.samdb
664 partition = get_partition(self.samdb, partition)
666 short_partitions, long_partitions = get_partition_maps(self.samdb)
667 color_scheme = self.calc_distance_color_scheme(color_scheme,
670 for part_name, part_dn in short_partitions.items():
671 if partition not in (part_dn, None):
672 continue # we aren't doing this partition
674 utdv_edges = get_utdv_edges(local_kcc, dsas, part_dn, lp, creds)
676 distances = get_utdv_distances(utdv_edges, dsas)
678 max_distance = get_utdv_max_distance(distances)
680 digits = min(max_digits, len(str(max_distance)))
683 c_scale = 10 ** digits
685 s = full_matrix(distances,
688 shorten_names=shorten_names,
690 grouping_function=get_dnstr_site,
691 colour_scale=c_scale,
694 xlabel='out-of-date-ness')
696 self.write('\n%s\n\n%s' % (part_name, s), output)
699 class cmd_visualize(SuperCommand):
700 """Produces graphical representations of Samba network state."""
703 for k, v in globals().items():
704 if k.startswith('cmd_'):
705 subcommands[k[4:]] = v()