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.netcmd import Command, SuperCommand, CommandError, Option
30 from samba.samdb import SamDB
31 from samba.graph import dot_graph
32 from samba.graph import distance_matrix, COLOUR_SETS
33 from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
36 from samba.kcc import KCC, ldif_import_export
37 from samba.kcc.kcc_utils import KCCError
38 from samba.compat import text_type
41 Option("-H", "--URL", help="LDB URL for database or target server",
42 type=str, metavar="URL", dest="H"),
43 Option("-o", "--output", help="write here (default stdout)",
44 type=str, metavar="FILE", default=None),
45 Option("--dot", help="Graphviz dot output", dest='format',
46 const='dot', action='store_const'),
47 Option("--distance", help="Distance matrix graph output (default)",
48 dest='format', const='distance', action='store_const'),
49 Option("--utf8", help="Use utf-8 Unicode characters",
51 Option("--color", help="use color (yes, no, auto)",
52 choices=['yes', 'no', 'auto']),
53 Option("--color-scheme", help=("use this colour scheme "
54 "(implies --color=yes)"),
55 choices=list(COLOUR_SETS.keys())),
56 Option("-S", "--shorten-names",
57 help="don't print long common suffixes",
58 action='store_true', default=False),
59 Option("-r", "--talk-to-remote", help="query other DCs' databases",
60 action='store_true', default=False),
61 Option("--no-key", help="omit the explanatory key",
62 action='store_false', default=True, dest='key'),
65 TEMP_FILE = '__temp__'
68 class GraphCommand(Command):
69 """Base class for graphing commands"""
71 synopsis = "%prog [options]"
72 takes_optiongroups = {
73 "sambaopts": options.SambaOptions,
74 "versionopts": options.VersionOptions,
75 "credopts": options.CredentialsOptions,
77 takes_options = COMMON_OPTIONS
80 def get_db(self, H, sambaopts, credopts):
81 lp = sambaopts.get_loadparm()
82 creds = credopts.get_credentials(lp, fallback_machine=True)
83 samdb = SamDB(url=H, credentials=creds, lp=lp)
86 def get_kcc_and_dsas(self, H, lp, creds):
87 """Get a readonly KCC object and the list of DSAs it knows about."""
88 unix_now = int(time.time())
89 kcc = KCC(unix_now, readonly=True)
90 kcc.load_samdb(H, lp, creds)
92 dsa_list = kcc.list_dsas()
94 if len(dsas) != len(dsa_list):
95 print("There seem to be duplicate dsas", file=sys.stderr)
99 def write(self, s, fn=None, suffix='.dot'):
100 """Decide whether we're dealing with a filename, a tempfile, or
101 stdout, and write accordingly.
103 :param s: the string to write
104 :param fn: a destination
105 :param suffix: suffix, if destination is a tempfile
107 If fn is None or "-", write to stdout.
108 If fn is visualize.TEMP_FILE, write to a temporary file
109 Otherwise fn should be a filename to write to.
111 if fn is None or fn == '-':
112 # we're just using stdout (a.k.a self.outf)
113 print(s, file=self.outf)
117 fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
128 def calc_output_format(self, format, output):
129 """Heuristics to work out what output format was wanted."""
131 # They told us nothing! We have to work it out for ourselves.
132 if output and output.lower().endswith('.dot'):
138 def calc_distance_color_scheme(self, color, color_scheme, output):
139 """Heuristics to work out the colour scheme for distance matrices.
140 Returning None means no colour, otherwise it sould be a colour
141 from graph.COLOUR_SETS"""
146 if isinstance(output, str) and output != '-':
148 if not hasattr(self.outf, 'isatty'):
149 # not a real file, perhaps cStringIO in testing
151 if not self.outf.isatty():
154 if color_scheme is None:
155 if '256color' in os.environ.get('TERM', ''):
156 return 'xterm-256color-heatmap'
162 def get_dnstr_site(dn):
163 """Helper function for sorting and grouping DNs by site, if
165 m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
168 # Oh well, let it sort by DN
173 """Generate a randomish but consistent darkish colour based on the
175 from hashlib import md5
177 if isinstance(tmp_str, text_type):
178 tmp_str = tmp_str.encode('utf8')
179 c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
183 def get_partition_maps(samdb):
184 """Generate dictionaries mapping short partition names to the
186 base_dn = samdb.domain_dn()
189 "CONFIGURATION": str(samdb.get_config_basedn()),
190 "SCHEMA": "CN=Schema,%s" % samdb.get_config_basedn(),
191 "DNSDOMAIN": "DC=DomainDnsZones,%s" % base_dn,
192 "DNSFOREST": "DC=ForestDnsZones,%s" % base_dn
196 for s, l in short_to_long.items():
199 return short_to_long, long_to_short
202 class cmd_reps(GraphCommand):
203 "repsFrom/repsTo from every DSA"
205 takes_options = COMMON_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=None, color_scheme=None,
214 utf8=None, format=None):
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 = self.get_kcc_and_dsas(H, lp, creds)
220 unix_now = local_kcc.unix_now
222 # Allow people to say "--partition=DOMAIN" rather than
223 # "--partition=DC=blah,DC=..."
224 short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
225 if partition is not None:
226 partition = short_partitions.get(partition.upper(), partition)
227 if partition not in long_partitions:
228 raise CommandError("unknown partition %s" % partition)
230 # nc_reps is an autovivifying dictionary of dictionaries of lists.
231 # nc_reps[partition]['current' | 'needed'] is a list of
232 # (dsa dn string, repsFromTo object) pairs.
233 nc_reps = defaultdict(lambda: defaultdict(list))
237 # We run a new KCC for each DSA even if we aren't talking to
238 # the remote, because after kcc.run (or kcc.list_dsas) the kcc
239 # ends up in a messy state.
241 kcc = KCC(unix_now, readonly=True)
243 res = local_kcc.samdb.search(dsa_dn,
245 attrs=["dNSHostName"])
246 dns_name = res[0]["dNSHostName"][0]
247 print("Attempting to contact ldap://%s (%s)" %
251 kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
252 except KCCError as e:
253 print("Could not contact ldap://%s (%s)" % (dns_name, e),
257 kcc.run(H, lp, creds)
259 kcc.load_samdb(H, lp, creds)
260 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
262 dsas_from_here = set(kcc.list_dsas())
263 if dsas != dsas_from_here:
264 print("found extra DSAs:", file=sys.stderr)
265 for dsa in (dsas_from_here - dsas):
266 print(" %s" % dsa, file=sys.stderr)
267 print("missing DSAs (known locally, not by %s):" % dsa_dn,
269 for dsa in (dsas - dsas_from_here):
270 print(" %s" % dsa, file=sys.stderr)
272 for remote_dn in dsas_from_here:
273 if mode == 'others' and remote_dn == dsa_dn:
275 elif mode == 'self' and remote_dn != dsa_dn:
278 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
279 kcc.translate_ntdsconn(remote_dsa)
280 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
281 # get_reps_tables() returns two dictionaries mapping
282 # dns to NCReplica objects
283 c, n = remote_dsa.get_rep_tables()
284 for part, rep in c.items():
285 if partition is None or part == partition:
286 nc_reps[part]['current'].append((dsa_dn, rep))
287 for part, rep in n.items():
288 if partition is None or part == partition:
289 nc_reps[part]['needed'].append((dsa_dn, rep))
291 all_edges = {'needed': {'to': [], 'from': []},
292 'current': {'to': [], 'from': []}}
294 for partname, part in nc_reps.items():
295 for state, edgelists in all_edges.items():
296 for dsa_dn, rep in part[state]:
297 short_name = long_partitions.get(partname, partname)
298 for r in rep.rep_repsFrom:
299 edgelists['from'].append(
301 guid_to_dnstr[str(r.source_dsa_obj_guid)],
303 for r in rep.rep_repsTo:
304 edgelists['to'].append(
305 (guid_to_dnstr[str(r.source_dsa_obj_guid)],
309 # Here we have the set of edges. From now it is a matter of
310 # interpretation and presentation.
312 if self.calc_output_format(format, output) == 'distance':
313 color_scheme = self.calc_distance_color_scheme(color,
317 'from': "RepsFrom objects for %s",
318 'to': "RepsTo objects for %s",
320 for state, edgelists in all_edges.items():
321 for direction, items in edgelists.items():
322 part_edges = defaultdict(list)
323 for src, dest, part in items:
324 part_edges[part].append((src, dest))
325 for part, edges in part_edges.items():
326 s = distance_matrix(None, edges,
329 shorten_names=shorten_names,
331 grouping_function=get_dnstr_site)
333 s = "\n%s\n%s" % (header_strings[direction] % part, s)
334 self.write(s, output)
343 for state, edgelist in all_edges.items():
344 for direction, items in edgelist.items():
345 for src, dest, part in items:
346 colour = used_colours.setdefault((part),
349 linestyle = 'dotted' if state == 'needed' else 'solid'
350 arrow = 'open' if direction == 'to' else 'empty'
351 dot_vertices.add(src)
352 dot_vertices.add(dest)
353 dot_edges.append((src, dest))
354 edge_colours.append(colour)
355 style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
356 edge_styles.append(style)
357 key_set.add((part, 'reps' + direction.title(),
362 for part, direction, colour, linestyle in sorted(key_set):
363 key_items.append((False,
364 'color="%s"; %s' % (colour, linestyle),
365 "%s %s" % (part, direction)))
366 key_items.append((False,
367 'style="dotted"; arrowhead="open"',
368 "repsFromTo is needed"))
369 key_items.append((False,
370 'style="solid"; arrowhead="open"',
371 "repsFromTo currently exists"))
373 s = dot_graph(dot_vertices, dot_edges,
375 edge_colors=edge_colours,
376 edge_styles=edge_styles,
377 shorten_names=shorten_names,
380 self.write(s, output)
383 class NTDSConn(object):
384 """Collects observation counts for NTDS connections, so we know
385 whether all DSAs agree."""
386 def __init__(self, src, dest):
387 self.observations = 0
388 self.src_attests = False
389 self.dest_attests = False
393 def attest(self, attester):
394 self.observations += 1
395 if attester == self.src:
396 self.src_attests = True
397 if attester == self.dest:
398 self.dest_attests = True
401 class cmd_ntdsconn(GraphCommand):
402 "Draw the NTDSConnection graph"
403 takes_options = COMMON_OPTIONS + [
404 Option("--importldif", help="graph from samba_kcc generated ldif",
408 def import_ldif_db(self, ldif, lp):
409 d = tempfile.mkdtemp(prefix='samba-tool-visualise')
410 fn = os.path.join(d, 'imported.ldb')
411 self._tmp_fn_to_delete = fn
412 samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
415 def run(self, H=None, output=None, shorten_names=False,
416 key=True, talk_to_remote=False,
417 sambaopts=None, credopts=None, versionopts=None,
418 color=None, color_scheme=None,
419 utf8=None, format=None, importldif=None):
421 lp = sambaopts.get_loadparm()
422 if importldif is None:
423 creds = credopts.get_credentials(lp, fallback_machine=True)
426 H = self.import_ldif_db(importldif, lp)
428 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
429 local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
434 res = local_kcc.samdb.search(dsa_dn,
436 attrs=["dNSHostName"])
437 dns_name = res[0]["dNSHostName"][0]
439 samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
441 except LdbError as e:
442 print("Could not contact ldap://%s (%s)" % (dns_name, e),
446 ntds_dn = samdb.get_dsServiceName()
447 dn = samdb.domain_dn()
449 samdb = self.get_db(H, sambaopts, credopts)
450 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
453 res = samdb.search(ntds_dn,
455 attrs=["msDS-isRODC"])
457 is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
459 vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
460 # XXX we could also look at schedule
461 res = samdb.search(dn,
463 expression="(objectClass=nTDSConnection)",
464 attrs=['fromServer'],
465 # XXX can't be critical for ldif test
466 #controls=["search_options:1:2"],
467 controls=["search_options:0:2"],
472 dest_dn = msgdn[msgdn.index(',') + 1:]
473 attested_edges.append((msg['fromServer'][0],
476 if importldif and H == self._tmp_fn_to_delete:
478 os.rmdir(os.path.dirname(H))
480 # now we overlay all the graphs and generate styles accordingly
482 for src, dest, attester in attested_edges:
491 vertices, rodc_status = zip(*sorted(vertices))
493 if self.calc_output_format(format, output) == 'distance':
494 color_scheme = self.calc_distance_color_scheme(color,
497 colours = COLOUR_SETS[color_scheme]
498 c_header = colours.get('header', '')
499 c_reset = colours.get('reset', '')
502 if 'RODC' in rodc_status:
503 epilog.append('No outbound connections are expected from RODCs')
505 if not talk_to_remote:
506 # If we are not talking to remote servers, we list all
508 graph_edges = edges.keys()
509 title = 'NTDS Connections known to %s' % local_dsa_dn
512 # If we are talking to the remotes, there are
513 # interesting cases we can discover. What matters most
514 # is that the destination (i.e. owner) knowns about
515 # the connection, but it would be worth noting if the
516 # source doesn't. Another strange situation could be
517 # when a DC thinks there is a connection elsewhere,
518 # but the computers allegedly involved don't believe
521 # With limited bandwidth in the table, we mark the
522 # edges known to the destination, and note the other
523 # cases in a list after the diagram.
528 for e, conn in edges.items():
529 if conn.dest_attests:
530 graph_edges.append(e)
531 if not conn.src_attests:
532 source_denies.append(e)
533 elif conn.src_attests:
534 dest_denies.append(e)
538 title = 'NTDS Connections known to each destination DC'
541 epilog.append('The following connections are alleged by '
542 'DCs other than the source and '
545 epilog.append(' %s -> %s\n' % e)
547 epilog.append('The following connections are alleged by '
548 'DCs other than the destination but '
549 'including the source:\n')
550 for e in dest_denies:
551 epilog.append(' %s -> %s\n' % e)
553 epilog.append('The following connections '
554 '(included in the chart) '
555 'are not known to the source DC:\n')
556 for e in source_denies:
557 epilog.append(' %s -> %s\n' % e)
560 s = distance_matrix(vertices, graph_edges,
563 shorten_names=shorten_names,
565 grouping_function=get_dnstr_site,
566 row_comments=rodc_status)
568 epilog = ''.join(epilog)
570 epilog = '\n%sNOTES%s\n%s' % (c_header,
574 self.write('\n%s\n\n%s\n%s' % (title,
583 n_servers = len(dsas)
584 for k, e in sorted(edges.items()):
586 if e.observations == n_servers or not talk_to_remote:
587 edge_colours.append('#000000')
588 edge_styles.append('')
590 edge_styles.append('')
592 edge_colours.append('#0000ff')
594 edge_colours.append('#cc00ff')
596 edge_colours.append('#ff0000')
597 edge_styles.append('style=dashed')
599 edge_colours.append('#ff0000')
600 edge_styles.append('style=dotted')
604 key_items.append((False,
607 for colour, desc in (('#0000ff', "missing from some DCs"),
608 ('#cc00ff', "missing from source DC")):
609 if colour in edge_colours:
610 key_items.append((False, 'color="%s"' % colour, desc))
612 for style, desc in (('style=dashed', "unknown to destination"),
614 "unknown to source and destination")):
615 if style in edge_styles:
616 key_items.append((False,
617 'color="#ff0000; %s"' % style,
621 title = 'NTDS Connections'
623 title = 'NTDS Connections known to %s' % local_dsa_dn
625 s = dot_graph(sorted(vertices), dot_edges,
628 edge_colors=edge_colours,
629 edge_labels=edge_labels,
630 edge_styles=edge_styles,
631 shorten_names=shorten_names,
633 self.write(s, output)
636 class cmd_visualize(SuperCommand):
637 """Produces graphical representations of Samba network state"""
640 for k, v in globals().items():
641 if k.startswith('cmd_'):
642 subcommands[k[4:]] = v()