samba-tool visualize: separate dot options from common options
[Samba.git] / python / samba / netcmd / visualize.py
blobefae28ea0860671d141cbebe4e342b92eb17f1c2
1 # Visualisation tools
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
22 import os
23 import sys
24 from collections import defaultdict
25 import subprocess
27 import tempfile
28 import samba
29 import samba.getopt as options
30 from samba.netcmd import Command, SuperCommand, CommandError, Option
31 from samba.samdb import SamDB
32 from samba.graph import dot_graph
33 from samba.graph import distance_matrix, COLOUR_SETS
34 from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
35 import time
36 import re
37 from samba.kcc import KCC, ldif_import_export
38 from samba.kcc.kcc_utils import KCCError
39 from samba.compat import text_type
41 COMMON_OPTIONS = [
42 Option("-H", "--URL", help="LDB URL for database or target server",
43 type=str, metavar="URL", dest="H"),
44 Option("-o", "--output", help="write here (default stdout)",
45 type=str, metavar="FILE", default=None),
46 Option("--distance", help="Distance matrix graph output (default)",
47 dest='format', const='distance', action='store_const'),
48 Option("--utf8", help="Use utf-8 Unicode characters",
49 action='store_true'),
50 Option("--color", help="use color (yes, no, auto)",
51 choices=['yes', 'no', 'auto']),
52 Option("--color-scheme", help=("use this colour scheme "
53 "(implies --color=yes)"),
54 choices=list(COLOUR_SETS.keys())),
55 Option("-S", "--shorten-names",
56 help="don't print long common suffixes",
57 action='store_true', default=False),
58 Option("-r", "--talk-to-remote", help="query other DCs' databases",
59 action='store_true', default=False),
60 Option("--no-key", help="omit the explanatory key",
61 action='store_false', default=True, dest='key'),
64 DOT_OPTIONS = [
65 Option("--dot", help="Graphviz dot output", dest='format',
66 const='dot', action='store_const'),
67 Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
68 const='xdot', action='store_const'),
71 TEMP_FILE = '__temp__'
74 class GraphCommand(Command):
75 """Base class for graphing commands"""
77 synopsis = "%prog [options]"
78 takes_optiongroups = {
79 "sambaopts": options.SambaOptions,
80 "versionopts": options.VersionOptions,
81 "credopts": options.CredentialsOptions,
83 takes_options = COMMON_OPTIONS + DOT_OPTIONS
84 takes_args = ()
86 def get_db(self, H, sambaopts, credopts):
87 lp = sambaopts.get_loadparm()
88 creds = credopts.get_credentials(lp, fallback_machine=True)
89 samdb = SamDB(url=H, credentials=creds, lp=lp)
90 return samdb
92 def get_kcc_and_dsas(self, H, lp, creds):
93 """Get a readonly KCC object and the list of DSAs it knows about."""
94 unix_now = int(time.time())
95 kcc = KCC(unix_now, readonly=True)
96 kcc.load_samdb(H, lp, creds)
98 dsa_list = kcc.list_dsas()
99 dsas = set(dsa_list)
100 if len(dsas) != len(dsa_list):
101 print("There seem to be duplicate dsas", file=sys.stderr)
103 return kcc, dsas
105 def write(self, s, fn=None, suffix='.dot'):
106 """Decide whether we're dealing with a filename, a tempfile, or
107 stdout, and write accordingly.
109 :param s: the string to write
110 :param fn: a destination
111 :param suffix: suffix, if destination is a tempfile
113 If fn is None or "-", write to stdout.
114 If fn is visualize.TEMP_FILE, write to a temporary file
115 Otherwise fn should be a filename to write to.
117 if fn is None or fn == '-':
118 # we're just using stdout (a.k.a self.outf)
119 print(s, file=self.outf)
120 return
122 if fn is TEMP_FILE:
123 fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
124 suffix=suffix)
125 f = open(fn, 'w')
126 os.close(fd)
127 else:
128 f = open(fn, 'w')
130 f.write(s)
131 f.close()
132 return fn
134 def calc_output_format(self, format, output):
135 """Heuristics to work out what output format was wanted."""
136 if not format:
137 # They told us nothing! We have to work it out for ourselves.
138 if output and output.lower().endswith('.dot'):
139 return 'dot'
140 else:
141 return 'distance'
143 if format == 'xdot':
144 return 'dot'
146 return format
148 def call_xdot(self, s, output):
149 if output is None:
150 fn = self.write(s, TEMP_FILE)
151 else:
152 fn = self.write(s, output)
153 xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
154 subprocess.call([xdot, fn])
155 os.remove(fn)
157 def calc_distance_color_scheme(self, color, color_scheme, output):
158 """Heuristics to work out the colour scheme for distance matrices.
159 Returning None means no colour, otherwise it sould be a colour
160 from graph.COLOUR_SETS"""
161 if color == 'no':
162 return None
164 if color == 'auto':
165 if isinstance(output, str) and output != '-':
166 return None
167 if not hasattr(self.outf, 'isatty'):
168 # not a real file, perhaps cStringIO in testing
169 return None
170 if not self.outf.isatty():
171 return None
173 if color_scheme is None:
174 if '256color' in os.environ.get('TERM', ''):
175 return 'xterm-256color-heatmap'
176 return 'ansi'
178 return color_scheme
181 def get_dnstr_site(dn):
182 """Helper function for sorting and grouping DNs by site, if
183 possible."""
184 m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
185 if m:
186 return m.group(1)
187 # Oh well, let it sort by DN
188 return dn
191 def colour_hash(x):
192 """Generate a randomish but consistent darkish colour based on the
193 given object."""
194 from hashlib import md5
195 tmp_str = str(x)
196 if isinstance(tmp_str, text_type):
197 tmp_str = tmp_str.encode('utf8')
198 c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
199 return '#%06x' % c
202 def get_partition_maps(samdb):
203 """Generate dictionaries mapping short partition names to the
204 appropriate DNs."""
205 base_dn = samdb.domain_dn()
206 short_to_long = {
207 "DOMAIN": base_dn,
208 "CONFIGURATION": str(samdb.get_config_basedn()),
209 "SCHEMA": "CN=Schema,%s" % samdb.get_config_basedn(),
210 "DNSDOMAIN": "DC=DomainDnsZones,%s" % base_dn,
211 "DNSFOREST": "DC=ForestDnsZones,%s" % base_dn
214 long_to_short = {}
215 for s, l in short_to_long.items():
216 long_to_short[l] = s
218 return short_to_long, long_to_short
221 def get_partition(samdb, part):
222 # Allow people to say "--partition=DOMAIN" rather than
223 # "--partition=DC=blah,DC=..."
224 if part is not None:
225 short_partitions, long_partitions = get_partition_maps(samdb)
226 part = short_partitions.get(part.upper(), part)
227 if part not in long_partitions:
228 raise CommandError("unknown partition %s" % partition)
229 return part
232 class cmd_reps(GraphCommand):
233 "repsFrom/repsTo from every DSA"
235 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
236 Option("-p", "--partition", help="restrict to this partition",
237 default=None),
240 def run(self, H=None, output=None, shorten_names=False,
241 key=True, talk_to_remote=False,
242 sambaopts=None, credopts=None, versionopts=None,
243 mode='self', partition=None, color=None, color_scheme=None,
244 utf8=None, format=None, xdot=False):
245 # We use the KCC libraries in readonly mode to get the
246 # replication graph.
247 lp = sambaopts.get_loadparm()
248 creds = credopts.get_credentials(lp, fallback_machine=True)
249 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
250 unix_now = local_kcc.unix_now
252 partition = get_partition(local_kcc.samdb, partition)
254 # nc_reps is an autovivifying dictionary of dictionaries of lists.
255 # nc_reps[partition]['current' | 'needed'] is a list of
256 # (dsa dn string, repsFromTo object) pairs.
257 nc_reps = defaultdict(lambda: defaultdict(list))
259 guid_to_dnstr = {}
261 # We run a new KCC for each DSA even if we aren't talking to
262 # the remote, because after kcc.run (or kcc.list_dsas) the kcc
263 # ends up in a messy state.
264 for dsa_dn in dsas:
265 kcc = KCC(unix_now, readonly=True)
266 if talk_to_remote:
267 res = local_kcc.samdb.search(dsa_dn,
268 scope=SCOPE_BASE,
269 attrs=["dNSHostName"])
270 dns_name = res[0]["dNSHostName"][0]
271 print("Attempting to contact ldap://%s (%s)" %
272 (dns_name, dsa_dn),
273 file=sys.stderr)
274 try:
275 kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
276 except KCCError as e:
277 print("Could not contact ldap://%s (%s)" % (dns_name, e),
278 file=sys.stderr)
279 continue
281 kcc.run(H, lp, creds)
282 else:
283 kcc.load_samdb(H, lp, creds)
284 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
286 dsas_from_here = set(kcc.list_dsas())
287 if dsas != dsas_from_here:
288 print("found extra DSAs:", file=sys.stderr)
289 for dsa in (dsas_from_here - dsas):
290 print(" %s" % dsa, file=sys.stderr)
291 print("missing DSAs (known locally, not by %s):" % dsa_dn,
292 file=sys.stderr)
293 for dsa in (dsas - dsas_from_here):
294 print(" %s" % dsa, file=sys.stderr)
296 for remote_dn in dsas_from_here:
297 if mode == 'others' and remote_dn == dsa_dn:
298 continue
299 elif mode == 'self' and remote_dn != dsa_dn:
300 continue
302 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
303 kcc.translate_ntdsconn(remote_dsa)
304 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
305 # get_reps_tables() returns two dictionaries mapping
306 # dns to NCReplica objects
307 c, n = remote_dsa.get_rep_tables()
308 for part, rep in c.items():
309 if partition is None or part == partition:
310 nc_reps[part]['current'].append((dsa_dn, rep))
311 for part, rep in n.items():
312 if partition is None or part == partition:
313 nc_reps[part]['needed'].append((dsa_dn, rep))
315 all_edges = {'needed': {'to': [], 'from': []},
316 'current': {'to': [], 'from': []}}
318 short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
320 for partname, part in nc_reps.items():
321 for state, edgelists in all_edges.items():
322 for dsa_dn, rep in part[state]:
323 short_name = long_partitions.get(partname, partname)
324 for r in rep.rep_repsFrom:
325 edgelists['from'].append(
326 (dsa_dn,
327 guid_to_dnstr[str(r.source_dsa_obj_guid)],
328 short_name))
329 for r in rep.rep_repsTo:
330 edgelists['to'].append(
331 (guid_to_dnstr[str(r.source_dsa_obj_guid)],
332 dsa_dn,
333 short_name))
335 # Here we have the set of edges. From now it is a matter of
336 # interpretation and presentation.
338 if self.calc_output_format(format, output) == 'distance':
339 color_scheme = self.calc_distance_color_scheme(color,
340 color_scheme,
341 output)
342 header_strings = {
343 'from': "RepsFrom objects for %s",
344 'to': "RepsTo objects for %s",
346 for state, edgelists in all_edges.items():
347 for direction, items in edgelists.items():
348 part_edges = defaultdict(list)
349 for src, dest, part in items:
350 part_edges[part].append((src, dest))
351 for part, edges in part_edges.items():
352 s = distance_matrix(None, edges,
353 utf8=utf8,
354 colour=color_scheme,
355 shorten_names=shorten_names,
356 generate_key=key,
357 grouping_function=get_dnstr_site)
359 s = "\n%s\n%s" % (header_strings[direction] % part, s)
360 self.write(s, output)
361 return
363 edge_colours = []
364 edge_styles = []
365 dot_edges = []
366 dot_vertices = set()
367 used_colours = {}
368 key_set = set()
369 for state, edgelist in all_edges.items():
370 for direction, items in edgelist.items():
371 for src, dest, part in items:
372 colour = used_colours.setdefault((part),
373 colour_hash((part,
374 direction)))
375 linestyle = 'dotted' if state == 'needed' else 'solid'
376 arrow = 'open' if direction == 'to' else 'empty'
377 dot_vertices.add(src)
378 dot_vertices.add(dest)
379 dot_edges.append((src, dest))
380 edge_colours.append(colour)
381 style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
382 edge_styles.append(style)
383 key_set.add((part, 'reps' + direction.title(),
384 colour, style))
386 key_items = []
387 if key:
388 for part, direction, colour, linestyle in sorted(key_set):
389 key_items.append((False,
390 'color="%s"; %s' % (colour, linestyle),
391 "%s %s" % (part, direction)))
392 key_items.append((False,
393 'style="dotted"; arrowhead="open"',
394 "repsFromTo is needed"))
395 key_items.append((False,
396 'style="solid"; arrowhead="open"',
397 "repsFromTo currently exists"))
399 s = dot_graph(dot_vertices, dot_edges,
400 directed=True,
401 edge_colors=edge_colours,
402 edge_styles=edge_styles,
403 shorten_names=shorten_names,
404 key_items=key_items)
406 if format == 'xdot':
407 self.call_xdot(s, output)
408 else:
409 self.write(s, output)
412 class NTDSConn(object):
413 """Collects observation counts for NTDS connections, so we know
414 whether all DSAs agree."""
415 def __init__(self, src, dest):
416 self.observations = 0
417 self.src_attests = False
418 self.dest_attests = False
419 self.src = src
420 self.dest = dest
422 def attest(self, attester):
423 self.observations += 1
424 if attester == self.src:
425 self.src_attests = True
426 if attester == self.dest:
427 self.dest_attests = True
430 class cmd_ntdsconn(GraphCommand):
431 "Draw the NTDSConnection graph"
432 takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
433 Option("--importldif", help="graph from samba_kcc generated ldif",
434 default=None),
437 def import_ldif_db(self, ldif, lp):
438 d = tempfile.mkdtemp(prefix='samba-tool-visualise')
439 fn = os.path.join(d, 'imported.ldb')
440 self._tmp_fn_to_delete = fn
441 samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
442 return fn
444 def run(self, H=None, output=None, shorten_names=False,
445 key=True, talk_to_remote=False,
446 sambaopts=None, credopts=None, versionopts=None,
447 color=None, color_scheme=None,
448 utf8=None, format=None, importldif=None,
449 xdot=False):
451 lp = sambaopts.get_loadparm()
452 if importldif is None:
453 creds = credopts.get_credentials(lp, fallback_machine=True)
454 else:
455 creds = None
456 H = self.import_ldif_db(importldif, lp)
458 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
459 local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
460 vertices = set()
461 attested_edges = []
462 for dsa_dn in dsas:
463 if talk_to_remote:
464 res = local_kcc.samdb.search(dsa_dn,
465 scope=SCOPE_BASE,
466 attrs=["dNSHostName"])
467 dns_name = res[0]["dNSHostName"][0]
468 try:
469 samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
470 credopts)
471 except LdbError as e:
472 print("Could not contact ldap://%s (%s)" % (dns_name, e),
473 file=sys.stderr)
474 continue
476 ntds_dn = samdb.get_dsServiceName()
477 dn = samdb.domain_dn()
478 else:
479 samdb = self.get_db(H, sambaopts, credopts)
480 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
481 dn = dsa_dn
483 res = samdb.search(ntds_dn,
484 scope=SCOPE_BASE,
485 attrs=["msDS-isRODC"])
487 is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
489 vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
490 # XXX we could also look at schedule
491 res = samdb.search(dn,
492 scope=SCOPE_SUBTREE,
493 expression="(objectClass=nTDSConnection)",
494 attrs=['fromServer'],
495 # XXX can't be critical for ldif test
496 #controls=["search_options:1:2"],
497 controls=["search_options:0:2"],
500 for msg in res:
501 msgdn = str(msg.dn)
502 dest_dn = msgdn[msgdn.index(',') + 1:]
503 attested_edges.append((msg['fromServer'][0],
504 dest_dn, ntds_dn))
506 if importldif and H == self._tmp_fn_to_delete:
507 os.remove(H)
508 os.rmdir(os.path.dirname(H))
510 # now we overlay all the graphs and generate styles accordingly
511 edges = {}
512 for src, dest, attester in attested_edges:
513 k = (src, dest)
514 if k in edges:
515 e = edges[k]
516 else:
517 e = NTDSConn(*k)
518 edges[k] = e
519 e.attest(attester)
521 vertices, rodc_status = zip(*sorted(vertices))
523 if self.calc_output_format(format, output) == 'distance':
524 color_scheme = self.calc_distance_color_scheme(color,
525 color_scheme,
526 output)
527 colours = COLOUR_SETS[color_scheme]
528 c_header = colours.get('header', '')
529 c_reset = colours.get('reset', '')
531 epilog = []
532 if 'RODC' in rodc_status:
533 epilog.append('No outbound connections are expected from RODCs')
535 if not talk_to_remote:
536 # If we are not talking to remote servers, we list all
537 # the connections.
538 graph_edges = edges.keys()
539 title = 'NTDS Connections known to %s' % local_dsa_dn
541 else:
542 # If we are talking to the remotes, there are
543 # interesting cases we can discover. What matters most
544 # is that the destination (i.e. owner) knowns about
545 # the connection, but it would be worth noting if the
546 # source doesn't. Another strange situation could be
547 # when a DC thinks there is a connection elsewhere,
548 # but the computers allegedly involved don't believe
549 # it exists.
551 # With limited bandwidth in the table, we mark the
552 # edges known to the destination, and note the other
553 # cases in a list after the diagram.
554 graph_edges = []
555 source_denies = []
556 dest_denies = []
557 both_deny = []
558 for e, conn in edges.items():
559 if conn.dest_attests:
560 graph_edges.append(e)
561 if not conn.src_attests:
562 source_denies.append(e)
563 elif conn.src_attests:
564 dest_denies.append(e)
565 else:
566 both_deny.append(e)
568 title = 'NTDS Connections known to each destination DC'
570 if both_deny:
571 epilog.append('The following connections are alleged by '
572 'DCs other than the source and '
573 'destination:\n')
574 for e in both_deny:
575 epilog.append(' %s -> %s\n' % e)
576 if dest_denies:
577 epilog.append('The following connections are alleged by '
578 'DCs other than the destination but '
579 'including the source:\n')
580 for e in dest_denies:
581 epilog.append(' %s -> %s\n' % e)
582 if source_denies:
583 epilog.append('The following connections '
584 '(included in the chart) '
585 'are not known to the source DC:\n')
586 for e in source_denies:
587 epilog.append(' %s -> %s\n' % e)
590 s = distance_matrix(vertices, graph_edges,
591 utf8=utf8,
592 colour=color_scheme,
593 shorten_names=shorten_names,
594 generate_key=key,
595 grouping_function=get_dnstr_site,
596 row_comments=rodc_status)
598 epilog = ''.join(epilog)
599 if epilog:
600 epilog = '\n%sNOTES%s\n%s' % (c_header,
601 c_reset,
602 epilog)
604 self.write('\n%s\n\n%s\n%s' % (title,
606 epilog), output)
607 return
609 dot_edges = []
610 edge_colours = []
611 edge_styles = []
612 edge_labels = []
613 n_servers = len(dsas)
614 for k, e in sorted(edges.items()):
615 dot_edges.append(k)
616 if e.observations == n_servers or not talk_to_remote:
617 edge_colours.append('#000000')
618 edge_styles.append('')
619 elif e.dest_attests:
620 edge_styles.append('')
621 if e.src_attests:
622 edge_colours.append('#0000ff')
623 else:
624 edge_colours.append('#cc00ff')
625 elif e.src_attests:
626 edge_colours.append('#ff0000')
627 edge_styles.append('style=dashed')
628 else:
629 edge_colours.append('#ff0000')
630 edge_styles.append('style=dotted')
632 key_items = []
633 if key:
634 key_items.append((False,
635 'color="#000000"',
636 "NTDS Connection"))
637 for colour, desc in (('#0000ff', "missing from some DCs"),
638 ('#cc00ff', "missing from source DC")):
639 if colour in edge_colours:
640 key_items.append((False, 'color="%s"' % colour, desc))
642 for style, desc in (('style=dashed', "unknown to destination"),
643 ('style=dotted',
644 "unknown to source and destination")):
645 if style in edge_styles:
646 key_items.append((False,
647 'color="#ff0000; %s"' % style,
648 desc))
650 if talk_to_remote:
651 title = 'NTDS Connections'
652 else:
653 title = 'NTDS Connections known to %s' % local_dsa_dn
655 s = dot_graph(sorted(vertices), dot_edges,
656 directed=True,
657 title=title,
658 edge_colors=edge_colours,
659 edge_labels=edge_labels,
660 edge_styles=edge_styles,
661 shorten_names=shorten_names,
662 key_items=key_items)
664 if format == 'xdot':
665 self.call_xdot(s, output)
666 else:
667 self.write(s, output)
670 class cmd_visualize(SuperCommand):
671 """Produces graphical representations of Samba network state"""
672 subcommands = {}
674 for k, v in globals().items():
675 if k.startswith('cmd_'):
676 subcommands[k[4:]] = v()