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
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
39 from samba
.kcc
import KCC
, ldif_import_export
40 from samba
.kcc
.kcc_utils
import KCCError
41 from samba
.compat
import text_type
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",
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'),
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'),
73 TEMP_FILE
= '__temp__'
76 class GraphCommand(Command
):
77 """Base class for graphing commands"""
79 synopsis
= "%prog [options]"
80 takes_optiongroups
= {
81 "sambaopts": options
.SambaOptions
,
82 "versionopts": options
.VersionOptions
,
83 "credopts": options
.CredentialsOptions
,
85 takes_options
= COMMON_OPTIONS
+ DOT_OPTIONS
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
)
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
)
100 dsa_list
= kcc
.list_dsas()
102 if len(dsas
) != len(dsa_list
):
103 print("There seem to be duplicate dsas", file=sys
.stderr
)
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.
111 :param s: the string to write
112 :param fn: a destination
113 :param suffix: suffix, if destination is a tempfile
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.
119 if fn
is None or fn
== '-':
120 # we're just using stdout (a.k.a self.outf)
121 print(s
, file=self
.outf
)
125 fd
, fn
= tempfile
.mkstemp(prefix
='samba-tool-visualise',
136 def calc_output_format(self
, format
, output
):
137 """Heuristics to work out what output format was wanted."""
139 # They told us nothing! We have to work it out for ourselves.
140 if output
and output
.lower().endswith('.dot'):
150 def call_xdot(self
, s
, output
):
152 fn
= self
.write(s
, TEMP_FILE
)
154 fn
= self
.write(s
, output
)
155 xdot
= os
.environ
.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
156 subprocess
.call([xdot
, fn
])
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"""
167 if isinstance(output
, str) and output
!= '-':
169 if not hasattr(self
.outf
, 'isatty'):
170 # not a real file, perhaps cStringIO in testing
172 if not self
.outf
.isatty():
175 if color_scheme
is None:
176 if '256color' in os
.environ
.get('TERM', ''):
177 return 'xterm-256color-heatmap'
183 def get_dnstr_site(dn
):
184 """Helper function for sorting and grouping DNs by site, if
186 m
= re
.search(r
'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn
)
189 # Oh well, let it sort by DN
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])
200 """Generate a randomish but consistent darkish colour based on the
202 from hashlib
import md5
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
210 def get_partition_maps(samdb
):
211 """Generate dictionaries mapping short partition names to the
213 base_dn
= samdb
.domain_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
223 for s
, l
in short_to_long
.items():
226 return short_to_long
, long_to_short
229 def get_partition(samdb
, part
):
230 # Allow people to say "--partition=DOMAIN" rather than
231 # "--partition=DC=blah,DC=..."
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
)
240 class cmd_reps(GraphCommand
):
241 "repsFrom/repsTo from every DSA"
243 takes_options
= COMMON_OPTIONS
+ DOT_OPTIONS
+ [
244 Option("-p", "--partition", help="restrict to this partition",
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
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
260 partition
= get_partition(local_kcc
.samdb
, partition
)
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))
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.
273 kcc
= KCC(unix_now
, readonly
=True)
275 res
= local_kcc
.samdb
.search(dsa_dn
,
277 attrs
=["dNSHostName"])
278 dns_name
= res
[0]["dNSHostName"][0]
279 print("Attempting to contact ldap://%s (%s)" %
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
),
289 kcc
.run(H
, lp
, creds
)
291 kcc
.load_samdb(H
, lp
, creds
)
292 kcc
.run(H
, lp
, creds
, forced_local_dsa
=dsa_dn
)
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
,
301 for dsa
in (dsas
- dsas_from_here
):
302 print(" %s" % dsa
, file=sys
.stderr
)
304 for remote_dn
in dsas_from_here
:
305 if mode
== 'others' and remote_dn
== dsa_dn
:
307 elif mode
== 'self' and remote_dn
!= dsa_dn
:
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
))
323 all_edges
= {'needed': {'to': [], 'from': []},
324 'current': {'to': [], 'from': []}}
326 short_partitions
, long_partitions
= get_partition_maps(local_kcc
.samdb
)
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(
335 guid_to_dnstr
[str(r
.source_dsa_obj_guid
)],
337 for r
in rep
.rep_repsTo
:
338 edgelists
['to'].append(
339 (guid_to_dnstr
[str(r
.source_dsa_obj_guid
)],
343 # Here we have the set of edges. From now it is a matter of
344 # interpretation and presentation.
346 if self
.calc_output_format(format
, output
) == 'distance':
347 color_scheme
= self
.calc_distance_color_scheme(color
,
351 'from': "RepsFrom objects for %s",
352 'to': "RepsTo objects for %s",
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
,
363 shorten_names
=shorten_names
,
365 grouping_function
=get_dnstr_site
)
367 s
= "\n%s\n%s" % (header_strings
[direction
] % part
, s
)
368 self
.write(s
, output
)
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
),
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(),
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"))
407 s
= dot_graph(dot_vertices
, dot_edges
,
409 edge_colors
=edge_colours
,
410 edge_styles
=edge_styles
,
411 shorten_names
=shorten_names
,
415 self
.call_xdot(s
, output
)
417 self
.write(s
, output
)
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
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
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",
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
)
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,
459 lp
= sambaopts
.get_loadparm()
460 if importldif
is None:
461 creds
= credopts
.get_credentials(lp
, fallback_machine
=True)
464 H
= self
.import_ldif_db(importldif
, lp
)
466 local_kcc
, dsas
= self
.get_kcc_and_dsas(H
, lp
, creds
)
467 local_dsa_dn
= local_kcc
.my_dsa_dnstr
.split(',', 1)[1]
472 res
= local_kcc
.samdb
.search(dsa_dn
,
474 attrs
=["dNSHostName"])
475 dns_name
= res
[0]["dNSHostName"][0]
477 samdb
= self
.get_db("ldap://%s" % dns_name
, sambaopts
,
479 except LdbError
as e
:
480 print("Could not contact ldap://%s (%s)" % (dns_name
, e
),
484 ntds_dn
= samdb
.get_dsServiceName()
485 dn
= samdb
.domain_dn()
487 samdb
= self
.get_db(H
, sambaopts
, credopts
)
488 ntds_dn
= 'CN=NTDS Settings,' + dsa_dn
491 res
= samdb
.search(ntds_dn
,
493 attrs
=["msDS-isRODC"])
495 is_rodc
= res
[0]["msDS-isRODC"][0] == 'TRUE'
497 vertices
.add((ntds_dn
, 'RODC' if is_rodc
else ''))
498 # XXX we could also look at schedule
499 res
= samdb
.search(dn
,
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"],
510 dest_dn
= msgdn
[msgdn
.index(',') + 1:]
511 attested_edges
.append((msg
['fromServer'][0],
514 if importldif
and H
== self
._tmp
_fn
_to
_delete
:
516 os
.rmdir(os
.path
.dirname(H
))
518 # now we overlay all the graphs and generate styles accordingly
520 for src
, dest
, attester
in attested_edges
:
529 vertices
, rodc_status
= zip(*sorted(vertices
))
531 if self
.calc_output_format(format
, output
) == 'distance':
532 color_scheme
= self
.calc_distance_color_scheme(color
,
535 colours
= COLOUR_SETS
[color_scheme
]
536 c_header
= colours
.get('header', '')
537 c_reset
= colours
.get('reset', '')
540 if 'RODC' in rodc_status
:
541 epilog
.append('No outbound connections are expected from RODCs')
543 if not talk_to_remote
:
544 # If we are not talking to remote servers, we list all
546 graph_edges
= edges
.keys()
547 title
= 'NTDS Connections known to %s' % local_dsa_dn
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
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.
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
)
576 title
= 'NTDS Connections known to each destination DC'
579 epilog
.append('The following connections are alleged by '
580 'DCs other than the source and '
583 epilog
.append(' %s -> %s\n' % e
)
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
)
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
)
597 s
= distance_matrix(vertices
, graph_edges
,
600 shorten_names
=shorten_names
,
602 grouping_function
=get_dnstrlist_site
,
603 row_comments
=rodc_status
)
605 epilog
= ''.join(epilog
)
607 epilog
= '\n%sNOTES%s\n%s' % (c_header
,
611 self
.write('\n%s\n\n%s\n%s' % (title
,
620 n_servers
= len(dsas
)
621 for k
, e
in sorted(edges
.items()):
623 if e
.observations
== n_servers
or not talk_to_remote
:
624 edge_colours
.append('#000000')
625 edge_styles
.append('')
627 edge_styles
.append('')
629 edge_colours
.append('#0000ff')
631 edge_colours
.append('#cc00ff')
633 edge_colours
.append('#ff0000')
634 edge_styles
.append('style=dashed')
636 edge_colours
.append('#ff0000')
637 edge_styles
.append('style=dotted')
641 key_items
.append((False,
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
))
649 for style
, desc
in (('style=dashed', "unknown to destination"),
651 "unknown to source and destination")):
652 if style
in edge_styles
:
653 key_items
.append((False,
654 'color="#ff0000; %s"' % style
,
658 title
= 'NTDS Connections'
660 title
= 'NTDS Connections known to %s' % local_dsa_dn
662 s
= dot_graph(sorted(vertices
), dot_edges
,
665 edge_colors
=edge_colours
,
666 edge_labels
=edge_labels
,
667 edge_styles
=edge_styles
,
668 shorten_names
=shorten_names
,
672 self
.call_xdot(s
, output
)
674 self
.write(s
, output
)
677 class cmd_uptodateness(GraphCommand
):
678 """visualize uptodateness vectors"""
680 takes_options
= COMMON_OPTIONS
+ [
681 Option("-p", "--partition", help="restrict to this partition",
683 Option("--max-digits", default
=3, type=int,
684 help="display this many digits of out-of-date-ness"),
687 def get_utdv(self
, samdb
, dn
):
688 """This finds the uptodateness vector in the database."""
690 config_dn
= samdb
.get_config_basedn()
691 for c
in dsdb
._dsdb
_load
_udv
_v
2(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" %
703 cursors
.append((dsa_dn
,
706 nttime2unix(c
.last_sync_success
)))
709 def get_own_cursor(self
, samdb
):
710 res
= samdb
.search(base
="",
712 attrs
=["highestCommittedUSN"])
713 usn
= int(res
[0]["highestCommittedUSN"][0])
714 now
= int(time
.time())
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
)
728 # We use the KCC libraries in readonly mode to get the
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
)
736 short_partitions
, long_partitions
= get_partition_maps(self
.samdb
)
737 color_scheme
= self
.calc_distance_color_scheme(color
,
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
745 cursors
= self
.get_utdv(self
.samdb
, part_dn
)
747 # we talk to each remote and make a matrix of the vectors
748 # -- for each partition
749 # normalise by oldest
752 res
= local_kcc
.samdb
.search(dsa_dn
,
754 attrs
=["dNSHostName"])
755 ldap_url
= "ldap://%s" % res
[0]["dNSHostName"][0]
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
:
763 except LdbError
as e
:
764 print("Could not contact %s (%s)" % (ldap_url
, e
),
767 utdv_edges
[dsa_dn
] = remotes
773 peak
= utdv_edges
[dn1
][dn1
]
774 except KeyError as e
:
779 if dn2
in utdv_edges
:
780 if dn1
in utdv_edges
[dn2
]:
781 dist
= peak
- utdv_edges
[dn2
][dn1
]
783 if dist
> max_distance
:
786 print("Missing dn %s from UTD vector" % dn1
,
789 print("missing dn %s from UTD vector list" % dn2
,
792 digits
= min(max_digits
, len(str(max_distance
)))
795 c_scale
= 10 ** digits
797 s
= full_matrix(distances
,
800 shorten_names
=shorten_names
,
802 grouping_function
=get_dnstr_site
,
803 colour_scale
=c_scale
,
806 xlabel
='out-of-date-ness')
808 self
.write('\n%s\n\n%s' % (part_name
, s
), output
)
811 class cmd_visualize(SuperCommand
):
812 """Produces graphical representations of Samba network state"""
815 for k
, v
in globals().items():
816 if k
.startswith('cmd_'):
817 subcommands
[k
[4:]] = v()