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
35 from samba.kcc import KCC
36 from samba.kcc.kcc_utils import KCCError
39 Option("-H", "--URL", help="LDB URL for database or target server",
40 type=str, metavar="URL", dest="H"),
41 Option("-o", "--output", help="write here (default stdout)",
42 type=str, metavar="FILE", default=None),
43 Option("--dot", help="Graphviz dot output", dest='format',
44 const='dot', action='store_const'),
45 Option("--distance", help="Distance matrix graph output (default)",
46 dest='format', const='distance', action='store_const'),
47 Option("--utf8", help="Use utf-8 Unicode characters",
49 Option("--color", help="use color (yes, no, auto)",
50 choices=['yes', 'no', 'auto']),
51 Option("--color-scheme", help=("use this colour scheme "
52 "(implies --color=yes)"),
53 choices=COLOUR_SETS.keys()),
54 Option("-S", "--shorten-names",
55 help="don't print long common suffixes",
56 action='store_true', default=False),
57 Option("-r", "--talk-to-remote", help="query other DCs' databases",
58 action='store_true', default=False),
59 Option("--no-key", help="omit the explanatory key",
60 action='store_false', default=True, dest='key'),
63 TEMP_FILE = '__temp__'
66 class GraphCommand(Command):
67 """Base class for graphing commands"""
69 synopsis = "%prog [options]"
70 takes_optiongroups = {
71 "sambaopts": options.SambaOptions,
72 "versionopts": options.VersionOptions,
73 "credopts": options.CredentialsOptions,
75 takes_options = COMMON_OPTIONS
78 def get_db(self, H, sambaopts, credopts):
79 lp = sambaopts.get_loadparm()
80 creds = credopts.get_credentials(lp, fallback_machine=True)
81 samdb = SamDB(url=H, credentials=creds, lp=lp)
84 def get_kcc_and_dsas(self, H, lp, creds):
85 """Get a readonly KCC object and the list of DSAs it knows about."""
86 unix_now = int(time.time())
87 kcc = KCC(unix_now, readonly=True)
88 kcc.load_samdb(H, lp, creds)
90 dsa_list = kcc.list_dsas()
92 if len(dsas) != len(dsa_list):
93 print("There seem to be duplicate dsas", file=sys.stderr)
97 def write(self, s, fn=None, suffix='.dot'):
98 """Decide whether we're dealing with a filename, a tempfile, or
99 stdout, and write accordingly.
101 :param s: the string to write
102 :param fn: a destination
103 :param suffix: suffix, if destination is a tempfile
105 If fn is None or "-", write to stdout.
106 If fn is visualize.TEMP_FILE, write to a temporary file
107 Otherwise fn should be a filename to write to.
109 if fn is None or fn == '-':
110 # we're just using stdout (a.k.a self.outf)
111 print(s, file=self.outf)
115 fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
126 def calc_output_format(self, format, output):
127 """Heuristics to work out what output format was wanted."""
129 # They told us nothing! We have to work it out for ourselves.
130 if output and output.lower().endswith('.dot'):
136 def calc_distance_color_scheme(self, color, color_scheme, output):
137 """Heuristics to work out the colour scheme for distance matrices.
138 Returning None means no colour, otherwise it sould be a colour
139 from graph.COLOUR_SETS"""
144 if isinstance(output, str) and output != '-':
146 if not hasattr(self.outf, 'isatty'):
147 # not a real file, perhaps cStringIO in testing
149 if not self.outf.isatty():
152 if color_scheme is None:
153 if '256color' in os.environ.get('TERM', ''):
154 return 'xterm-256color-heatmap'
161 """Generate a randomish but consistent darkish colour based on the
163 from hashlib import md5
164 c = int(md5(str(x)).hexdigest()[:6], base=16) & 0x7f7f7f
168 def get_partition_maps(samdb):
169 """Generate dictionaries mapping short partition names to the
171 base_dn = samdb.domain_dn()
174 "CONFIGURATION": str(samdb.get_config_basedn()),
175 "SCHEMA": "CN=Schema,%s" % samdb.get_config_basedn(),
176 "DNSDOMAIN": "DC=DomainDnsZones,%s" % base_dn,
177 "DNSFOREST": "DC=ForestDnsZones,%s" % base_dn
179 long_to_short = {v: k for k, v in short_to_long.iteritems()}
180 return short_to_long, long_to_short
183 class cmd_reps(GraphCommand):
184 "repsFrom/repsTo from every DSA"
186 takes_options = COMMON_OPTIONS + [
187 Option("-p", "--partition", help="restrict to this partition",
191 def run(self, H=None, output=None, shorten_names=False,
192 key=True, talk_to_remote=False,
193 sambaopts=None, credopts=None, versionopts=None,
194 mode='self', partition=None, color=None, color_scheme=None,
195 utf8=None, format=None):
196 # We use the KCC libraries in readonly mode to get the
198 lp = sambaopts.get_loadparm()
199 creds = credopts.get_credentials(lp, fallback_machine=True)
200 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
201 unix_now = local_kcc.unix_now
203 # Allow people to say "--partition=DOMAIN" rather than
204 # "--partition=DC=blah,DC=..."
205 short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
206 if partition is not None:
207 partition = short_partitions.get(partition.upper(), partition)
208 if partition not in long_partitions:
209 raise CommandError("unknown partition %s" % partition)
211 # nc_reps is an autovivifying dictionary of dictionaries of lists.
212 # nc_reps[partition]['current' | 'needed'] is a list of
213 # (dsa dn string, repsFromTo object) pairs.
214 nc_reps = defaultdict(lambda: defaultdict(list))
218 # We run a new KCC for each DSA even if we aren't talking to
219 # the remote, because after kcc.run (or kcc.list_dsas) the kcc
220 # ends up in a messy state.
222 kcc = KCC(unix_now, readonly=True)
224 res = local_kcc.samdb.search(dsa_dn,
226 attrs=["dNSHostName"])
227 dns_name = res[0]["dNSHostName"][0]
228 print("Attempting to contact ldap://%s (%s)" %
232 kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
233 except KCCError as e:
234 print("Could not contact ldap://%s (%s)" % (dns_name, e),
238 kcc.run(H, lp, creds)
240 kcc.load_samdb(H, lp, creds)
241 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
243 dsas_from_here = set(kcc.list_dsas())
244 if dsas != dsas_from_here:
245 print("found extra DSAs:", file=sys.stderr)
246 for dsa in (dsas_from_here - dsas):
247 print(" %s" % dsa, file=sys.stderr)
248 print("missing DSAs (known locally, not by %s):" % dsa_dn,
250 for dsa in (dsas - dsas_from_here):
251 print(" %s" % dsa, file=sys.stderr)
253 for remote_dn in dsas_from_here:
254 if mode == 'others' and remote_dn == dsa_dn:
256 elif mode == 'self' and remote_dn != dsa_dn:
259 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
260 kcc.translate_ntdsconn(remote_dsa)
261 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
262 # get_reps_tables() returns two dictionaries mapping
263 # dns to NCReplica objects
264 c, n = remote_dsa.get_rep_tables()
265 for part, rep in c.iteritems():
266 if partition is None or part == partition:
267 nc_reps[part]['current'].append((dsa_dn, rep))
268 for part, rep in n.iteritems():
269 if partition is None or part == partition:
270 nc_reps[part]['needed'].append((dsa_dn, rep))
272 all_edges = {'needed': {'to': [], 'from': []},
273 'current': {'to': [], 'from': []}}
275 for partname, part in nc_reps.iteritems():
276 for state, edgelists in all_edges.iteritems():
277 for dsa_dn, rep in part[state]:
278 short_name = long_partitions.get(partname, partname)
279 for r in rep.rep_repsFrom:
280 edgelists['from'].append(
282 guid_to_dnstr[str(r.source_dsa_obj_guid)],
284 for r in rep.rep_repsTo:
285 edgelists['to'].append(
286 (guid_to_dnstr[str(r.source_dsa_obj_guid)],
290 # Here we have the set of edges. From now it is a matter of
291 # interpretation and presentation.
293 if self.calc_output_format(format, output) == 'distance':
294 color_scheme = self.calc_distance_color_scheme(color,
298 'from': "RepsFrom objects for %s",
299 'to': "RepsTo objects for %s",
301 for state, edgelists in all_edges.iteritems():
302 for direction, items in edgelists.iteritems():
303 part_edges = defaultdict(list)
304 for src, dest, part in items:
305 part_edges[part].append((src, dest))
306 for part, edges in part_edges.iteritems():
307 s = distance_matrix(None, edges,
310 shorten_names=shorten_names,
313 s = "\n%s\n%s" % (header_strings[direction] % part, s)
314 self.write(s, output)
323 for state, edgelist in all_edges.iteritems():
324 for direction, items in edgelist.iteritems():
325 for src, dest, part in items:
326 colour = used_colours.setdefault((part),
329 linestyle = 'dotted' if state == 'needed' else 'solid'
330 arrow = 'open' if direction == 'to' else 'empty'
331 dot_vertices.add(src)
332 dot_vertices.add(dest)
333 dot_edges.append((src, dest))
334 edge_colours.append(colour)
335 style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
336 edge_styles.append(style)
337 key_set.add((part, 'reps' + direction.title(),
342 for part, direction, colour, linestyle in sorted(key_set):
343 key_items.append((False,
344 'color="%s"; %s' % (colour, linestyle),
345 "%s %s" % (part, direction)))
346 key_items.append((False,
347 'style="dotted"; arrowhead="open"',
348 "repsFromTo is needed"))
349 key_items.append((False,
350 'style="solid"; arrowhead="open"',
351 "repsFromTo currently exists"))
353 s = dot_graph(dot_vertices, dot_edges,
355 edge_colors=edge_colours,
356 edge_styles=edge_styles,
357 shorten_names=shorten_names,
360 self.write(s, output)
363 class NTDSConn(object):
364 """Collects observation counts for NTDS connections, so we know
365 whether all DSAs agree."""
366 def __init__(self, src, dest):
367 self.observations = 0
368 self.src_attests = False
369 self.dest_attests = False
373 def attest(self, attester):
374 self.observations += 1
375 if attester == self.src:
376 self.src_attests = True
377 if attester == self.dest:
378 self.dest_attests = True
381 class cmd_ntdsconn(GraphCommand):
382 "Draw the NTDSConnection graph"
383 def run(self, H=None, output=None, shorten_names=False,
384 key=True, talk_to_remote=False,
385 sambaopts=None, credopts=None, versionopts=None,
386 color=None, color_scheme=None,
387 utf8=None, format=None):
388 lp = sambaopts.get_loadparm()
389 creds = credopts.get_credentials(lp, fallback_machine=True)
390 local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
396 res = local_kcc.samdb.search(dsa_dn,
398 attrs=["dNSHostName"])
399 dns_name = res[0]["dNSHostName"][0]
401 samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
403 except LdbError as e:
404 print("Could not contact ldap://%s (%s)" % (dns_name, e),
408 ntds_dn = samdb.get_dsServiceName()
409 dn = samdb.domain_dn()
411 samdb = self.get_db(H, sambaopts, credopts)
412 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
415 vertices.add(ntds_dn)
416 # XXX we could also look at schedule
417 res = samdb.search(dn,
419 expression="(objectClass=nTDSConnection)",
420 attrs=['fromServer'],
421 # XXX can't be critical for ldif test
422 #controls=["search_options:1:2"],
423 controls=["search_options:0:2"],
428 dest_dn = msgdn[msgdn.index(',') + 1:]
429 attested_edges.append((msg['fromServer'][0],
432 # now we overlay all the graphs and generate styles accordingly
434 for src, dest, attester in attested_edges:
443 if self.calc_output_format(format, output) == 'distance':
444 color_scheme = self.calc_distance_color_scheme(color,
447 if not talk_to_remote:
448 # If we are not talking to remote servers, we list all
450 graph_edges = edges.keys()
451 title = 'NTDS Connections known to %s' % dsa_dn
455 # If we are talking to the remotes, there are
456 # interesting cases we can discover. What matters most
457 # is that the destination (i.e. owner) knowns about
458 # the connection, but it would be worth noting if the
459 # source doesn't. Another strange situation could be
460 # when a DC thinks there is a connection elsewhere,
461 # but the computers allegedly involved don't believe
464 # With limited bandwidth in the table, we mark the
465 # edges known to the destination, and note the other
466 # cases in a list after the diagram.
471 for e, conn in edges.iteritems():
472 if conn.dest_attests:
473 graph_edges.append(e)
474 if not conn.src_attests:
475 source_denies.append(e)
476 elif conn.src_attests:
477 dest_denies.append(e)
481 title = 'NTDS Connections known to each destination DC'
484 epilog.append('The following connections are alleged by '
485 'DCs other than the source and '
488 epilog.append(' %s -> %s\n' % e)
490 epilog.append('The following connections are alleged by '
491 'DCs other than the destination but '
492 'including the source:\n')
493 for e in dest_denies:
494 epilog.append(' %s -> %s\n' % e)
496 epilog.append('The following connections '
497 '(included in the chart) '
498 'are not known to the source DC:\n')
499 for e in source_denies:
500 epilog.append(' %s -> %s\n' % e)
501 epilog = ''.join(epilog)
503 s = distance_matrix(sorted(vertices), graph_edges,
506 shorten_names=shorten_names,
508 self.write('\n%s\n%s\n%s' % (title, s, epilog), output)
515 n_servers = len(dsas)
516 for k, e in sorted(edges.iteritems()):
518 if e.observations == n_servers or not talk_to_remote:
519 edge_colours.append('#000000')
520 edge_styles.append('')
522 edge_styles.append('')
524 edge_colours.append('#0000ff')
526 edge_colours.append('#cc00ff')
528 edge_colours.append('#ff0000')
529 edge_styles.append('style=dashed')
531 edge_colours.append('#ff0000')
532 edge_styles.append('style=dotted')
536 key_items.append((False,
539 for colour, desc in (('#0000ff', "missing from some DCs"),
540 ('#cc00ff', "missing from source DC")):
541 if colour in edge_colours:
542 key_items.append((False, 'color="%s"' % colour, desc))
544 for style, desc in (('style=dashed', "unknown to destination"),
546 "unknown to source and destination")):
547 if style in edge_styles:
548 key_items.append((False,
549 'color="#ff0000; %s"' % style,
553 title = 'NTDS Connections'
555 title = 'NTDS Connections known to %s' % dsa_dn
557 s = dot_graph(sorted(vertices), dot_edges,
560 edge_colors=edge_colours,
561 edge_labels=edge_labels,
562 edge_styles=edge_styles,
563 shorten_names=shorten_names,
565 self.write(s, output)
568 class cmd_visualize(SuperCommand):
569 """Produces graphical representations of Samba network state"""
572 for k, v in globals().iteritems():
573 if k.startswith('cmd_'):
574 subcommands[k[4:]] = v()