473872a7d72871e6d36fa817df9649929b2916d9
[metze/samba/wip.git] / python / samba / netcmd / visualize.py
1 # Visualisation tools
2 #
3 # Copyright (C) Andrew Bartlett 2015, 2018
4 #
5 # by Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
6 #
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.
11 #
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.
16 #
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/>.
19
20 from __future__ import print_function
21
22 import os
23 import sys
24 from collections import defaultdict
25
26 import tempfile
27 import samba
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
34 import time
35 from samba.kcc import KCC
36 from samba.kcc.kcc_utils import KCCError
37
38 COMMON_OPTIONS = [
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",
48            action='store_true'),
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'),
61 ]
62
63 TEMP_FILE = '__temp__'
64
65
66 class GraphCommand(Command):
67     """Base class for graphing commands"""
68
69     synopsis = "%prog [options]"
70     takes_optiongroups = {
71         "sambaopts": options.SambaOptions,
72         "versionopts": options.VersionOptions,
73         "credopts": options.CredentialsOptions,
74     }
75     takes_options = COMMON_OPTIONS
76     takes_args = ()
77
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)
82         return samdb
83
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)
89
90         dsa_list = kcc.list_dsas()
91         dsas = set(dsa_list)
92         if len(dsas) != len(dsa_list):
93             print("There seem to be duplicate dsas", file=sys.stderr)
94
95         return kcc, dsas
96
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.
100
101         :param s: the string to write
102         :param fn: a destination
103         :param suffix: suffix, if destination is a tempfile
104
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.
108         """
109         if fn is None or fn == '-':
110             # we're just using stdout (a.k.a self.outf)
111             print(s, file=self.outf)
112             return
113
114         if fn is TEMP_FILE:
115             fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
116                                       suffix=suffix)
117             f = open(fn, 'w')
118             os.close(fd)
119         else:
120             f = open(fn, 'w')
121
122         f.write(s)
123         f.close()
124         return fn
125
126     def calc_output_format(self, format, output):
127         """Heuristics to work out what output format was wanted."""
128         if not format:
129             # They told us nothing! We have to work it out for ourselves.
130             if output and output.lower().endswith('.dot'):
131                 return 'dot'
132             else:
133                 return 'distance'
134         return format
135
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"""
140         if color == 'no':
141             return None
142
143         if color == 'auto':
144             if isinstance(output, str) and output != '-':
145                 return None
146             if not hasattr(self.outf, 'isatty'):
147                 # not a real file, perhaps cStringIO in testing
148                 return None
149             if not self.outf.isatty():
150                 return None
151
152         if color_scheme is None:
153             if '256color' in os.environ.get('TERM', ''):
154                 return 'xterm-256color-heatmap'
155             return 'ansi'
156
157         return color_scheme
158
159
160 def colour_hash(x):
161     """Generate a randomish but consistent darkish colour based on the
162     given object."""
163     from hashlib import md5
164     c = int(md5(str(x)).hexdigest()[:6], base=16) & 0x7f7f7f
165     return '#%06x' % c
166
167
168 def get_partition_maps(samdb):
169     """Generate dictionaries mapping short partition names to the
170     appropriate DNs."""
171     base_dn = samdb.domain_dn()
172     short_to_long = {
173         "DOMAIN": base_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
178     }
179     long_to_short = {v: k for k, v in short_to_long.iteritems()}
180     return short_to_long, long_to_short
181
182
183 class cmd_reps(GraphCommand):
184     "repsFrom/repsTo from every DSA"
185
186     takes_options = COMMON_OPTIONS + [
187         Option("-p", "--partition", help="restrict to this partition",
188                default=None),
189     ]
190
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
197         # replication graph.
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
202
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)
210
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))
215
216         guid_to_dnstr = {}
217
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.
221         for dsa_dn in dsas:
222             kcc = KCC(unix_now, readonly=True)
223             if talk_to_remote:
224                 res = local_kcc.samdb.search(dsa_dn,
225                                              scope=SCOPE_BASE,
226                                              attrs=["dNSHostName"])
227                 dns_name = res[0]["dNSHostName"][0]
228                 print("Attempting to contact ldap://%s (%s)" %
229                       (dns_name, dsa_dn),
230                       file=sys.stderr)
231                 try:
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),
235                           file=sys.stderr)
236                     continue
237
238                 kcc.run(H, lp, creds)
239             else:
240                 kcc.load_samdb(H, lp, creds)
241                 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
242
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,
249                       file=sys.stderr)
250                 for dsa in (dsas - dsas_from_here):
251                     print("   %s" % dsa, file=sys.stderr)
252
253             for remote_dn in dsas_from_here:
254                 if mode == 'others' and remote_dn == dsa_dn:
255                     continue
256                 elif mode == 'self' and remote_dn != dsa_dn:
257                     continue
258
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))
271
272         all_edges = {'needed':  {'to': [], 'from': []},
273                      'current': {'to': [], 'from': []}}
274
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(
281                             (dsa_dn,
282                              guid_to_dnstr[str(r.source_dsa_obj_guid)],
283                              short_name))
284                     for r in rep.rep_repsTo:
285                         edgelists['to'].append(
286                             (guid_to_dnstr[str(r.source_dsa_obj_guid)],
287                              dsa_dn,
288                              short_name))
289
290         # Here we have the set of edges. From now it is a matter of
291         # interpretation and presentation.
292
293         if self.calc_output_format(format, output) == 'distance':
294             color_scheme = self.calc_distance_color_scheme(color,
295                                                            color_scheme,
296                                                            output)
297             header_strings = {
298                 'from': "RepsFrom objects for %s",
299                 'to': "RepsTo objects for %s",
300             }
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,
308                                             utf8=utf8,
309                                             colour=color_scheme,
310                                             shorten_names=shorten_names,
311                                             generate_key=key)
312
313                         s = "\n%s\n%s" % (header_strings[direction] % part, s)
314                         self.write(s, output)
315             return
316
317         edge_colours = []
318         edge_styles = []
319         dot_edges = []
320         dot_vertices = set()
321         used_colours = {}
322         key_set = set()
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),
327                                                      colour_hash((part,
328                                                                   direction)))
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(),
338                                  colour, style))
339
340         key_items = []
341         if key:
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"))
352
353         s = dot_graph(dot_vertices, dot_edges,
354                       directed=True,
355                       edge_colors=edge_colours,
356                       edge_styles=edge_styles,
357                       shorten_names=shorten_names,
358                       key_items=key_items)
359
360         self.write(s, output)
361
362
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
370         self.src = src
371         self.dest = dest
372
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
379
380
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)
391
392         vertices = set()
393         attested_edges = []
394         for dsa_dn in dsas:
395             if talk_to_remote:
396                 res = local_kcc.samdb.search(dsa_dn,
397                                              scope=SCOPE_BASE,
398                                              attrs=["dNSHostName"])
399                 dns_name = res[0]["dNSHostName"][0]
400                 try:
401                     samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
402                                         credopts)
403                 except LdbError as e:
404                     print("Could not contact ldap://%s (%s)" % (dns_name, e),
405                           file=sys.stderr)
406                     continue
407
408                 ntds_dn = samdb.get_dsServiceName()
409                 dn = samdb.domain_dn()
410             else:
411                 samdb = self.get_db(H, sambaopts, credopts)
412                 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
413                 dn = dsa_dn
414
415             vertices.add(ntds_dn)
416             # XXX we could also look at schedule
417             res = samdb.search(dn,
418                                scope=SCOPE_SUBTREE,
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"],
424             )
425
426             for msg in res:
427                 msgdn = str(msg.dn)
428                 dest_dn = msgdn[msgdn.index(',') + 1:]
429                 attested_edges.append((msg['fromServer'][0],
430                                        dest_dn, ntds_dn))
431
432         # now we overlay all the graphs and generate styles accordingly
433         edges = {}
434         for src, dest, attester in attested_edges:
435             k = (src, dest)
436             if k in edges:
437                 e = edges[k]
438             else:
439                 e = NTDSConn(*k)
440                 edges[k] = e
441             e.attest(attester)
442
443         if self.calc_output_format(format, output) == 'distance':
444             color_scheme = self.calc_distance_color_scheme(color,
445                                                            color_scheme,
446                                                            output)
447             if not talk_to_remote:
448                 # If we are not talking to remote servers, we list all
449                 # the connections.
450                 graph_edges = edges.keys()
451                 title = 'NTDS Connections known to %s' % dsa_dn
452                 epilog = ''
453
454             else:
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
462                 # it exists.
463                 #
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.
467                 graph_edges = []
468                 source_denies = []
469                 dest_denies = []
470                 both_deny = []
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)
478                     else:
479                         both_deny.append(e)
480
481                 title = 'NTDS Connections known to each destination DC'
482                 epilog = []
483                 if both_deny:
484                     epilog.append('The following connections are alleged by '
485                                   'DCs other than the source and '
486                                   'destination:\n')
487                     for e in both_deny:
488                         epilog.append('  %s -> %s\n' % e)
489                 if dest_denies:
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)
495                 if source_denies:
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)
502
503             s = distance_matrix(sorted(vertices), graph_edges,
504                                 utf8=utf8,
505                                 colour=color_scheme,
506                                 shorten_names=shorten_names,
507                                 generate_key=key)
508             self.write('\n%s\n%s\n%s' % (title, s, epilog), output)
509             return
510
511         dot_edges = []
512         edge_colours = []
513         edge_styles = []
514         edge_labels = []
515         n_servers = len(dsas)
516         for k, e in sorted(edges.iteritems()):
517             dot_edges.append(k)
518             if e.observations == n_servers or not talk_to_remote:
519                 edge_colours.append('#000000')
520                 edge_styles.append('')
521             elif e.dest_attests:
522                 edge_styles.append('')
523                 if e.src_attests:
524                     edge_colours.append('#0000ff')
525                 else:
526                     edge_colours.append('#cc00ff')
527             elif e.src_attests:
528                 edge_colours.append('#ff0000')
529                 edge_styles.append('style=dashed')
530             else:
531                 edge_colours.append('#ff0000')
532                 edge_styles.append('style=dotted')
533
534         key_items = []
535         if key:
536             key_items.append((False,
537                               'color="#000000"',
538                               "NTDS Connection"))
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))
543
544             for style, desc in (('style=dashed', "unknown to destination"),
545                                 ('style=dotted',
546                                  "unknown to source and destination")):
547                 if style in edge_styles:
548                     key_items.append((False,
549                                       'color="#ff0000; %s"' % style,
550                                       desc))
551
552         if talk_to_remote:
553             title = 'NTDS Connections'
554         else:
555             title = 'NTDS Connections known to %s' % dsa_dn
556
557         s = dot_graph(sorted(vertices), dot_edges,
558                       directed=True,
559                       title=title,
560                       edge_colors=edge_colours,
561                       edge_labels=edge_labels,
562                       edge_styles=edge_styles,
563                       shorten_names=shorten_names,
564                       key_items=key_items)
565         self.write(s, output)
566
567
568 class cmd_visualize(SuperCommand):
569     """Produces graphical representations of Samba network state"""
570     subcommands = {}
571
572     for k, v in globals().iteritems():
573         if k.startswith('cmd_'):
574             subcommands[k[4:]] = v()