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
29 import samba
.getopt
as options
30 from samba
.netcmd
import Command
, SuperCommand
, CommandError
, Option
31 from samba
.samdb
import SamDB
32 from samba
.graph
import dot_graph
33 from samba
.graph
import distance_matrix
, COLOUR_SETS
34 from ldb
import SCOPE_BASE
, SCOPE_SUBTREE
, LdbError
37 from samba
.kcc
import KCC
, ldif_import_export
38 from samba
.kcc
.kcc_utils
import KCCError
39 from samba
.compat
import text_type
42 Option("-H", "--URL", help="LDB URL for database or target server",
43 type=str, metavar
="URL", dest
="H"),
44 Option("-o", "--output", help="write here (default stdout)",
45 type=str, metavar
="FILE", default
=None),
46 Option("--distance", help="Distance matrix graph output (default)",
47 dest
='format', const
='distance', action
='store_const'),
48 Option("--utf8", help="Use utf-8 Unicode characters",
50 Option("--color", help="use color (yes, no, auto)",
51 choices
=['yes', 'no', 'auto']),
52 Option("--color-scheme", help=("use this colour scheme "
53 "(implies --color=yes)"),
54 choices
=list(COLOUR_SETS
.keys())),
55 Option("-S", "--shorten-names",
56 help="don't print long common suffixes",
57 action
='store_true', default
=False),
58 Option("-r", "--talk-to-remote", help="query other DCs' databases",
59 action
='store_true', default
=False),
60 Option("--no-key", help="omit the explanatory key",
61 action
='store_false', default
=True, dest
='key'),
65 Option("--dot", help="Graphviz dot output", dest
='format',
66 const
='dot', action
='store_const'),
67 Option("--xdot", help="attempt to call Graphviz xdot", dest
='format',
68 const
='xdot', action
='store_const'),
71 TEMP_FILE
= '__temp__'
74 class GraphCommand(Command
):
75 """Base class for graphing commands"""
77 synopsis
= "%prog [options]"
78 takes_optiongroups
= {
79 "sambaopts": options
.SambaOptions
,
80 "versionopts": options
.VersionOptions
,
81 "credopts": options
.CredentialsOptions
,
83 takes_options
= COMMON_OPTIONS
+ DOT_OPTIONS
86 def get_db(self
, H
, sambaopts
, credopts
):
87 lp
= sambaopts
.get_loadparm()
88 creds
= credopts
.get_credentials(lp
, fallback_machine
=True)
89 samdb
= SamDB(url
=H
, credentials
=creds
, lp
=lp
)
92 def get_kcc_and_dsas(self
, H
, lp
, creds
):
93 """Get a readonly KCC object and the list of DSAs it knows about."""
94 unix_now
= int(time
.time())
95 kcc
= KCC(unix_now
, readonly
=True)
96 kcc
.load_samdb(H
, lp
, creds
)
98 dsa_list
= kcc
.list_dsas()
100 if len(dsas
) != len(dsa_list
):
101 print("There seem to be duplicate dsas", file=sys
.stderr
)
105 def write(self
, s
, fn
=None, suffix
='.dot'):
106 """Decide whether we're dealing with a filename, a tempfile, or
107 stdout, and write accordingly.
109 :param s: the string to write
110 :param fn: a destination
111 :param suffix: suffix, if destination is a tempfile
113 If fn is None or "-", write to stdout.
114 If fn is visualize.TEMP_FILE, write to a temporary file
115 Otherwise fn should be a filename to write to.
117 if fn
is None or fn
== '-':
118 # we're just using stdout (a.k.a self.outf)
119 print(s
, file=self
.outf
)
123 fd
, fn
= tempfile
.mkstemp(prefix
='samba-tool-visualise',
134 def calc_output_format(self
, format
, output
):
135 """Heuristics to work out what output format was wanted."""
137 # They told us nothing! We have to work it out for ourselves.
138 if output
and output
.lower().endswith('.dot'):
148 def call_xdot(self
, s
, output
):
150 fn
= self
.write(s
, TEMP_FILE
)
152 fn
= self
.write(s
, output
)
153 xdot
= os
.environ
.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
154 subprocess
.call([xdot
, fn
])
157 def calc_distance_color_scheme(self
, color
, color_scheme
, output
):
158 """Heuristics to work out the colour scheme for distance matrices.
159 Returning None means no colour, otherwise it sould be a colour
160 from graph.COLOUR_SETS"""
165 if isinstance(output
, str) and output
!= '-':
167 if not hasattr(self
.outf
, 'isatty'):
168 # not a real file, perhaps cStringIO in testing
170 if not self
.outf
.isatty():
173 if color_scheme
is None:
174 if '256color' in os
.environ
.get('TERM', ''):
175 return 'xterm-256color-heatmap'
181 def get_dnstr_site(dn
):
182 """Helper function for sorting and grouping DNs by site, if
184 m
= re
.search(r
'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn
)
187 # Oh well, let it sort by DN
192 """Generate a randomish but consistent darkish colour based on the
194 from hashlib
import md5
196 if isinstance(tmp_str
, text_type
):
197 tmp_str
= tmp_str
.encode('utf8')
198 c
= int(md5(tmp_str
).hexdigest()[:6], base
=16) & 0x7f7f7f
202 def get_partition_maps(samdb
):
203 """Generate dictionaries mapping short partition names to the
205 base_dn
= samdb
.domain_dn()
208 "CONFIGURATION": str(samdb
.get_config_basedn()),
209 "SCHEMA": "CN=Schema,%s" % samdb
.get_config_basedn(),
210 "DNSDOMAIN": "DC=DomainDnsZones,%s" % base_dn
,
211 "DNSFOREST": "DC=ForestDnsZones,%s" % base_dn
215 for s
, l
in short_to_long
.items():
218 return short_to_long
, long_to_short
221 def get_partition(samdb
, part
):
222 # Allow people to say "--partition=DOMAIN" rather than
223 # "--partition=DC=blah,DC=..."
225 short_partitions
, long_partitions
= get_partition_maps(samdb
)
226 part
= short_partitions
.get(part
.upper(), part
)
227 if part
not in long_partitions
:
228 raise CommandError("unknown partition %s" % partition
)
232 class cmd_reps(GraphCommand
):
233 "repsFrom/repsTo from every DSA"
235 takes_options
= COMMON_OPTIONS
+ DOT_OPTIONS
+ [
236 Option("-p", "--partition", help="restrict to this partition",
240 def run(self
, H
=None, output
=None, shorten_names
=False,
241 key
=True, talk_to_remote
=False,
242 sambaopts
=None, credopts
=None, versionopts
=None,
243 mode
='self', partition
=None, color
=None, color_scheme
=None,
244 utf8
=None, format
=None, xdot
=False):
245 # We use the KCC libraries in readonly mode to get the
247 lp
= sambaopts
.get_loadparm()
248 creds
= credopts
.get_credentials(lp
, fallback_machine
=True)
249 local_kcc
, dsas
= self
.get_kcc_and_dsas(H
, lp
, creds
)
250 unix_now
= local_kcc
.unix_now
252 partition
= get_partition(local_kcc
.samdb
, partition
)
254 # nc_reps is an autovivifying dictionary of dictionaries of lists.
255 # nc_reps[partition]['current' | 'needed'] is a list of
256 # (dsa dn string, repsFromTo object) pairs.
257 nc_reps
= defaultdict(lambda: defaultdict(list))
261 # We run a new KCC for each DSA even if we aren't talking to
262 # the remote, because after kcc.run (or kcc.list_dsas) the kcc
263 # ends up in a messy state.
265 kcc
= KCC(unix_now
, readonly
=True)
267 res
= local_kcc
.samdb
.search(dsa_dn
,
269 attrs
=["dNSHostName"])
270 dns_name
= res
[0]["dNSHostName"][0]
271 print("Attempting to contact ldap://%s (%s)" %
275 kcc
.load_samdb("ldap://%s" % dns_name
, lp
, creds
)
276 except KCCError
as e
:
277 print("Could not contact ldap://%s (%s)" % (dns_name
, e
),
281 kcc
.run(H
, lp
, creds
)
283 kcc
.load_samdb(H
, lp
, creds
)
284 kcc
.run(H
, lp
, creds
, forced_local_dsa
=dsa_dn
)
286 dsas_from_here
= set(kcc
.list_dsas())
287 if dsas
!= dsas_from_here
:
288 print("found extra DSAs:", file=sys
.stderr
)
289 for dsa
in (dsas_from_here
- dsas
):
290 print(" %s" % dsa
, file=sys
.stderr
)
291 print("missing DSAs (known locally, not by %s):" % dsa_dn
,
293 for dsa
in (dsas
- dsas_from_here
):
294 print(" %s" % dsa
, file=sys
.stderr
)
296 for remote_dn
in dsas_from_here
:
297 if mode
== 'others' and remote_dn
== dsa_dn
:
299 elif mode
== 'self' and remote_dn
!= dsa_dn
:
302 remote_dsa
= kcc
.get_dsa('CN=NTDS Settings,' + remote_dn
)
303 kcc
.translate_ntdsconn(remote_dsa
)
304 guid_to_dnstr
[str(remote_dsa
.dsa_guid
)] = remote_dn
305 # get_reps_tables() returns two dictionaries mapping
306 # dns to NCReplica objects
307 c
, n
= remote_dsa
.get_rep_tables()
308 for part
, rep
in c
.items():
309 if partition
is None or part
== partition
:
310 nc_reps
[part
]['current'].append((dsa_dn
, rep
))
311 for part
, rep
in n
.items():
312 if partition
is None or part
== partition
:
313 nc_reps
[part
]['needed'].append((dsa_dn
, rep
))
315 all_edges
= {'needed': {'to': [], 'from': []},
316 'current': {'to': [], 'from': []}}
318 short_partitions
, long_partitions
= get_partition_maps(local_kcc
.samdb
)
320 for partname
, part
in nc_reps
.items():
321 for state
, edgelists
in all_edges
.items():
322 for dsa_dn
, rep
in part
[state
]:
323 short_name
= long_partitions
.get(partname
, partname
)
324 for r
in rep
.rep_repsFrom
:
325 edgelists
['from'].append(
327 guid_to_dnstr
[str(r
.source_dsa_obj_guid
)],
329 for r
in rep
.rep_repsTo
:
330 edgelists
['to'].append(
331 (guid_to_dnstr
[str(r
.source_dsa_obj_guid
)],
335 # Here we have the set of edges. From now it is a matter of
336 # interpretation and presentation.
338 if self
.calc_output_format(format
, output
) == 'distance':
339 color_scheme
= self
.calc_distance_color_scheme(color
,
343 'from': "RepsFrom objects for %s",
344 'to': "RepsTo objects for %s",
346 for state
, edgelists
in all_edges
.items():
347 for direction
, items
in edgelists
.items():
348 part_edges
= defaultdict(list)
349 for src
, dest
, part
in items
:
350 part_edges
[part
].append((src
, dest
))
351 for part
, edges
in part_edges
.items():
352 s
= distance_matrix(None, edges
,
355 shorten_names
=shorten_names
,
357 grouping_function
=get_dnstr_site
)
359 s
= "\n%s\n%s" % (header_strings
[direction
] % part
, s
)
360 self
.write(s
, output
)
369 for state
, edgelist
in all_edges
.items():
370 for direction
, items
in edgelist
.items():
371 for src
, dest
, part
in items
:
372 colour
= used_colours
.setdefault((part
),
375 linestyle
= 'dotted' if state
== 'needed' else 'solid'
376 arrow
= 'open' if direction
== 'to' else 'empty'
377 dot_vertices
.add(src
)
378 dot_vertices
.add(dest
)
379 dot_edges
.append((src
, dest
))
380 edge_colours
.append(colour
)
381 style
= 'style="%s"; arrowhead=%s' % (linestyle
, arrow
)
382 edge_styles
.append(style
)
383 key_set
.add((part
, 'reps' + direction
.title(),
388 for part
, direction
, colour
, linestyle
in sorted(key_set
):
389 key_items
.append((False,
390 'color="%s"; %s' % (colour
, linestyle
),
391 "%s %s" % (part
, direction
)))
392 key_items
.append((False,
393 'style="dotted"; arrowhead="open"',
394 "repsFromTo is needed"))
395 key_items
.append((False,
396 'style="solid"; arrowhead="open"',
397 "repsFromTo currently exists"))
399 s
= dot_graph(dot_vertices
, dot_edges
,
401 edge_colors
=edge_colours
,
402 edge_styles
=edge_styles
,
403 shorten_names
=shorten_names
,
407 self
.call_xdot(s
, output
)
409 self
.write(s
, output
)
412 class NTDSConn(object):
413 """Collects observation counts for NTDS connections, so we know
414 whether all DSAs agree."""
415 def __init__(self
, src
, dest
):
416 self
.observations
= 0
417 self
.src_attests
= False
418 self
.dest_attests
= False
422 def attest(self
, attester
):
423 self
.observations
+= 1
424 if attester
== self
.src
:
425 self
.src_attests
= True
426 if attester
== self
.dest
:
427 self
.dest_attests
= True
430 class cmd_ntdsconn(GraphCommand
):
431 "Draw the NTDSConnection graph"
432 takes_options
= COMMON_OPTIONS
+ DOT_OPTIONS
+ [
433 Option("--importldif", help="graph from samba_kcc generated ldif",
437 def import_ldif_db(self
, ldif
, lp
):
438 d
= tempfile
.mkdtemp(prefix
='samba-tool-visualise')
439 fn
= os
.path
.join(d
, 'imported.ldb')
440 self
._tmp
_fn
_to
_delete
= fn
441 samdb
= ldif_import_export
.ldif_to_samdb(fn
, lp
, ldif
)
444 def run(self
, H
=None, output
=None, shorten_names
=False,
445 key
=True, talk_to_remote
=False,
446 sambaopts
=None, credopts
=None, versionopts
=None,
447 color
=None, color_scheme
=None,
448 utf8
=None, format
=None, importldif
=None,
451 lp
= sambaopts
.get_loadparm()
452 if importldif
is None:
453 creds
= credopts
.get_credentials(lp
, fallback_machine
=True)
456 H
= self
.import_ldif_db(importldif
, lp
)
458 local_kcc
, dsas
= self
.get_kcc_and_dsas(H
, lp
, creds
)
459 local_dsa_dn
= local_kcc
.my_dsa_dnstr
.split(',', 1)[1]
464 res
= local_kcc
.samdb
.search(dsa_dn
,
466 attrs
=["dNSHostName"])
467 dns_name
= res
[0]["dNSHostName"][0]
469 samdb
= self
.get_db("ldap://%s" % dns_name
, sambaopts
,
471 except LdbError
as e
:
472 print("Could not contact ldap://%s (%s)" % (dns_name
, e
),
476 ntds_dn
= samdb
.get_dsServiceName()
477 dn
= samdb
.domain_dn()
479 samdb
= self
.get_db(H
, sambaopts
, credopts
)
480 ntds_dn
= 'CN=NTDS Settings,' + dsa_dn
483 res
= samdb
.search(ntds_dn
,
485 attrs
=["msDS-isRODC"])
487 is_rodc
= res
[0]["msDS-isRODC"][0] == 'TRUE'
489 vertices
.add((ntds_dn
, 'RODC' if is_rodc
else ''))
490 # XXX we could also look at schedule
491 res
= samdb
.search(dn
,
493 expression
="(objectClass=nTDSConnection)",
494 attrs
=['fromServer'],
495 # XXX can't be critical for ldif test
496 #controls=["search_options:1:2"],
497 controls
=["search_options:0:2"],
502 dest_dn
= msgdn
[msgdn
.index(',') + 1:]
503 attested_edges
.append((msg
['fromServer'][0],
506 if importldif
and H
== self
._tmp
_fn
_to
_delete
:
508 os
.rmdir(os
.path
.dirname(H
))
510 # now we overlay all the graphs and generate styles accordingly
512 for src
, dest
, attester
in attested_edges
:
521 vertices
, rodc_status
= zip(*sorted(vertices
))
523 if self
.calc_output_format(format
, output
) == 'distance':
524 color_scheme
= self
.calc_distance_color_scheme(color
,
527 colours
= COLOUR_SETS
[color_scheme
]
528 c_header
= colours
.get('header', '')
529 c_reset
= colours
.get('reset', '')
532 if 'RODC' in rodc_status
:
533 epilog
.append('No outbound connections are expected from RODCs')
535 if not talk_to_remote
:
536 # If we are not talking to remote servers, we list all
538 graph_edges
= edges
.keys()
539 title
= 'NTDS Connections known to %s' % local_dsa_dn
542 # If we are talking to the remotes, there are
543 # interesting cases we can discover. What matters most
544 # is that the destination (i.e. owner) knowns about
545 # the connection, but it would be worth noting if the
546 # source doesn't. Another strange situation could be
547 # when a DC thinks there is a connection elsewhere,
548 # but the computers allegedly involved don't believe
551 # With limited bandwidth in the table, we mark the
552 # edges known to the destination, and note the other
553 # cases in a list after the diagram.
558 for e
, conn
in edges
.items():
559 if conn
.dest_attests
:
560 graph_edges
.append(e
)
561 if not conn
.src_attests
:
562 source_denies
.append(e
)
563 elif conn
.src_attests
:
564 dest_denies
.append(e
)
568 title
= 'NTDS Connections known to each destination DC'
571 epilog
.append('The following connections are alleged by '
572 'DCs other than the source and '
575 epilog
.append(' %s -> %s\n' % e
)
577 epilog
.append('The following connections are alleged by '
578 'DCs other than the destination but '
579 'including the source:\n')
580 for e
in dest_denies
:
581 epilog
.append(' %s -> %s\n' % e
)
583 epilog
.append('The following connections '
584 '(included in the chart) '
585 'are not known to the source DC:\n')
586 for e
in source_denies
:
587 epilog
.append(' %s -> %s\n' % e
)
590 s
= distance_matrix(vertices
, graph_edges
,
593 shorten_names
=shorten_names
,
595 grouping_function
=get_dnstr_site
,
596 row_comments
=rodc_status
)
598 epilog
= ''.join(epilog
)
600 epilog
= '\n%sNOTES%s\n%s' % (c_header
,
604 self
.write('\n%s\n\n%s\n%s' % (title
,
613 n_servers
= len(dsas
)
614 for k
, e
in sorted(edges
.items()):
616 if e
.observations
== n_servers
or not talk_to_remote
:
617 edge_colours
.append('#000000')
618 edge_styles
.append('')
620 edge_styles
.append('')
622 edge_colours
.append('#0000ff')
624 edge_colours
.append('#cc00ff')
626 edge_colours
.append('#ff0000')
627 edge_styles
.append('style=dashed')
629 edge_colours
.append('#ff0000')
630 edge_styles
.append('style=dotted')
634 key_items
.append((False,
637 for colour
, desc
in (('#0000ff', "missing from some DCs"),
638 ('#cc00ff', "missing from source DC")):
639 if colour
in edge_colours
:
640 key_items
.append((False, 'color="%s"' % colour
, desc
))
642 for style
, desc
in (('style=dashed', "unknown to destination"),
644 "unknown to source and destination")):
645 if style
in edge_styles
:
646 key_items
.append((False,
647 'color="#ff0000; %s"' % style
,
651 title
= 'NTDS Connections'
653 title
= 'NTDS Connections known to %s' % local_dsa_dn
655 s
= dot_graph(sorted(vertices
), dot_edges
,
658 edge_colors
=edge_colours
,
659 edge_labels
=edge_labels
,
660 edge_styles
=edge_styles
,
661 shorten_names
=shorten_names
,
665 self
.call_xdot(s
, output
)
667 self
.write(s
, output
)
670 class cmd_visualize(SuperCommand
):
671 """Produces graphical representations of Samba network state"""
674 for k
, v
in globals().items():
675 if k
.startswith('cmd_'):
676 subcommands
[k
[4:]] = v()