sambatool visualize: add up-to-dateness visualization
[vlendec/samba-autobuild/.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 import subprocess
26
27 import tempfile
28 import samba.getopt as options
29 from samba import dsdb
30 from samba import nttime2unix
31 from samba.netcmd import Command, SuperCommand, CommandError, Option
32 from samba.samdb import SamDB
33 from samba.graph import dot_graph
34 from samba.graph import distance_matrix, COLOUR_SETS
35 from samba.graph import full_matrix
36 from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
37 import time
38 import re
39 from samba.kcc import KCC, ldif_import_export
40 from samba.kcc.kcc_utils import KCCError
41 from samba.compat import text_type
42
43 COMMON_OPTIONS = [
44     Option("-H", "--URL", help="LDB URL for database or target server",
45            type=str, metavar="URL", dest="H"),
46     Option("-o", "--output", help="write here (default stdout)",
47            type=str, metavar="FILE", default=None),
48     Option("--distance", help="Distance matrix graph output (default)",
49            dest='format', const='distance', action='store_const'),
50     Option("--utf8", help="Use utf-8 Unicode characters",
51            action='store_true'),
52     Option("--color", help="use color (yes, no, auto)",
53            choices=['yes', 'no', 'auto']),
54     Option("--color-scheme", help=("use this colour scheme "
55                                    "(implies --color=yes)"),
56            choices=list(COLOUR_SETS.keys())),
57     Option("-S", "--shorten-names",
58            help="don't print long common suffixes",
59            action='store_true', default=False),
60     Option("-r", "--talk-to-remote", help="query other DCs' databases",
61            action='store_true', default=False),
62     Option("--no-key", help="omit the explanatory key",
63            action='store_false', default=True, dest='key'),
64 ]
65
66 DOT_OPTIONS = [
67     Option("--dot", help="Graphviz dot output", dest='format',
68            const='dot', action='store_const'),
69     Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
70            const='xdot', action='store_const'),
71 ]
72
73 TEMP_FILE = '__temp__'
74
75
76 class GraphCommand(Command):
77     """Base class for graphing commands"""
78
79     synopsis = "%prog [options]"
80     takes_optiongroups = {
81         "sambaopts": options.SambaOptions,
82         "versionopts": options.VersionOptions,
83         "credopts": options.CredentialsOptions,
84     }
85     takes_options = COMMON_OPTIONS + DOT_OPTIONS
86     takes_args = ()
87
88     def get_db(self, H, sambaopts, credopts):
89         lp = sambaopts.get_loadparm()
90         creds = credopts.get_credentials(lp, fallback_machine=True)
91         samdb = SamDB(url=H, credentials=creds, lp=lp)
92         return samdb
93
94     def get_kcc_and_dsas(self, H, lp, creds):
95         """Get a readonly KCC object and the list of DSAs it knows about."""
96         unix_now = int(time.time())
97         kcc = KCC(unix_now, readonly=True)
98         kcc.load_samdb(H, lp, creds)
99
100         dsa_list = kcc.list_dsas()
101         dsas = set(dsa_list)
102         if len(dsas) != len(dsa_list):
103             print("There seem to be duplicate dsas", file=sys.stderr)
104
105         return kcc, dsas
106
107     def write(self, s, fn=None, suffix='.dot'):
108         """Decide whether we're dealing with a filename, a tempfile, or
109         stdout, and write accordingly.
110
111         :param s: the string to write
112         :param fn: a destination
113         :param suffix: suffix, if destination is a tempfile
114
115         If fn is None or "-", write to stdout.
116         If fn is visualize.TEMP_FILE, write to a temporary file
117         Otherwise fn should be a filename to write to.
118         """
119         if fn is None or fn == '-':
120             # we're just using stdout (a.k.a self.outf)
121             print(s, file=self.outf)
122             return
123
124         if fn is TEMP_FILE:
125             fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
126                                       suffix=suffix)
127             f = open(fn, 'w')
128             os.close(fd)
129         else:
130             f = open(fn, 'w')
131
132         f.write(s)
133         f.close()
134         return fn
135
136     def calc_output_format(self, format, output):
137         """Heuristics to work out what output format was wanted."""
138         if not format:
139             # They told us nothing! We have to work it out for ourselves.
140             if output and output.lower().endswith('.dot'):
141                 return 'dot'
142             else:
143                 return 'distance'
144
145         if format == 'xdot':
146             return 'dot'
147
148         return format
149
150     def call_xdot(self, s, output):
151         if output is None:
152             fn = self.write(s, TEMP_FILE)
153         else:
154             fn = self.write(s, output)
155         xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
156         subprocess.call([xdot, fn])
157         os.remove(fn)
158
159     def calc_distance_color_scheme(self, color, color_scheme, output):
160         """Heuristics to work out the colour scheme for distance matrices.
161         Returning None means no colour, otherwise it sould be a colour
162         from graph.COLOUR_SETS"""
163         if color == 'no':
164             return None
165
166         if color == 'auto':
167             if isinstance(output, str) and output != '-':
168                 return None
169             if not hasattr(self.outf, 'isatty'):
170                 # not a real file, perhaps cStringIO in testing
171                 return None
172             if not self.outf.isatty():
173                 return None
174
175         if color_scheme is None:
176             if '256color' in os.environ.get('TERM', ''):
177                 return 'xterm-256color-heatmap'
178             return 'ansi'
179
180         return color_scheme
181
182
183 def get_dnstr_site(dn):
184     """Helper function for sorting and grouping DNs by site, if
185     possible."""
186     m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
187     if m:
188         return m.group(1)
189     # Oh well, let it sort by DN
190     return dn
191
192
193 def get_dnstrlist_site(t):
194     """Helper function for sorting and grouping lists of (DN, ...) tuples
195     by site, if possible."""
196     return get_dnstr_site(t[0])
197
198
199 def colour_hash(x):
200     """Generate a randomish but consistent darkish colour based on the
201     given object."""
202     from hashlib import md5
203     tmp_str = str(x)
204     if isinstance(tmp_str, text_type):
205         tmp_str = tmp_str.encode('utf8')
206     c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
207     return '#%06x' % c
208
209
210 def get_partition_maps(samdb):
211     """Generate dictionaries mapping short partition names to the
212     appropriate DNs."""
213     base_dn = samdb.domain_dn()
214     short_to_long = {
215         "DOMAIN": base_dn,
216         "CONFIGURATION": str(samdb.get_config_basedn()),
217         "SCHEMA": "CN=Schema,%s" % samdb.get_config_basedn(),
218         "DNSDOMAIN": "DC=DomainDnsZones,%s" % base_dn,
219         "DNSFOREST": "DC=ForestDnsZones,%s" % base_dn
220     }
221
222     long_to_short = {}
223     for s, l in short_to_long.items():
224         long_to_short[l] = s
225
226     return short_to_long, long_to_short
227
228
229 def get_partition(samdb, part):
230     # Allow people to say "--partition=DOMAIN" rather than
231     # "--partition=DC=blah,DC=..."
232     if part is not None:
233         short_partitions, long_partitions = get_partition_maps(samdb)
234         part = short_partitions.get(part.upper(), part)
235         if part not in long_partitions:
236             raise CommandError("unknown partition %s" % part)
237     return part
238
239
240 class cmd_reps(GraphCommand):
241     "repsFrom/repsTo from every DSA"
242
243     takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
244         Option("-p", "--partition", help="restrict to this partition",
245                default=None),
246     ]
247
248     def run(self, H=None, output=None, shorten_names=False,
249             key=True, talk_to_remote=False,
250             sambaopts=None, credopts=None, versionopts=None,
251             mode='self', partition=None, color=None, color_scheme=None,
252             utf8=None, format=None, xdot=False):
253         # We use the KCC libraries in readonly mode to get the
254         # replication graph.
255         lp = sambaopts.get_loadparm()
256         creds = credopts.get_credentials(lp, fallback_machine=True)
257         local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
258         unix_now = local_kcc.unix_now
259
260         partition = get_partition(local_kcc.samdb, partition)
261
262         # nc_reps is an autovivifying dictionary of dictionaries of lists.
263         # nc_reps[partition]['current' | 'needed'] is a list of
264         # (dsa dn string, repsFromTo object) pairs.
265         nc_reps = defaultdict(lambda: defaultdict(list))
266
267         guid_to_dnstr = {}
268
269         # We run a new KCC for each DSA even if we aren't talking to
270         # the remote, because after kcc.run (or kcc.list_dsas) the kcc
271         # ends up in a messy state.
272         for dsa_dn in dsas:
273             kcc = KCC(unix_now, readonly=True)
274             if talk_to_remote:
275                 res = local_kcc.samdb.search(dsa_dn,
276                                              scope=SCOPE_BASE,
277                                              attrs=["dNSHostName"])
278                 dns_name = res[0]["dNSHostName"][0]
279                 print("Attempting to contact ldap://%s (%s)" %
280                       (dns_name, dsa_dn),
281                       file=sys.stderr)
282                 try:
283                     kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
284                 except KCCError as e:
285                     print("Could not contact ldap://%s (%s)" % (dns_name, e),
286                           file=sys.stderr)
287                     continue
288
289                 kcc.run(H, lp, creds)
290             else:
291                 kcc.load_samdb(H, lp, creds)
292                 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
293
294             dsas_from_here = set(kcc.list_dsas())
295             if dsas != dsas_from_here:
296                 print("found extra DSAs:", file=sys.stderr)
297                 for dsa in (dsas_from_here - dsas):
298                     print("   %s" % dsa, file=sys.stderr)
299                 print("missing DSAs (known locally, not by %s):" % dsa_dn,
300                       file=sys.stderr)
301                 for dsa in (dsas - dsas_from_here):
302                     print("   %s" % dsa, file=sys.stderr)
303
304             for remote_dn in dsas_from_here:
305                 if mode == 'others' and remote_dn == dsa_dn:
306                     continue
307                 elif mode == 'self' and remote_dn != dsa_dn:
308                     continue
309
310                 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
311                 kcc.translate_ntdsconn(remote_dsa)
312                 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
313                 # get_reps_tables() returns two dictionaries mapping
314                 # dns to NCReplica objects
315                 c, n = remote_dsa.get_rep_tables()
316                 for part, rep in c.items():
317                     if partition is None or part == partition:
318                         nc_reps[part]['current'].append((dsa_dn, rep))
319                 for part, rep in n.items():
320                     if partition is None or part == partition:
321                         nc_reps[part]['needed'].append((dsa_dn, rep))
322
323         all_edges = {'needed':  {'to': [], 'from': []},
324                      'current': {'to': [], 'from': []}}
325
326         short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
327
328         for partname, part in nc_reps.items():
329             for state, edgelists in all_edges.items():
330                 for dsa_dn, rep in part[state]:
331                     short_name = long_partitions.get(partname, partname)
332                     for r in rep.rep_repsFrom:
333                         edgelists['from'].append(
334                             (dsa_dn,
335                              guid_to_dnstr[str(r.source_dsa_obj_guid)],
336                              short_name))
337                     for r in rep.rep_repsTo:
338                         edgelists['to'].append(
339                             (guid_to_dnstr[str(r.source_dsa_obj_guid)],
340                              dsa_dn,
341                              short_name))
342
343         # Here we have the set of edges. From now it is a matter of
344         # interpretation and presentation.
345
346         if self.calc_output_format(format, output) == 'distance':
347             color_scheme = self.calc_distance_color_scheme(color,
348                                                            color_scheme,
349                                                            output)
350             header_strings = {
351                 'from': "RepsFrom objects for %s",
352                 'to': "RepsTo objects for %s",
353             }
354             for state, edgelists in all_edges.items():
355                 for direction, items in edgelists.items():
356                     part_edges = defaultdict(list)
357                     for src, dest, part in items:
358                         part_edges[part].append((src, dest))
359                     for part, edges in part_edges.items():
360                         s = distance_matrix(None, edges,
361                                             utf8=utf8,
362                                             colour=color_scheme,
363                                             shorten_names=shorten_names,
364                                             generate_key=key,
365                                             grouping_function=get_dnstr_site)
366
367                         s = "\n%s\n%s" % (header_strings[direction] % part, s)
368                         self.write(s, output)
369             return
370
371         edge_colours = []
372         edge_styles = []
373         dot_edges = []
374         dot_vertices = set()
375         used_colours = {}
376         key_set = set()
377         for state, edgelist in all_edges.items():
378             for direction, items in edgelist.items():
379                 for src, dest, part in items:
380                     colour = used_colours.setdefault((part),
381                                                      colour_hash((part,
382                                                                   direction)))
383                     linestyle = 'dotted' if state == 'needed' else 'solid'
384                     arrow = 'open' if direction == 'to' else 'empty'
385                     dot_vertices.add(src)
386                     dot_vertices.add(dest)
387                     dot_edges.append((src, dest))
388                     edge_colours.append(colour)
389                     style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
390                     edge_styles.append(style)
391                     key_set.add((part, 'reps' + direction.title(),
392                                  colour, style))
393
394         key_items = []
395         if key:
396             for part, direction, colour, linestyle in sorted(key_set):
397                 key_items.append((False,
398                                   'color="%s"; %s' % (colour, linestyle),
399                                   "%s %s" % (part, direction)))
400             key_items.append((False,
401                               'style="dotted"; arrowhead="open"',
402                               "repsFromTo is needed"))
403             key_items.append((False,
404                               'style="solid"; arrowhead="open"',
405                               "repsFromTo currently exists"))
406
407         s = dot_graph(dot_vertices, dot_edges,
408                       directed=True,
409                       edge_colors=edge_colours,
410                       edge_styles=edge_styles,
411                       shorten_names=shorten_names,
412                       key_items=key_items)
413
414         if format == 'xdot':
415             self.call_xdot(s, output)
416         else:
417             self.write(s, output)
418
419
420 class NTDSConn(object):
421     """Collects observation counts for NTDS connections, so we know
422     whether all DSAs agree."""
423     def __init__(self, src, dest):
424         self.observations = 0
425         self.src_attests = False
426         self.dest_attests = False
427         self.src = src
428         self.dest = dest
429
430     def attest(self, attester):
431         self.observations += 1
432         if attester == self.src:
433             self.src_attests = True
434         if attester == self.dest:
435             self.dest_attests = True
436
437
438 class cmd_ntdsconn(GraphCommand):
439     "Draw the NTDSConnection graph"
440     takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
441         Option("--importldif", help="graph from samba_kcc generated ldif",
442                default=None),
443     ]
444
445     def import_ldif_db(self, ldif, lp):
446         d = tempfile.mkdtemp(prefix='samba-tool-visualise')
447         fn = os.path.join(d, 'imported.ldb')
448         self._tmp_fn_to_delete = fn
449         samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
450         return fn
451
452     def run(self, H=None, output=None, shorten_names=False,
453             key=True, talk_to_remote=False,
454             sambaopts=None, credopts=None, versionopts=None,
455             color=None, color_scheme=None,
456             utf8=None, format=None, importldif=None,
457             xdot=False):
458
459         lp = sambaopts.get_loadparm()
460         if importldif is None:
461             creds = credopts.get_credentials(lp, fallback_machine=True)
462         else:
463             creds = None
464             H = self.import_ldif_db(importldif, lp)
465
466         local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
467         local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
468         vertices = set()
469         attested_edges = []
470         for dsa_dn in dsas:
471             if talk_to_remote:
472                 res = local_kcc.samdb.search(dsa_dn,
473                                              scope=SCOPE_BASE,
474                                              attrs=["dNSHostName"])
475                 dns_name = res[0]["dNSHostName"][0]
476                 try:
477                     samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
478                                         credopts)
479                 except LdbError as e:
480                     print("Could not contact ldap://%s (%s)" % (dns_name, e),
481                           file=sys.stderr)
482                     continue
483
484                 ntds_dn = samdb.get_dsServiceName()
485                 dn = samdb.domain_dn()
486             else:
487                 samdb = self.get_db(H, sambaopts, credopts)
488                 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
489                 dn = dsa_dn
490
491             res = samdb.search(ntds_dn,
492                                scope=SCOPE_BASE,
493                                attrs=["msDS-isRODC"])
494
495             is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
496
497             vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
498             # XXX we could also look at schedule
499             res = samdb.search(dn,
500                                scope=SCOPE_SUBTREE,
501                                expression="(objectClass=nTDSConnection)",
502                                attrs=['fromServer'],
503                                # XXX can't be critical for ldif test
504                                #controls=["search_options:1:2"],
505                                controls=["search_options:0:2"],
506             )
507
508             for msg in res:
509                 msgdn = str(msg.dn)
510                 dest_dn = msgdn[msgdn.index(',') + 1:]
511                 attested_edges.append((msg['fromServer'][0],
512                                        dest_dn, ntds_dn))
513
514         if importldif and H == self._tmp_fn_to_delete:
515             os.remove(H)
516             os.rmdir(os.path.dirname(H))
517
518         # now we overlay all the graphs and generate styles accordingly
519         edges = {}
520         for src, dest, attester in attested_edges:
521             k = (src, dest)
522             if k in edges:
523                 e = edges[k]
524             else:
525                 e = NTDSConn(*k)
526                 edges[k] = e
527             e.attest(attester)
528
529         vertices, rodc_status = zip(*sorted(vertices))
530
531         if self.calc_output_format(format, output) == 'distance':
532             color_scheme = self.calc_distance_color_scheme(color,
533                                                            color_scheme,
534                                                            output)
535             colours = COLOUR_SETS[color_scheme]
536             c_header = colours.get('header', '')
537             c_reset = colours.get('reset', '')
538
539             epilog = []
540             if 'RODC' in rodc_status:
541                 epilog.append('No outbound connections are expected from RODCs')
542
543             if not talk_to_remote:
544                 # If we are not talking to remote servers, we list all
545                 # the connections.
546                 graph_edges = edges.keys()
547                 title = 'NTDS Connections known to %s' % local_dsa_dn
548
549             else:
550                 # If we are talking to the remotes, there are
551                 # interesting cases we can discover. What matters most
552                 # is that the destination (i.e. owner) knowns about
553                 # the connection, but it would be worth noting if the
554                 # source doesn't. Another strange situation could be
555                 # when a DC thinks there is a connection elsewhere,
556                 # but the computers allegedly involved don't believe
557                 # it exists.
558                 #
559                 # With limited bandwidth in the table, we mark the
560                 # edges known to the destination, and note the other
561                 # cases in a list after the diagram.
562                 graph_edges = []
563                 source_denies = []
564                 dest_denies = []
565                 both_deny = []
566                 for e, conn in edges.items():
567                     if conn.dest_attests:
568                         graph_edges.append(e)
569                         if not conn.src_attests:
570                             source_denies.append(e)
571                     elif conn.src_attests:
572                         dest_denies.append(e)
573                     else:
574                         both_deny.append(e)
575
576                 title = 'NTDS Connections known to each destination DC'
577
578                 if both_deny:
579                     epilog.append('The following connections are alleged by '
580                                   'DCs other than the source and '
581                                   'destination:\n')
582                     for e in both_deny:
583                         epilog.append('  %s -> %s\n' % e)
584                 if dest_denies:
585                     epilog.append('The following connections are alleged by '
586                                   'DCs other than the destination but '
587                                   'including the source:\n')
588                     for e in dest_denies:
589                         epilog.append('  %s -> %s\n' % e)
590                 if source_denies:
591                     epilog.append('The following connections '
592                                   '(included in the chart) '
593                                   'are not known to the source DC:\n')
594                     for e in source_denies:
595                         epilog.append('  %s -> %s\n' % e)
596
597             s = distance_matrix(vertices, graph_edges,
598                                 utf8=utf8,
599                                 colour=color_scheme,
600                                 shorten_names=shorten_names,
601                                 generate_key=key,
602                                 grouping_function=get_dnstrlist_site,
603                                 row_comments=rodc_status)
604
605             epilog = ''.join(epilog)
606             if epilog:
607                 epilog = '\n%sNOTES%s\n%s' % (c_header,
608                                               c_reset,
609                                               epilog)
610
611             self.write('\n%s\n\n%s\n%s' % (title,
612                                            s,
613                                            epilog), output)
614             return
615
616         dot_edges = []
617         edge_colours = []
618         edge_styles = []
619         edge_labels = []
620         n_servers = len(dsas)
621         for k, e in sorted(edges.items()):
622             dot_edges.append(k)
623             if e.observations == n_servers or not talk_to_remote:
624                 edge_colours.append('#000000')
625                 edge_styles.append('')
626             elif e.dest_attests:
627                 edge_styles.append('')
628                 if e.src_attests:
629                     edge_colours.append('#0000ff')
630                 else:
631                     edge_colours.append('#cc00ff')
632             elif e.src_attests:
633                 edge_colours.append('#ff0000')
634                 edge_styles.append('style=dashed')
635             else:
636                 edge_colours.append('#ff0000')
637                 edge_styles.append('style=dotted')
638
639         key_items = []
640         if key:
641             key_items.append((False,
642                               'color="#000000"',
643                               "NTDS Connection"))
644             for colour, desc in (('#0000ff', "missing from some DCs"),
645                                  ('#cc00ff', "missing from source DC")):
646                 if colour in edge_colours:
647                     key_items.append((False, 'color="%s"' % colour, desc))
648
649             for style, desc in (('style=dashed', "unknown to destination"),
650                                 ('style=dotted',
651                                  "unknown to source and destination")):
652                 if style in edge_styles:
653                     key_items.append((False,
654                                       'color="#ff0000; %s"' % style,
655                                       desc))
656
657         if talk_to_remote:
658             title = 'NTDS Connections'
659         else:
660             title = 'NTDS Connections known to %s' % local_dsa_dn
661
662         s = dot_graph(sorted(vertices), dot_edges,
663                       directed=True,
664                       title=title,
665                       edge_colors=edge_colours,
666                       edge_labels=edge_labels,
667                       edge_styles=edge_styles,
668                       shorten_names=shorten_names,
669                       key_items=key_items)
670
671         if format == 'xdot':
672             self.call_xdot(s, output)
673         else:
674             self.write(s, output)
675
676
677 class cmd_uptodateness(GraphCommand):
678     """visualize uptodateness vectors"""
679
680     takes_options = COMMON_OPTIONS + [
681         Option("-p", "--partition", help="restrict to this partition",
682                default=None),
683         Option("--max-digits", default=3, type=int,
684                help="display this many digits of out-of-date-ness"),
685     ]
686
687     def get_utdv(self, samdb, dn):
688         """This finds the uptodateness vector in the database."""
689         cursors = []
690         config_dn = samdb.get_config_basedn()
691         for c in dsdb._dsdb_load_udv_v2(samdb, dn):
692             inv_id = str(c.source_dsa_invocation_id)
693             res = samdb.search(base=config_dn,
694                                expression=("(&(invocationId=%s)"
695                                            "(objectClass=nTDSDSA))" % inv_id),
696                                attrs=["distinguishedName", "invocationId"])
697             settings_dn = res[0]["distinguishedName"][0]
698             prefix, dsa_dn = settings_dn.split(',', 1)
699             if prefix != 'CN=NTDS Settings':
700                 raise CommandError("Expected NTDS Settings DN, got %s" %
701                                    settings_dn)
702
703             cursors.append((dsa_dn,
704                             inv_id,
705                             int(c.highest_usn),
706                             nttime2unix(c.last_sync_success)))
707         return cursors
708
709     def get_own_cursor(self, samdb):
710             res = samdb.search(base="",
711                                scope=SCOPE_BASE,
712                                attrs=["highestCommittedUSN"])
713             usn = int(res[0]["highestCommittedUSN"][0])
714             now = int(time.time())
715             return (usn, now)
716
717     def run(self, H=None, output=None, shorten_names=False,
718             key=True, talk_to_remote=False,
719             sambaopts=None, credopts=None, versionopts=None,
720             color=None, color_scheme=None,
721             utf8=False, format=None, importldif=None,
722             xdot=False, partition=None, max_digits=3):
723         if not talk_to_remote:
724             print("this won't work without talking to the remote servers "
725                   "(use -r)", file=self.outf)
726             return
727
728         # We use the KCC libraries in readonly mode to get the
729         # replication graph.
730         lp = sambaopts.get_loadparm()
731         creds = credopts.get_credentials(lp, fallback_machine=True)
732         local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
733         self.samdb = local_kcc.samdb
734         partition = get_partition(self.samdb, partition)
735
736         short_partitions, long_partitions = get_partition_maps(self.samdb)
737         color_scheme = self.calc_distance_color_scheme(color,
738                                                        color_scheme,
739                                                        output)
740
741         for part_name, part_dn in short_partitions.items():
742             if partition not in (part_dn, None):
743                 continue  # we aren't doing this partition
744
745             cursors = self.get_utdv(self.samdb, part_dn)
746
747             # we talk to each remote and make a matrix of the vectors
748             # -- for each partition
749             # normalise by oldest
750             utdv_edges = {}
751             for dsa_dn in dsas:
752                 res = local_kcc.samdb.search(dsa_dn,
753                                              scope=SCOPE_BASE,
754                                              attrs=["dNSHostName"])
755                 ldap_url = "ldap://%s" % res[0]["dNSHostName"][0]
756                 try:
757                     samdb = self.get_db(ldap_url, sambaopts, credopts)
758                     cursors = self.get_utdv(samdb, part_dn)
759                     own_usn, own_time = self.get_own_cursor(samdb)
760                     remotes = {dsa_dn: own_usn}
761                     for dn, guid, usn, t in cursors:
762                         remotes[dn] = usn
763                 except LdbError as e:
764                     print("Could not contact %s (%s)" % (ldap_url, e),
765                           file=sys.stderr)
766                     continue
767                 utdv_edges[dsa_dn] = remotes
768
769             distances = {}
770             max_distance = 0
771             for dn1 in dsas:
772                 try:
773                     peak = utdv_edges[dn1][dn1]
774                 except KeyError as e:
775                     peak = 0
776                 d = {}
777                 distances[dn1] = d
778                 for dn2 in dsas:
779                     if dn2 in utdv_edges:
780                         if dn1 in utdv_edges[dn2]:
781                             dist = peak - utdv_edges[dn2][dn1]
782                             d[dn2] = dist
783                             if dist > max_distance:
784                                 max_distance = dist
785                         else:
786                             print("Missing dn %s from UTD vector" % dn1,
787                                   file=sys.stderr)
788                     else:
789                         print("missing dn %s from UTD vector list" % dn2,
790                               file=sys.stderr)
791
792             digits = min(max_digits, len(str(max_distance)))
793             if digits < 1:
794                 digits = 1
795             c_scale = 10 ** digits
796
797             s = full_matrix(distances,
798                             utf8=utf8,
799                             colour=color_scheme,
800                             shorten_names=shorten_names,
801                             generate_key=key,
802                             grouping_function=get_dnstr_site,
803                             colour_scale=c_scale,
804                             digits=digits,
805                             ylabel='DC',
806                             xlabel='out-of-date-ness')
807
808             self.write('\n%s\n\n%s' % (part_name, s), output)
809
810
811 class cmd_visualize(SuperCommand):
812     """Produces graphical representations of Samba network state"""
813     subcommands = {}
814
815     for k, v in globals().items():
816         if k.startswith('cmd_'):
817             subcommands[k[4:]] = v()