mirror of
https://github.com/samba-team/samba.git
synced 2024-12-24 21:34:56 +03:00
2d8cc50d39
Or more accurately, out-of-dateness visualization, which shows how far each DCs is from every other using the difference in the up-to-dateness vectors. An example usage is samba-tool visualize uptodateness -r -S -H ldap://somewhere \ -UAdministrator --color=auto --partition=DOMAIN Signed-off-by: Douglas Bagnall <douglas.bagnall@catalyst.net.nz> Reviewed-by: Andrew Bartlett <abartlet@samba.org>
818 lines
32 KiB
Python
818 lines
32 KiB
Python
# Visualisation tools
|
|
#
|
|
# Copyright (C) Andrew Bartlett 2015, 2018
|
|
#
|
|
# by Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
from __future__ import print_function
|
|
|
|
import os
|
|
import sys
|
|
from collections import defaultdict
|
|
import subprocess
|
|
|
|
import tempfile
|
|
import samba.getopt as options
|
|
from samba import dsdb
|
|
from samba import nttime2unix
|
|
from samba.netcmd import Command, SuperCommand, CommandError, Option
|
|
from samba.samdb import SamDB
|
|
from samba.graph import dot_graph
|
|
from samba.graph import distance_matrix, COLOUR_SETS
|
|
from samba.graph import full_matrix
|
|
from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
|
|
import time
|
|
import re
|
|
from samba.kcc import KCC, ldif_import_export
|
|
from samba.kcc.kcc_utils import KCCError
|
|
from samba.compat import text_type
|
|
|
|
COMMON_OPTIONS = [
|
|
Option("-H", "--URL", help="LDB URL for database or target server",
|
|
type=str, metavar="URL", dest="H"),
|
|
Option("-o", "--output", help="write here (default stdout)",
|
|
type=str, metavar="FILE", default=None),
|
|
Option("--distance", help="Distance matrix graph output (default)",
|
|
dest='format', const='distance', action='store_const'),
|
|
Option("--utf8", help="Use utf-8 Unicode characters",
|
|
action='store_true'),
|
|
Option("--color", help="use color (yes, no, auto)",
|
|
choices=['yes', 'no', 'auto']),
|
|
Option("--color-scheme", help=("use this colour scheme "
|
|
"(implies --color=yes)"),
|
|
choices=list(COLOUR_SETS.keys())),
|
|
Option("-S", "--shorten-names",
|
|
help="don't print long common suffixes",
|
|
action='store_true', default=False),
|
|
Option("-r", "--talk-to-remote", help="query other DCs' databases",
|
|
action='store_true', default=False),
|
|
Option("--no-key", help="omit the explanatory key",
|
|
action='store_false', default=True, dest='key'),
|
|
]
|
|
|
|
DOT_OPTIONS = [
|
|
Option("--dot", help="Graphviz dot output", dest='format',
|
|
const='dot', action='store_const'),
|
|
Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
|
|
const='xdot', action='store_const'),
|
|
]
|
|
|
|
TEMP_FILE = '__temp__'
|
|
|
|
|
|
class GraphCommand(Command):
|
|
"""Base class for graphing commands"""
|
|
|
|
synopsis = "%prog [options]"
|
|
takes_optiongroups = {
|
|
"sambaopts": options.SambaOptions,
|
|
"versionopts": options.VersionOptions,
|
|
"credopts": options.CredentialsOptions,
|
|
}
|
|
takes_options = COMMON_OPTIONS + DOT_OPTIONS
|
|
takes_args = ()
|
|
|
|
def get_db(self, H, sambaopts, credopts):
|
|
lp = sambaopts.get_loadparm()
|
|
creds = credopts.get_credentials(lp, fallback_machine=True)
|
|
samdb = SamDB(url=H, credentials=creds, lp=lp)
|
|
return samdb
|
|
|
|
def get_kcc_and_dsas(self, H, lp, creds):
|
|
"""Get a readonly KCC object and the list of DSAs it knows about."""
|
|
unix_now = int(time.time())
|
|
kcc = KCC(unix_now, readonly=True)
|
|
kcc.load_samdb(H, lp, creds)
|
|
|
|
dsa_list = kcc.list_dsas()
|
|
dsas = set(dsa_list)
|
|
if len(dsas) != len(dsa_list):
|
|
print("There seem to be duplicate dsas", file=sys.stderr)
|
|
|
|
return kcc, dsas
|
|
|
|
def write(self, s, fn=None, suffix='.dot'):
|
|
"""Decide whether we're dealing with a filename, a tempfile, or
|
|
stdout, and write accordingly.
|
|
|
|
:param s: the string to write
|
|
:param fn: a destination
|
|
:param suffix: suffix, if destination is a tempfile
|
|
|
|
If fn is None or "-", write to stdout.
|
|
If fn is visualize.TEMP_FILE, write to a temporary file
|
|
Otherwise fn should be a filename to write to.
|
|
"""
|
|
if fn is None or fn == '-':
|
|
# we're just using stdout (a.k.a self.outf)
|
|
print(s, file=self.outf)
|
|
return
|
|
|
|
if fn is TEMP_FILE:
|
|
fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
|
|
suffix=suffix)
|
|
f = open(fn, 'w')
|
|
os.close(fd)
|
|
else:
|
|
f = open(fn, 'w')
|
|
|
|
f.write(s)
|
|
f.close()
|
|
return fn
|
|
|
|
def calc_output_format(self, format, output):
|
|
"""Heuristics to work out what output format was wanted."""
|
|
if not format:
|
|
# They told us nothing! We have to work it out for ourselves.
|
|
if output and output.lower().endswith('.dot'):
|
|
return 'dot'
|
|
else:
|
|
return 'distance'
|
|
|
|
if format == 'xdot':
|
|
return 'dot'
|
|
|
|
return format
|
|
|
|
def call_xdot(self, s, output):
|
|
if output is None:
|
|
fn = self.write(s, TEMP_FILE)
|
|
else:
|
|
fn = self.write(s, output)
|
|
xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
|
|
subprocess.call([xdot, fn])
|
|
os.remove(fn)
|
|
|
|
def calc_distance_color_scheme(self, color, color_scheme, output):
|
|
"""Heuristics to work out the colour scheme for distance matrices.
|
|
Returning None means no colour, otherwise it sould be a colour
|
|
from graph.COLOUR_SETS"""
|
|
if color == 'no':
|
|
return None
|
|
|
|
if color == 'auto':
|
|
if isinstance(output, str) and output != '-':
|
|
return None
|
|
if not hasattr(self.outf, 'isatty'):
|
|
# not a real file, perhaps cStringIO in testing
|
|
return None
|
|
if not self.outf.isatty():
|
|
return None
|
|
|
|
if color_scheme is None:
|
|
if '256color' in os.environ.get('TERM', ''):
|
|
return 'xterm-256color-heatmap'
|
|
return 'ansi'
|
|
|
|
return color_scheme
|
|
|
|
|
|
def get_dnstr_site(dn):
|
|
"""Helper function for sorting and grouping DNs by site, if
|
|
possible."""
|
|
m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
|
|
if m:
|
|
return m.group(1)
|
|
# Oh well, let it sort by DN
|
|
return dn
|
|
|
|
|
|
def get_dnstrlist_site(t):
|
|
"""Helper function for sorting and grouping lists of (DN, ...) tuples
|
|
by site, if possible."""
|
|
return get_dnstr_site(t[0])
|
|
|
|
|
|
def colour_hash(x):
|
|
"""Generate a randomish but consistent darkish colour based on the
|
|
given object."""
|
|
from hashlib import md5
|
|
tmp_str = str(x)
|
|
if isinstance(tmp_str, text_type):
|
|
tmp_str = tmp_str.encode('utf8')
|
|
c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
|
|
return '#%06x' % c
|
|
|
|
|
|
def get_partition_maps(samdb):
|
|
"""Generate dictionaries mapping short partition names to the
|
|
appropriate DNs."""
|
|
base_dn = samdb.domain_dn()
|
|
short_to_long = {
|
|
"DOMAIN": base_dn,
|
|
"CONFIGURATION": str(samdb.get_config_basedn()),
|
|
"SCHEMA": "CN=Schema,%s" % samdb.get_config_basedn(),
|
|
"DNSDOMAIN": "DC=DomainDnsZones,%s" % base_dn,
|
|
"DNSFOREST": "DC=ForestDnsZones,%s" % base_dn
|
|
}
|
|
|
|
long_to_short = {}
|
|
for s, l in short_to_long.items():
|
|
long_to_short[l] = s
|
|
|
|
return short_to_long, long_to_short
|
|
|
|
|
|
def get_partition(samdb, part):
|
|
# Allow people to say "--partition=DOMAIN" rather than
|
|
# "--partition=DC=blah,DC=..."
|
|
if part is not None:
|
|
short_partitions, long_partitions = get_partition_maps(samdb)
|
|
part = short_partitions.get(part.upper(), part)
|
|
if part not in long_partitions:
|
|
raise CommandError("unknown partition %s" % part)
|
|
return part
|
|
|
|
|
|
class cmd_reps(GraphCommand):
|
|
"repsFrom/repsTo from every DSA"
|
|
|
|
takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
|
|
Option("-p", "--partition", help="restrict to this partition",
|
|
default=None),
|
|
]
|
|
|
|
def run(self, H=None, output=None, shorten_names=False,
|
|
key=True, talk_to_remote=False,
|
|
sambaopts=None, credopts=None, versionopts=None,
|
|
mode='self', partition=None, color=None, color_scheme=None,
|
|
utf8=None, format=None, xdot=False):
|
|
# We use the KCC libraries in readonly mode to get the
|
|
# replication graph.
|
|
lp = sambaopts.get_loadparm()
|
|
creds = credopts.get_credentials(lp, fallback_machine=True)
|
|
local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
|
|
unix_now = local_kcc.unix_now
|
|
|
|
partition = get_partition(local_kcc.samdb, partition)
|
|
|
|
# nc_reps is an autovivifying dictionary of dictionaries of lists.
|
|
# nc_reps[partition]['current' | 'needed'] is a list of
|
|
# (dsa dn string, repsFromTo object) pairs.
|
|
nc_reps = defaultdict(lambda: defaultdict(list))
|
|
|
|
guid_to_dnstr = {}
|
|
|
|
# We run a new KCC for each DSA even if we aren't talking to
|
|
# the remote, because after kcc.run (or kcc.list_dsas) the kcc
|
|
# ends up in a messy state.
|
|
for dsa_dn in dsas:
|
|
kcc = KCC(unix_now, readonly=True)
|
|
if talk_to_remote:
|
|
res = local_kcc.samdb.search(dsa_dn,
|
|
scope=SCOPE_BASE,
|
|
attrs=["dNSHostName"])
|
|
dns_name = res[0]["dNSHostName"][0]
|
|
print("Attempting to contact ldap://%s (%s)" %
|
|
(dns_name, dsa_dn),
|
|
file=sys.stderr)
|
|
try:
|
|
kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
|
|
except KCCError as e:
|
|
print("Could not contact ldap://%s (%s)" % (dns_name, e),
|
|
file=sys.stderr)
|
|
continue
|
|
|
|
kcc.run(H, lp, creds)
|
|
else:
|
|
kcc.load_samdb(H, lp, creds)
|
|
kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
|
|
|
|
dsas_from_here = set(kcc.list_dsas())
|
|
if dsas != dsas_from_here:
|
|
print("found extra DSAs:", file=sys.stderr)
|
|
for dsa in (dsas_from_here - dsas):
|
|
print(" %s" % dsa, file=sys.stderr)
|
|
print("missing DSAs (known locally, not by %s):" % dsa_dn,
|
|
file=sys.stderr)
|
|
for dsa in (dsas - dsas_from_here):
|
|
print(" %s" % dsa, file=sys.stderr)
|
|
|
|
for remote_dn in dsas_from_here:
|
|
if mode == 'others' and remote_dn == dsa_dn:
|
|
continue
|
|
elif mode == 'self' and remote_dn != dsa_dn:
|
|
continue
|
|
|
|
remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
|
|
kcc.translate_ntdsconn(remote_dsa)
|
|
guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
|
|
# get_reps_tables() returns two dictionaries mapping
|
|
# dns to NCReplica objects
|
|
c, n = remote_dsa.get_rep_tables()
|
|
for part, rep in c.items():
|
|
if partition is None or part == partition:
|
|
nc_reps[part]['current'].append((dsa_dn, rep))
|
|
for part, rep in n.items():
|
|
if partition is None or part == partition:
|
|
nc_reps[part]['needed'].append((dsa_dn, rep))
|
|
|
|
all_edges = {'needed': {'to': [], 'from': []},
|
|
'current': {'to': [], 'from': []}}
|
|
|
|
short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
|
|
|
|
for partname, part in nc_reps.items():
|
|
for state, edgelists in all_edges.items():
|
|
for dsa_dn, rep in part[state]:
|
|
short_name = long_partitions.get(partname, partname)
|
|
for r in rep.rep_repsFrom:
|
|
edgelists['from'].append(
|
|
(dsa_dn,
|
|
guid_to_dnstr[str(r.source_dsa_obj_guid)],
|
|
short_name))
|
|
for r in rep.rep_repsTo:
|
|
edgelists['to'].append(
|
|
(guid_to_dnstr[str(r.source_dsa_obj_guid)],
|
|
dsa_dn,
|
|
short_name))
|
|
|
|
# Here we have the set of edges. From now it is a matter of
|
|
# interpretation and presentation.
|
|
|
|
if self.calc_output_format(format, output) == 'distance':
|
|
color_scheme = self.calc_distance_color_scheme(color,
|
|
color_scheme,
|
|
output)
|
|
header_strings = {
|
|
'from': "RepsFrom objects for %s",
|
|
'to': "RepsTo objects for %s",
|
|
}
|
|
for state, edgelists in all_edges.items():
|
|
for direction, items in edgelists.items():
|
|
part_edges = defaultdict(list)
|
|
for src, dest, part in items:
|
|
part_edges[part].append((src, dest))
|
|
for part, edges in part_edges.items():
|
|
s = distance_matrix(None, edges,
|
|
utf8=utf8,
|
|
colour=color_scheme,
|
|
shorten_names=shorten_names,
|
|
generate_key=key,
|
|
grouping_function=get_dnstr_site)
|
|
|
|
s = "\n%s\n%s" % (header_strings[direction] % part, s)
|
|
self.write(s, output)
|
|
return
|
|
|
|
edge_colours = []
|
|
edge_styles = []
|
|
dot_edges = []
|
|
dot_vertices = set()
|
|
used_colours = {}
|
|
key_set = set()
|
|
for state, edgelist in all_edges.items():
|
|
for direction, items in edgelist.items():
|
|
for src, dest, part in items:
|
|
colour = used_colours.setdefault((part),
|
|
colour_hash((part,
|
|
direction)))
|
|
linestyle = 'dotted' if state == 'needed' else 'solid'
|
|
arrow = 'open' if direction == 'to' else 'empty'
|
|
dot_vertices.add(src)
|
|
dot_vertices.add(dest)
|
|
dot_edges.append((src, dest))
|
|
edge_colours.append(colour)
|
|
style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
|
|
edge_styles.append(style)
|
|
key_set.add((part, 'reps' + direction.title(),
|
|
colour, style))
|
|
|
|
key_items = []
|
|
if key:
|
|
for part, direction, colour, linestyle in sorted(key_set):
|
|
key_items.append((False,
|
|
'color="%s"; %s' % (colour, linestyle),
|
|
"%s %s" % (part, direction)))
|
|
key_items.append((False,
|
|
'style="dotted"; arrowhead="open"',
|
|
"repsFromTo is needed"))
|
|
key_items.append((False,
|
|
'style="solid"; arrowhead="open"',
|
|
"repsFromTo currently exists"))
|
|
|
|
s = dot_graph(dot_vertices, dot_edges,
|
|
directed=True,
|
|
edge_colors=edge_colours,
|
|
edge_styles=edge_styles,
|
|
shorten_names=shorten_names,
|
|
key_items=key_items)
|
|
|
|
if format == 'xdot':
|
|
self.call_xdot(s, output)
|
|
else:
|
|
self.write(s, output)
|
|
|
|
|
|
class NTDSConn(object):
|
|
"""Collects observation counts for NTDS connections, so we know
|
|
whether all DSAs agree."""
|
|
def __init__(self, src, dest):
|
|
self.observations = 0
|
|
self.src_attests = False
|
|
self.dest_attests = False
|
|
self.src = src
|
|
self.dest = dest
|
|
|
|
def attest(self, attester):
|
|
self.observations += 1
|
|
if attester == self.src:
|
|
self.src_attests = True
|
|
if attester == self.dest:
|
|
self.dest_attests = True
|
|
|
|
|
|
class cmd_ntdsconn(GraphCommand):
|
|
"Draw the NTDSConnection graph"
|
|
takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
|
|
Option("--importldif", help="graph from samba_kcc generated ldif",
|
|
default=None),
|
|
]
|
|
|
|
def import_ldif_db(self, ldif, lp):
|
|
d = tempfile.mkdtemp(prefix='samba-tool-visualise')
|
|
fn = os.path.join(d, 'imported.ldb')
|
|
self._tmp_fn_to_delete = fn
|
|
samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
|
|
return fn
|
|
|
|
def run(self, H=None, output=None, shorten_names=False,
|
|
key=True, talk_to_remote=False,
|
|
sambaopts=None, credopts=None, versionopts=None,
|
|
color=None, color_scheme=None,
|
|
utf8=None, format=None, importldif=None,
|
|
xdot=False):
|
|
|
|
lp = sambaopts.get_loadparm()
|
|
if importldif is None:
|
|
creds = credopts.get_credentials(lp, fallback_machine=True)
|
|
else:
|
|
creds = None
|
|
H = self.import_ldif_db(importldif, lp)
|
|
|
|
local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
|
|
local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
|
|
vertices = set()
|
|
attested_edges = []
|
|
for dsa_dn in dsas:
|
|
if talk_to_remote:
|
|
res = local_kcc.samdb.search(dsa_dn,
|
|
scope=SCOPE_BASE,
|
|
attrs=["dNSHostName"])
|
|
dns_name = res[0]["dNSHostName"][0]
|
|
try:
|
|
samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
|
|
credopts)
|
|
except LdbError as e:
|
|
print("Could not contact ldap://%s (%s)" % (dns_name, e),
|
|
file=sys.stderr)
|
|
continue
|
|
|
|
ntds_dn = samdb.get_dsServiceName()
|
|
dn = samdb.domain_dn()
|
|
else:
|
|
samdb = self.get_db(H, sambaopts, credopts)
|
|
ntds_dn = 'CN=NTDS Settings,' + dsa_dn
|
|
dn = dsa_dn
|
|
|
|
res = samdb.search(ntds_dn,
|
|
scope=SCOPE_BASE,
|
|
attrs=["msDS-isRODC"])
|
|
|
|
is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
|
|
|
|
vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
|
|
# XXX we could also look at schedule
|
|
res = samdb.search(dn,
|
|
scope=SCOPE_SUBTREE,
|
|
expression="(objectClass=nTDSConnection)",
|
|
attrs=['fromServer'],
|
|
# XXX can't be critical for ldif test
|
|
#controls=["search_options:1:2"],
|
|
controls=["search_options:0:2"],
|
|
)
|
|
|
|
for msg in res:
|
|
msgdn = str(msg.dn)
|
|
dest_dn = msgdn[msgdn.index(',') + 1:]
|
|
attested_edges.append((msg['fromServer'][0],
|
|
dest_dn, ntds_dn))
|
|
|
|
if importldif and H == self._tmp_fn_to_delete:
|
|
os.remove(H)
|
|
os.rmdir(os.path.dirname(H))
|
|
|
|
# now we overlay all the graphs and generate styles accordingly
|
|
edges = {}
|
|
for src, dest, attester in attested_edges:
|
|
k = (src, dest)
|
|
if k in edges:
|
|
e = edges[k]
|
|
else:
|
|
e = NTDSConn(*k)
|
|
edges[k] = e
|
|
e.attest(attester)
|
|
|
|
vertices, rodc_status = zip(*sorted(vertices))
|
|
|
|
if self.calc_output_format(format, output) == 'distance':
|
|
color_scheme = self.calc_distance_color_scheme(color,
|
|
color_scheme,
|
|
output)
|
|
colours = COLOUR_SETS[color_scheme]
|
|
c_header = colours.get('header', '')
|
|
c_reset = colours.get('reset', '')
|
|
|
|
epilog = []
|
|
if 'RODC' in rodc_status:
|
|
epilog.append('No outbound connections are expected from RODCs')
|
|
|
|
if not talk_to_remote:
|
|
# If we are not talking to remote servers, we list all
|
|
# the connections.
|
|
graph_edges = edges.keys()
|
|
title = 'NTDS Connections known to %s' % local_dsa_dn
|
|
|
|
else:
|
|
# If we are talking to the remotes, there are
|
|
# interesting cases we can discover. What matters most
|
|
# is that the destination (i.e. owner) knowns about
|
|
# the connection, but it would be worth noting if the
|
|
# source doesn't. Another strange situation could be
|
|
# when a DC thinks there is a connection elsewhere,
|
|
# but the computers allegedly involved don't believe
|
|
# it exists.
|
|
#
|
|
# With limited bandwidth in the table, we mark the
|
|
# edges known to the destination, and note the other
|
|
# cases in a list after the diagram.
|
|
graph_edges = []
|
|
source_denies = []
|
|
dest_denies = []
|
|
both_deny = []
|
|
for e, conn in edges.items():
|
|
if conn.dest_attests:
|
|
graph_edges.append(e)
|
|
if not conn.src_attests:
|
|
source_denies.append(e)
|
|
elif conn.src_attests:
|
|
dest_denies.append(e)
|
|
else:
|
|
both_deny.append(e)
|
|
|
|
title = 'NTDS Connections known to each destination DC'
|
|
|
|
if both_deny:
|
|
epilog.append('The following connections are alleged by '
|
|
'DCs other than the source and '
|
|
'destination:\n')
|
|
for e in both_deny:
|
|
epilog.append(' %s -> %s\n' % e)
|
|
if dest_denies:
|
|
epilog.append('The following connections are alleged by '
|
|
'DCs other than the destination but '
|
|
'including the source:\n')
|
|
for e in dest_denies:
|
|
epilog.append(' %s -> %s\n' % e)
|
|
if source_denies:
|
|
epilog.append('The following connections '
|
|
'(included in the chart) '
|
|
'are not known to the source DC:\n')
|
|
for e in source_denies:
|
|
epilog.append(' %s -> %s\n' % e)
|
|
|
|
s = distance_matrix(vertices, graph_edges,
|
|
utf8=utf8,
|
|
colour=color_scheme,
|
|
shorten_names=shorten_names,
|
|
generate_key=key,
|
|
grouping_function=get_dnstrlist_site,
|
|
row_comments=rodc_status)
|
|
|
|
epilog = ''.join(epilog)
|
|
if epilog:
|
|
epilog = '\n%sNOTES%s\n%s' % (c_header,
|
|
c_reset,
|
|
epilog)
|
|
|
|
self.write('\n%s\n\n%s\n%s' % (title,
|
|
s,
|
|
epilog), output)
|
|
return
|
|
|
|
dot_edges = []
|
|
edge_colours = []
|
|
edge_styles = []
|
|
edge_labels = []
|
|
n_servers = len(dsas)
|
|
for k, e in sorted(edges.items()):
|
|
dot_edges.append(k)
|
|
if e.observations == n_servers or not talk_to_remote:
|
|
edge_colours.append('#000000')
|
|
edge_styles.append('')
|
|
elif e.dest_attests:
|
|
edge_styles.append('')
|
|
if e.src_attests:
|
|
edge_colours.append('#0000ff')
|
|
else:
|
|
edge_colours.append('#cc00ff')
|
|
elif e.src_attests:
|
|
edge_colours.append('#ff0000')
|
|
edge_styles.append('style=dashed')
|
|
else:
|
|
edge_colours.append('#ff0000')
|
|
edge_styles.append('style=dotted')
|
|
|
|
key_items = []
|
|
if key:
|
|
key_items.append((False,
|
|
'color="#000000"',
|
|
"NTDS Connection"))
|
|
for colour, desc in (('#0000ff', "missing from some DCs"),
|
|
('#cc00ff', "missing from source DC")):
|
|
if colour in edge_colours:
|
|
key_items.append((False, 'color="%s"' % colour, desc))
|
|
|
|
for style, desc in (('style=dashed', "unknown to destination"),
|
|
('style=dotted',
|
|
"unknown to source and destination")):
|
|
if style in edge_styles:
|
|
key_items.append((False,
|
|
'color="#ff0000; %s"' % style,
|
|
desc))
|
|
|
|
if talk_to_remote:
|
|
title = 'NTDS Connections'
|
|
else:
|
|
title = 'NTDS Connections known to %s' % local_dsa_dn
|
|
|
|
s = dot_graph(sorted(vertices), dot_edges,
|
|
directed=True,
|
|
title=title,
|
|
edge_colors=edge_colours,
|
|
edge_labels=edge_labels,
|
|
edge_styles=edge_styles,
|
|
shorten_names=shorten_names,
|
|
key_items=key_items)
|
|
|
|
if format == 'xdot':
|
|
self.call_xdot(s, output)
|
|
else:
|
|
self.write(s, output)
|
|
|
|
|
|
class cmd_uptodateness(GraphCommand):
|
|
"""visualize uptodateness vectors"""
|
|
|
|
takes_options = COMMON_OPTIONS + [
|
|
Option("-p", "--partition", help="restrict to this partition",
|
|
default=None),
|
|
Option("--max-digits", default=3, type=int,
|
|
help="display this many digits of out-of-date-ness"),
|
|
]
|
|
|
|
def get_utdv(self, samdb, dn):
|
|
"""This finds the uptodateness vector in the database."""
|
|
cursors = []
|
|
config_dn = samdb.get_config_basedn()
|
|
for c in dsdb._dsdb_load_udv_v2(samdb, dn):
|
|
inv_id = str(c.source_dsa_invocation_id)
|
|
res = samdb.search(base=config_dn,
|
|
expression=("(&(invocationId=%s)"
|
|
"(objectClass=nTDSDSA))" % inv_id),
|
|
attrs=["distinguishedName", "invocationId"])
|
|
settings_dn = res[0]["distinguishedName"][0]
|
|
prefix, dsa_dn = settings_dn.split(',', 1)
|
|
if prefix != 'CN=NTDS Settings':
|
|
raise CommandError("Expected NTDS Settings DN, got %s" %
|
|
settings_dn)
|
|
|
|
cursors.append((dsa_dn,
|
|
inv_id,
|
|
int(c.highest_usn),
|
|
nttime2unix(c.last_sync_success)))
|
|
return cursors
|
|
|
|
def get_own_cursor(self, samdb):
|
|
res = samdb.search(base="",
|
|
scope=SCOPE_BASE,
|
|
attrs=["highestCommittedUSN"])
|
|
usn = int(res[0]["highestCommittedUSN"][0])
|
|
now = int(time.time())
|
|
return (usn, now)
|
|
|
|
def run(self, H=None, output=None, shorten_names=False,
|
|
key=True, talk_to_remote=False,
|
|
sambaopts=None, credopts=None, versionopts=None,
|
|
color=None, color_scheme=None,
|
|
utf8=False, format=None, importldif=None,
|
|
xdot=False, partition=None, max_digits=3):
|
|
if not talk_to_remote:
|
|
print("this won't work without talking to the remote servers "
|
|
"(use -r)", file=self.outf)
|
|
return
|
|
|
|
# We use the KCC libraries in readonly mode to get the
|
|
# replication graph.
|
|
lp = sambaopts.get_loadparm()
|
|
creds = credopts.get_credentials(lp, fallback_machine=True)
|
|
local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
|
|
self.samdb = local_kcc.samdb
|
|
partition = get_partition(self.samdb, partition)
|
|
|
|
short_partitions, long_partitions = get_partition_maps(self.samdb)
|
|
color_scheme = self.calc_distance_color_scheme(color,
|
|
color_scheme,
|
|
output)
|
|
|
|
for part_name, part_dn in short_partitions.items():
|
|
if partition not in (part_dn, None):
|
|
continue # we aren't doing this partition
|
|
|
|
cursors = self.get_utdv(self.samdb, part_dn)
|
|
|
|
# we talk to each remote and make a matrix of the vectors
|
|
# -- for each partition
|
|
# normalise by oldest
|
|
utdv_edges = {}
|
|
for dsa_dn in dsas:
|
|
res = local_kcc.samdb.search(dsa_dn,
|
|
scope=SCOPE_BASE,
|
|
attrs=["dNSHostName"])
|
|
ldap_url = "ldap://%s" % res[0]["dNSHostName"][0]
|
|
try:
|
|
samdb = self.get_db(ldap_url, sambaopts, credopts)
|
|
cursors = self.get_utdv(samdb, part_dn)
|
|
own_usn, own_time = self.get_own_cursor(samdb)
|
|
remotes = {dsa_dn: own_usn}
|
|
for dn, guid, usn, t in cursors:
|
|
remotes[dn] = usn
|
|
except LdbError as e:
|
|
print("Could not contact %s (%s)" % (ldap_url, e),
|
|
file=sys.stderr)
|
|
continue
|
|
utdv_edges[dsa_dn] = remotes
|
|
|
|
distances = {}
|
|
max_distance = 0
|
|
for dn1 in dsas:
|
|
try:
|
|
peak = utdv_edges[dn1][dn1]
|
|
except KeyError as e:
|
|
peak = 0
|
|
d = {}
|
|
distances[dn1] = d
|
|
for dn2 in dsas:
|
|
if dn2 in utdv_edges:
|
|
if dn1 in utdv_edges[dn2]:
|
|
dist = peak - utdv_edges[dn2][dn1]
|
|
d[dn2] = dist
|
|
if dist > max_distance:
|
|
max_distance = dist
|
|
else:
|
|
print("Missing dn %s from UTD vector" % dn1,
|
|
file=sys.stderr)
|
|
else:
|
|
print("missing dn %s from UTD vector list" % dn2,
|
|
file=sys.stderr)
|
|
|
|
digits = min(max_digits, len(str(max_distance)))
|
|
if digits < 1:
|
|
digits = 1
|
|
c_scale = 10 ** digits
|
|
|
|
s = full_matrix(distances,
|
|
utf8=utf8,
|
|
colour=color_scheme,
|
|
shorten_names=shorten_names,
|
|
generate_key=key,
|
|
grouping_function=get_dnstr_site,
|
|
colour_scale=c_scale,
|
|
digits=digits,
|
|
ylabel='DC',
|
|
xlabel='out-of-date-ness')
|
|
|
|
self.write('\n%s\n\n%s' % (part_name, s), output)
|
|
|
|
|
|
class cmd_visualize(SuperCommand):
|
|
"""Produces graphical representations of Samba network state"""
|
|
subcommands = {}
|
|
|
|
for k, v in globals().items():
|
|
if k.startswith('cmd_'):
|
|
subcommands[k[4:]] = v()
|