Squashed commit of the following: commit975e4c6afcAuthor: Oliver Gorwits <oliver@cpan.org> Date: Tue Sep 3 13:35:26 2019 +0100 also support regexp in domain_suffix commit0a2b5c8fa2Author: Oliver Gorwits <oliver@cpan.org> Date: Tue Sep 3 13:17:17 2019 +0100 fix rancid, graph, and nodemonitor commit6d881de3ffAuthor: Oliver Gorwits <oliver@cpan.org> Date: Tue Sep 3 13:11:54 2019 +0100 improve docs and set default domain_suffix to be list commit1dcafc08a8Merge:9a752e0214ac69dcAuthor: Oliver Gorwits <oliver@cpan.org> Date: Tue Sep 3 13:00:39 2019 +0100 Merge branch 'master' into og-multiple-domain-suffix commit9a752e0298Merge:c836619f82a99ea9Author: Oliver Gorwits <oliver@cpan.org> Date: Tue Sep 3 09:45:25 2019 +0100 Merge branch 'master' into og-multiple-domain-suffix commitc836619f8cAuthor: Oliver Gorwits <oliver@cpan.org> Date: Thu Jun 13 07:52:45 2019 +0100 hokey fix for nodes with domains commitfed14bd810Author: Oliver Gorwits <oliver@cpan.org> Date: Thu Jun 13 07:02:09 2019 +0100 basic implementation, rancid graph and nodemonitor missing
480 lines
14 KiB
Perl
480 lines
14 KiB
Perl
package App::Netdisco::Util::Graph;
|
|
|
|
use App::Netdisco;
|
|
|
|
use Dancer qw/:syntax :script/;
|
|
use Dancer::Plugin::DBIC 'schema';
|
|
|
|
use App::Netdisco::Util::DNS qw/hostname_from_ip ipv4_from_hostname/;
|
|
use Graph::Undirected ();
|
|
use GraphViz ();
|
|
|
|
use base 'Exporter';
|
|
our @EXPORT = ('graph');
|
|
our @EXPORT_OK = qw/
|
|
graph_each
|
|
graph_addnode
|
|
make_graph
|
|
/;
|
|
our %EXPORT_TAGS = (all => \@EXPORT_OK);
|
|
|
|
# nothing to see here, please move along...
|
|
our ($ip, $label, $isdev, $devloc, %GRAPH, %GRAPH_SPEED);
|
|
|
|
=head1 NAME
|
|
|
|
App::Netdisco::Util::Graph
|
|
|
|
=head1 SYNOPSIS
|
|
|
|
$ brew install graphviz <-- install graphviz on your system
|
|
|
|
$ ~/bin/localenv bash
|
|
$ cpanm --notest Graph GraphViz
|
|
$ mkdir ~/graph
|
|
|
|
use App::Netdisco::Util::Graph;
|
|
graph;
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
Generate GraphViz output from Netdisco data. Requires that the L<Graph> and
|
|
L<GraphViz> distributions be installed.
|
|
|
|
Requires the same config as for Netdisco 1, but within a C<graph> key. See
|
|
C<share/config.yml> in the source distribution for an example.
|
|
|
|
The C<graph> subroutine is exported by default. The C<:all> tag will export
|
|
all subroutines.
|
|
|
|
=head1 EXPORT
|
|
|
|
=over 4
|
|
|
|
=item graph()
|
|
|
|
Creates netmap of network.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
sub graph {
|
|
my %CONFIG = %{ setting('graph') };
|
|
|
|
my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime();
|
|
my $month = sprintf("%d%02d",$year+1900,$mon+1);
|
|
|
|
info "graph() - Creating Graphs";
|
|
my $G = make_graph();
|
|
|
|
unless (defined $G){
|
|
print "graph() - make_graph() failed. Try running with debug (-D).\n";
|
|
return;
|
|
}
|
|
|
|
my @S = $G->connected_components;
|
|
|
|
# Count number of nodes in each subgraph
|
|
my %S_count;
|
|
for (my $i=0;$i< scalar @S;$i++){
|
|
$S_count{$i} = scalar @{$S[$i]};
|
|
}
|
|
|
|
foreach my $subgraph (sort { $S_count{$b} <=> $S_count{$a} } keys %S_count){
|
|
my $SUBG = $G->copy;
|
|
print "\$S[$subgraph] has $S_count{$subgraph} nodes.\n";
|
|
|
|
# Remove other subgraphs from this one
|
|
my %S_notme = %S_count;
|
|
delete $S_notme{$subgraph};
|
|
foreach my $other (keys %S_notme){
|
|
print "Removing Non-connected nodes: ",join(',',@{$S[$other]}),"\n";
|
|
$SUBG->delete_vertices(@{$S[$other]})
|
|
}
|
|
|
|
# Create the subgraph
|
|
my $timeout = defined $CONFIG{graph_timeout} ? $CONFIG{graph_timeout} : 60;
|
|
|
|
eval {
|
|
alarm($timeout*60);
|
|
graph_each($SUBG,'');
|
|
alarm(0);
|
|
};
|
|
if ($@) {
|
|
if ($@ =~ /timeout/){
|
|
print "! Creating Graph timed out!\n";
|
|
} else {
|
|
print "\n$@\n";
|
|
}
|
|
}
|
|
|
|
# Facility to create subgraph for each non-connected network segment.
|
|
# Right now, let's just make the biggest one only.
|
|
last;
|
|
}
|
|
}
|
|
|
|
=head1 EXPORT_OK
|
|
|
|
=over 4
|
|
|
|
=item graph_each($graph_obj, $name)
|
|
|
|
Generates subgraph. Does actual GraphViz calls.
|
|
|
|
=cut
|
|
|
|
sub graph_each {
|
|
my ($G, $name) = @_;
|
|
my %CONFIG = %{ setting('graph') };
|
|
info "Creating new Graph";
|
|
|
|
my $graph_defs = {
|
|
'bgcolor' => $CONFIG{graph_bg} || 'black',
|
|
'color' => $CONFIG{graph_color} || 'white',
|
|
'overlap' => $CONFIG{graph_overlap} || 'scale',
|
|
'fontpath'=> _homepath('graph_fontpath',''),
|
|
'ranksep' => $CONFIG{graph_ranksep} || 0.3,
|
|
'nodesep' => $CONFIG{graph_nodesep} || 2,
|
|
'ratio' => $CONFIG{graph_ratio} || 'compress',
|
|
'splines' => ($CONFIG{graph_splines} ? 'true' : 'false'),
|
|
'fontcolor' => $CONFIG{node_fontcolor} || 'white',
|
|
'fontname' => $CONFIG{node_font} || 'lucon',
|
|
'fontsize' => $CONFIG{node_fontsize} || 12,
|
|
};
|
|
my $edge_defs = {
|
|
'color' => $CONFIG{edge_color} || 'wheat',
|
|
};
|
|
my $node_defs = {
|
|
'shape' => $CONFIG{node_shape} || 'box',
|
|
'fillcolor' => $CONFIG{node_fillcolor} || 'dimgrey',
|
|
'fontcolor' => $CONFIG{node_fontcolor} || 'white',
|
|
'style' => $CONFIG{node_style} || 'filled',
|
|
'fontname' => $CONFIG{node_font} || 'lucon',
|
|
'fontsize' => $CONFIG{node_fontsize} || 12,
|
|
'fixedsize' => ($CONFIG{node_fixedsize} ? 'true' : 'false'),
|
|
};
|
|
$node_defs->{height} = $CONFIG{node_height} if defined $CONFIG{node_height};
|
|
$node_defs->{width} = $CONFIG{node_width} if defined $CONFIG{node_width};
|
|
|
|
my $epsilon = undef;
|
|
if (defined $CONFIG{graph_epsilon}){
|
|
$epsilon = "0." . '0' x $CONFIG{graph_epsilon} . '1';
|
|
}
|
|
|
|
my %gv = (
|
|
directed => 0,
|
|
layout => $CONFIG{graph_layout} || 'twopi',
|
|
graph => $graph_defs,
|
|
node => $node_defs,
|
|
edge => $edge_defs,
|
|
width => $CONFIG{graph_x} || 30,
|
|
height => $CONFIG{graph_y} || 30,
|
|
epsilon => $epsilon,
|
|
);
|
|
|
|
my $gv = GraphViz->new(%gv);
|
|
|
|
my %node_map = ();
|
|
my @nodes = $G->vertices;
|
|
|
|
foreach my $dev (@nodes){
|
|
my $node_name = graph_addnode($gv,$dev);
|
|
$node_map{$dev} = $node_name;
|
|
}
|
|
|
|
my $root_ip = defined $CONFIG{root_device}
|
|
? (ipv4_from_hostname($CONFIG{root_device}) || $CONFIG{root_device})
|
|
: undef;
|
|
|
|
if (defined $root_ip and defined $node_map{$root_ip}){
|
|
my $gv_root_name = $gv->_quote_name($root_ip);
|
|
if (defined $gv_root_name){
|
|
$gv->{GRAPH_ATTRS}->{root}=$gv_root_name;
|
|
}
|
|
}
|
|
|
|
my @edges = $G->edges;
|
|
|
|
while (my $e = shift @edges){
|
|
my $link = $e->[0];
|
|
my $dest = $e->[1];
|
|
my $speed = $GRAPH_SPEED{$link}->{$dest}->{speed};
|
|
|
|
if (!defined($speed)) {
|
|
info " ! No link speed for $link -> $dest";
|
|
$speed = 0;
|
|
}
|
|
|
|
my %edge = ();
|
|
my $val = ''; my $suffix = '';
|
|
|
|
if ($speed =~ /^([\d.]+)\s+([a-z])bps$/i) {
|
|
$val = $1; $suffix = $2;
|
|
}
|
|
|
|
if ( ($suffix eq 'k') or ($speed =~ m/(t1|ds3)/i) ){
|
|
$edge{color} = 'green';
|
|
$edge{style} = 'dotted';
|
|
}
|
|
|
|
if ($suffix eq 'M'){
|
|
if ($val < 10.0){
|
|
$edge{color} = 'green';
|
|
#$edge{style} = 'dotted';
|
|
$edge{style} = 'dashed';
|
|
} elsif ($val < 100.0){
|
|
$edge{color} = '#8b7e66';
|
|
#$edge{style} = 'normal';
|
|
$edge{style} = 'solid';
|
|
} else {
|
|
$edge{color} = '#ffe7ba';
|
|
$edge{style} = 'solid';
|
|
}
|
|
}
|
|
|
|
if ($suffix eq 'G'){
|
|
#$edge{style} = 'bold';
|
|
$edge{color} = 'cyan1';
|
|
}
|
|
|
|
# Add extra styles to edges (mainly for modifying width)
|
|
if(defined $CONFIG{edge_style}) {
|
|
$edge{style} .= "," . $CONFIG{edge_style};
|
|
}
|
|
|
|
$gv->add_edge($link => $dest, %edge );
|
|
}
|
|
|
|
info "Ignore all warnings about node size";
|
|
|
|
if (defined $CONFIG{graph_raw} and $CONFIG{graph_raw}){
|
|
my $graph_raw = _homepath('graph_raw');
|
|
info " Creating raw graph: $graph_raw";
|
|
$gv->as_canon($graph_raw);
|
|
}
|
|
|
|
if (defined $CONFIG{graph} and $CONFIG{graph}){
|
|
my $graph_gif = _homepath('graph');
|
|
info " Creating graph: $graph_gif";
|
|
$gv->as_gif($graph_gif);
|
|
}
|
|
|
|
if (defined $CONFIG{graph_png} and $CONFIG{graph_png}){
|
|
my $graph_png = _homepath('graph_png');
|
|
info " Creating png graph: $graph_png";
|
|
$gv->as_png($graph_png);
|
|
}
|
|
|
|
if (defined $CONFIG{graph_map} and $CONFIG{graph_map}){
|
|
my $graph_map = _homepath('graph_map');
|
|
info " Creating CMAP : $graph_map";
|
|
$gv->as_cmap($graph_map);
|
|
}
|
|
|
|
if (defined $CONFIG{graph_svg} and $CONFIG{graph_svg}){
|
|
my $graph_svg = _homepath('graph_svg');
|
|
info " Creating SVG : $graph_svg";
|
|
$gv->as_svg($graph_svg);
|
|
}
|
|
}
|
|
|
|
=item graph_addnode($graphviz_obj, $node_ip)
|
|
|
|
Checks for mapping settings in config file and adds node to the GraphViz
|
|
object.
|
|
|
|
=cut
|
|
|
|
sub graph_addnode {
|
|
my $gv = shift;
|
|
my %CONFIG = %{ setting('graph') };
|
|
my %node = ();
|
|
|
|
$ip = shift;
|
|
$label = $GRAPH{$ip}->{dns};
|
|
$isdev = $GRAPH{$ip}->{isdev};
|
|
$devloc = $GRAPH{$ip}->{location};
|
|
|
|
$label = "($ip)" unless defined $label;
|
|
my $domain_suffix = setting('domain_suffix');
|
|
$label =~ s/$domain_suffix//;
|
|
$node{label} = $label;
|
|
|
|
# Dereferencing the scalar by name below
|
|
# requires that the variable be non-lexical (not my)
|
|
# we'll create some local non-lexical versions
|
|
# that will expire at the end of this block
|
|
# Node Mappings
|
|
foreach my $map (@{ $CONFIG{'node_map'} || [] }){
|
|
my ($var, $regex, $attr, $val) = split(':', $map);
|
|
|
|
{ no strict 'refs';
|
|
$var = ${"$var"};
|
|
}
|
|
next unless defined $var;
|
|
|
|
if ($var =~ /$regex/) {
|
|
debug " graph_addnode - Giving node $ip $attr = $val";
|
|
$node{$attr} = $val;
|
|
}
|
|
}
|
|
|
|
# URL for image maps FIXME for non-root hosting
|
|
if ($isdev) {
|
|
$node{URL} = "/device?&q=$ip";
|
|
}
|
|
else {
|
|
$node{URL} = "/search?tab=node&q=$ip";
|
|
# Overrides any colors given to nodes above. Bug 1094208
|
|
$node{fillcolor} = $CONFIG{'node_problem'} || 'red';
|
|
}
|
|
|
|
if ($CONFIG{'graph_clusters'} && $devloc) {
|
|
# This odd construct works around a bug in GraphViz.pm's
|
|
# quoting of cluster names. If it has a name with spaces,
|
|
# it'll just quote it, resulting in creating a subgraph name
|
|
# of cluster_"location with spaces". This is an illegal name
|
|
# according to the dot grammar, so if the name matches the
|
|
# problematic regexp we make GraphViz.pm generate an internal
|
|
# name by using a leading space in the name.
|
|
#
|
|
# This is bug ID 16912 at rt.cpan.org -
|
|
# http://rt.cpan.org/NoAuth/Bug.html?id=16912
|
|
#
|
|
# Another bug, ID 11514, prevents us from using a combination
|
|
# of name and label attributes to hide the extra space from
|
|
# the user. However, since it's just a space, hopefully it
|
|
# won't be too noticable.
|
|
my($loc) = $devloc;
|
|
$loc = " " . $loc if ($loc =~ /^[a-zA-Z](\w| )*$/);
|
|
$node{cluster} = { name => $loc };
|
|
}
|
|
|
|
my $rv = $gv->add_node($ip, %node);
|
|
return $rv;
|
|
}
|
|
|
|
=item make_graph()
|
|
|
|
Returns C<Graph::Undirected> object that represents the discovered network.
|
|
|
|
Graph is made by loading all the C<device_port> entries that have a neighbor,
|
|
using them as edges. Then each device seen in those entries is added as a
|
|
vertex.
|
|
|
|
Nodes without topology information are not included.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
sub make_graph {
|
|
my $G = Graph::Undirected->new();
|
|
|
|
my $devices = schema('netdisco')->resultset('Device')
|
|
->search({}, { columns => [qw/ip dns location /] });
|
|
my $links = schema('netdisco')->resultset('DevicePort')
|
|
->search({remote_ip => { -not => undef }},
|
|
{ columns => [qw/ip remote_ip speed remote_type/]});
|
|
my %aliases = map {$_->alias => $_->ip}
|
|
schema('netdisco')->resultset('DeviceIp')
|
|
->search({}, { columns => [qw/ip alias/] })->all;
|
|
|
|
my %devs = ( map {($_->ip => $_->dns)} $devices->all );
|
|
my %locs = ( map {($_->ip => $_->location)} $devices->all );
|
|
|
|
# Check for no topology info
|
|
unless ($links->count > 0) {
|
|
debug "make_graph() - No topology information. skipping.";
|
|
return undef;
|
|
}
|
|
|
|
my %link_seen = ();
|
|
my %linkmap = ();
|
|
|
|
while (my $link = $links->next) {
|
|
my $source = $link->ip;
|
|
my $dest = $link->remote_ip;
|
|
my $speed = $link->speed;
|
|
my $type = $link->remote_type;
|
|
|
|
# Check for Aliases
|
|
if (defined $aliases{$dest}) {
|
|
# Set to root device
|
|
$dest = $aliases{$dest};
|
|
}
|
|
|
|
# Remove loopback - After alias check (bbaetz)
|
|
if ($source eq $dest) {
|
|
debug " make_graph() - Loopback on $source";
|
|
next;
|
|
}
|
|
|
|
# Skip IP Phones
|
|
if (defined $type and $type =~ /ip.phone/i) {
|
|
debug " make_graph() - Skipping IP Phone. $source -> $dest ($type)";
|
|
next;
|
|
}
|
|
next if exists $link_seen{$source}->{$dest};
|
|
|
|
push(@{ $linkmap{$source} }, $dest);
|
|
|
|
# take care of reverse too
|
|
$link_seen{$source}->{$dest}++;
|
|
$link_seen{$dest}->{$source}++;
|
|
|
|
$GRAPH_SPEED{$source}->{$dest}->{speed}=$speed;
|
|
$GRAPH_SPEED{$dest}->{$source}->{speed}=$speed;
|
|
}
|
|
|
|
foreach my $link (keys %linkmap) {
|
|
foreach my $dest (@{ $linkmap{$link} }) {
|
|
|
|
foreach my $side ($link, $dest) {
|
|
unless (defined $GRAPH{$side}) {
|
|
my $is_dev = exists $devs{$side};
|
|
my $dns = $is_dev ?
|
|
$devs{$side} :
|
|
hostname_from_ip($side);
|
|
|
|
# Default to IP if no dns
|
|
$dns = defined $dns ? $dns : "($side)";
|
|
|
|
$G->add_vertex($side);
|
|
debug " make_graph() - add_vertex('$side')";
|
|
|
|
$GRAPH{$side}->{dns} = $dns;
|
|
$GRAPH{$side}->{isdev} = $is_dev;
|
|
$GRAPH{$side}->{seen}++;
|
|
$GRAPH{$side}->{location} = $locs{$side};
|
|
}
|
|
}
|
|
|
|
$G->add_edge($link,$dest);
|
|
debug " make_graph - add_edge('$link','$dest')";
|
|
}
|
|
}
|
|
|
|
return $G;
|
|
}
|
|
|
|
sub _homepath {
|
|
my ($path, $default) = @_;
|
|
|
|
my $home = $ENV{NETDISCO_HOME};
|
|
my $item = setting('graph')->{$path} || $default;
|
|
return undef unless defined($item);
|
|
|
|
if ($item =~ m,^/,) {
|
|
return $item;
|
|
}
|
|
else {
|
|
$home =~ s,/*$,,;
|
|
return $home . "/" . $item;
|
|
}
|
|
}
|
|
|
|
1;
|