[#73] NetBIOS Poller - nbtstat and nbtwalk

This commit is contained in:
Eric A. Miller
2014-02-03 19:54:58 -05:00
parent 652bd171d6
commit 5847dafb6d
19 changed files with 547 additions and 11 deletions

View File

@@ -5,6 +5,7 @@
* [#86] Use Vendor abbrevs to enhance node display in device port view * [#86] Use Vendor abbrevs to enhance node display in device port view
* [#74] Device Name / DNS mismatches report * [#74] Device Name / DNS mismatches report
* [#71] Node search by date (but not time) * [#71] Node search by date (but not time)
* [#73] NetBIOS Poller - nbtstat and nbtwalk
[ENHANCEMENTS] [ENHANCEMENTS]

View File

@@ -77,6 +77,7 @@ if (!length $action) {
with 'App::Netdisco::Daemon::Worker::Poller::Device'; with 'App::Netdisco::Daemon::Worker::Poller::Device';
with 'App::Netdisco::Daemon::Worker::Poller::Arpnip'; with 'App::Netdisco::Daemon::Worker::Poller::Arpnip';
with 'App::Netdisco::Daemon::Worker::Poller::Macsuck'; with 'App::Netdisco::Daemon::Worker::Poller::Macsuck';
with 'App::Netdisco::Daemon::Worker::Poller::Nbtstat';
with 'App::Netdisco::Daemon::Worker::Poller::Expiry'; with 'App::Netdisco::Daemon::Worker::Poller::Expiry';
with 'App::Netdisco::Daemon::Worker::Interactive::DeviceActions'; with 'App::Netdisco::Daemon::Worker::Interactive::DeviceActions';
with 'App::Netdisco::Daemon::Worker::Interactive::PortActions'; with 'App::Netdisco::Daemon::Worker::Interactive::PortActions';
@@ -129,9 +130,9 @@ C<-D> flag is given.
This program allows you to run any Netdisco poller job from the command-line. This program allows you to run any Netdisco poller job from the command-line.
Note that some jobs (C<discoverall>, C<macwalk>, C<arpwalk>) simply add Note that some jobs (C<discoverall>, C<macwalk>, C<arpwalk>), C<nbtwalk>)
entries to the Netdisco job queue for other jobs, so won't seem to do much simply add entries to the Netdisco job queue for other jobs, so won't seem
when you trigger them. to do much when you trigger them.
=head1 ACTIONS =head1 ACTIONS
@@ -147,6 +148,10 @@ Run a macsuck on the device (specified with C<-d>).
Run an arpnip on the device (specified with C<-d>). Run an arpnip on the device (specified with C<-d>).
=head2 nbtstat
Run an nbtstat on the node (specified with C<-d>).
=head2 set_location =head2 set_location
Set the SNMP location field on the device (specified with C<-d>). Pass the Set the SNMP location field on the device (specified with C<-d>). Pass the

View File

@@ -0,0 +1,159 @@
package App::Netdisco::Core::Nbtstat;
use Dancer qw/:syntax :script/;
use Dancer::Plugin::DBIC 'schema';
use App::Netdisco::Util::SanityCheck 'check_mac';
use NetAddr::IP::Lite ':lower';
use Time::HiRes 'gettimeofday';
use Net::NBName;
use base 'Exporter';
our @EXPORT = ();
our @EXPORT_OK = qw/ do_nbtstat store_nbt /;
our %EXPORT_TAGS = (all => \@EXPORT_OK);
=head1 NAME
App::Netdisco::Core::Nbtstat
=head1 DESCRIPTION
Helper subroutines to support parts of the Netdisco application.
There are no default exports, however the C<:all> tag will export all
subroutines.
=head1 EXPORT_OK
=head2 do_nbtstat( $node )
Connects to node and gets NetBIOS information. Then adds entries to
node_nbt table.
Returns whether a node is answering netbios calls or not.
=cut
sub do_nbtstat {
my $host = shift;
my $ip = NetAddr::IP::Lite->new($host) or return;
unless ( $ip->version() == 4 ) {
debug ' nbtstat only supports IPv4, invalid ip %s', $ip->addr;
return;
}
my $nb = Net::NBName->new;
my $ns = $nb->node_status( $ip->addr );
# Check for NetBIOS Info
return unless $ns;
my $nbname = _filter_nbname( $ip->addr, $ns );
if ($nbname) {
store_nbt($nbname);
}
return 1;
}
# filter nbt names / information
sub _filter_nbname {
my $ip = shift;
my $node_status = shift;
my $server = 0;
my $nbname = '';
my $domain = '';
my $nbuser = '';
for my $rr ( $node_status->names ) {
my $suffix = defined $rr->suffix ? $rr->suffix : -1;
my $G = defined $rr->G ? $rr->G : '';
my $name = defined $rr->name ? $rr->name : '';
if ( $suffix == 0 and $G eq "GROUP" ) {
$domain = $name;
}
if ( $suffix == 3 and $G eq "UNIQUE" ) {
$nbuser = $name;
}
if ( $suffix == 0 and $G eq "UNIQUE" ) {
$nbname = $name unless $name =~ /^IS~/;
}
if ( $suffix == 32 and $G eq "UNIQUE" ) {
$server = 1;
}
}
unless ($nbname) {
debug ' nbtstat no computer name found for %s', $ip;
return;
}
my $mac = $node_status->mac_address || '';
unless ( check_mac( $ip, $mac ) ) {
# Just assume it's the last MAC we saw this IP at.
my $node_ip = schema('netdisco')->resultset('NodeIp')
->single( { ip => $ip, active => \'true' } );
if ( !defined $node_ip ) {
debug ' no MAC for %s returned by nbtstat or in DB', $ip;
return;
}
$mac = $node_ip->mac;
}
return {
ip => $ip,
mac => $mac,
nbname => $nbname,
domain => $domain,
server => $server,
nbuser => $nbuser
};
}
=item store_nbt($nb_hash_ref, $now?)
Stores entries in C<node_nbt> table from the provided hash reference; MAC
C<mac>, IP C<ip>, Unique NetBIOS Node Name C<nbname>, NetBIOS Domain or
Workgroup C<domain>, whether the Server Service is running C<server>,
and the current NetBIOS user C<nbuser>.
Adds new entry or time stamps matching one.
Optionally a literal string can be passed in the second argument for the
C<time_last> timestamp, otherwise the current timestamp (C<now()>) is used.
=cut
sub store_nbt {
my ( $hash_ref, $now ) = @_;
$now ||= 'now()';
schema('netdisco')->resultset('NodeNbt')->update_or_create(
{ mac => $hash_ref->{'mac'},
ip => $hash_ref->{'ip'},
nbname => $hash_ref->{'nbname'},
domain => $hash_ref->{'domain'},
server => $hash_ref->{'server'},
nbuser => $hash_ref->{'nbuser'},
active => \'true',
time_last => \$now,
},
{ key => 'primary',
for => 'update',
}
);
return;
}
1;

View File

@@ -14,7 +14,7 @@ __PACKAGE__->result_source_instance->view_definition(<<ENDSQL
COUNT( device ) AS number, MIN( started ) AS start, MAX( finished ) AS end, COUNT( device ) AS number, MIN( started ) AS start, MAX( finished ) AS end,
justify_interval( extract ( epoch FROM( max( finished ) - min( started ) ) ) * interval '1 second' ) AS elapsed justify_interval( extract ( epoch FROM( max( finished ) - min( started ) ) ) * interval '1 second' ) AS elapsed
FROM admin FROM admin
WHERE action IN ( 'discover', 'macsuck', 'arpnip' ) WHERE action IN ( 'discover', 'macsuck', 'arpnip', 'nbtstat' )
GROUP BY action, entered GROUP BY action, entered
HAVING count( device ) > 1 HAVING count( device ) > 1
ORDER BY entered DESC, elapsed DESC ORDER BY entered DESC, elapsed DESC

View File

@@ -191,4 +191,28 @@ sub search_by_mac {
->search($cond, $attrs); ->search($cond, $attrs);
} }
=head2 ip_version( $version )
my $rset = $rs->ip_version(4);
This predefined C<search()> returns a ResultSet of matching rows from the
NodeIp table of nodes with addresses of the supplied IP version.
=over 4
=item *
The C<version> parameter must be an integer either 4 or 6.
=cut
sub ip_version {
my ( $rs, $version ) = @_;
die "ip_version input must be either 4 or 6\n"
unless $version && ( $version == 4 || $version == 6 );
return $rs->search_rs( \[ 'family(ip) = ?', $version ] );
}
1; 1;

View File

@@ -22,8 +22,10 @@ sub capacity_for {
debug "checking local capacity for action $action"; debug "checking local capacity for action $action";
my $action_map = { my $action_map = {
Poller => [qw/discoverall discover arpwalk arpnip macwalk macsuck expiry/], Poller => [
Interactive => [qw/location contact portcontrol portname vlan power/], qw/discoverall discover arpwalk arpnip macwalk macsuck nbtstat nbtwalk expiry/
],
Interactive => [qw/location contact portcontrol portname vlan power/],
}; };
my $role_map = { my $role_map = {

View File

@@ -13,7 +13,7 @@ my $fqdn = hostfqdn || 'localhost';
my $role_map = { my $role_map = {
(map {$_ => 'Poller'} (map {$_ => 'Poller'}
qw/discoverall discover arpwalk arpnip macwalk macsuck expiry/), qw/discoverall discover arpwalk arpnip macwalk macsuck nbtstat nbtwalk expiry/),
(map {$_ => 'Interactive'} (map {$_ => 'Interactive'}
qw/location contact portcontrol portname vlan power/) qw/location contact portcontrol portname vlan power/)
}; };

View File

@@ -10,6 +10,7 @@ with 'App::Netdisco::Daemon::Worker::Common';
with 'App::Netdisco::Daemon::Worker::Poller::Device', with 'App::Netdisco::Daemon::Worker::Poller::Device',
'App::Netdisco::Daemon::Worker::Poller::Arpnip', 'App::Netdisco::Daemon::Worker::Poller::Arpnip',
'App::Netdisco::Daemon::Worker::Poller::Macsuck', 'App::Netdisco::Daemon::Worker::Poller::Macsuck',
'App::Netdisco::Daemon::Worker::Poller::Nbtstat',
'App::Netdisco::Daemon::Worker::Poller::Expiry'; 'App::Netdisco::Daemon::Worker::Poller::Expiry';
sub worker_type { 'pol' } sub worker_type { 'pol' }

View File

@@ -88,4 +88,80 @@ sub _single_body {
return job_done("Ended $job_type for ". $host->addr); return job_done("Ended $job_type for ". $host->addr);
} }
# _walk_nodes_body
# Queue a job for all active nodes that have been seen in the last
# configured days.
#
sub _walk_nodes_body {
my ($self, $job_type) = @_;
my $action_method = $job_type .'_action';
my $job_action = $self->$action_method;
my $jobqueue = schema('netdisco')->resultset('Admin');
my $rs = schema('netdisco')->resultset('NodeIp')
->search({ip => { -not_in =>
$jobqueue->search({
device => { '!=' => undef},
action => $job_type,
status => { -like => 'queued%' },
})->get_column('device')->as_query
}, -bool => 'active'});
my $ip_version = $job_type .'_ip_version';
my $job_ip_ver = $self->$ip_version;
if ($job_ip_ver) {
$rs = $rs->ip_version($job_ip_ver)
}
my $config_max_age = $job_type . '_max_age';
my $max_age = setting($config_max_age);
if ($max_age) {
my $interval = "$max_age day";
$rs = $rs->search(
{ time_last => \[ '>= now() - ?::interval', $interval ] } );
}
my @nodes = $rs->get_column('ip')->all;
my $filter_method = $job_type .'_filter';
my $job_filter = $self->$filter_method;
my @filtered_nodes = grep {$job_filter->($_)} @nodes;
schema('netdisco')->resultset('Admin')->txn_do_locked(sub {
$jobqueue->populate([
map {{
device => $_,
action => $job_type,
status => 'queued',
}} (@filtered_nodes)
]);
});
return job_done("Queued $job_type job for all nodes");
}
sub _single_node_body {
my ($self, $job_type, $job) = @_;
my $action_method = $job_type .'_action';
my $job_action = $self->$action_method;
my $host = NetAddr::IP::Lite->new($job->device);
my $filter_method = $job_type .'_filter';
my $job_filter = $self->$filter_method;
unless ($job_filter->($host->addr)) {
return job_defer("$job_type deferred: $host is not ${job_type}able");
}
$job_action->($host->addr);
return job_done("Ended $job_type for ". $host->addr);
}
1; 1;

View File

@@ -0,0 +1,18 @@
package App::Netdisco::Daemon::Worker::Poller::Nbtstat;
use App::Netdisco::Core::Nbtstat 'do_nbtstat';
use App::Netdisco::Util::Node 'is_nbtstatable';
use Role::Tiny;
use namespace::clean;
with 'App::Netdisco::Daemon::Worker::Poller::Common';
sub nbtstat_action { \&do_nbtstat }
sub nbtstat_filter { \&is_nbtstatable }
sub nbtstat_ip_version { 4 }
sub nbtwalk { (shift)->_walk_nodes_body('nbtstat', @_) }
sub nbtstat { (shift)->_single_node_body('nbtstat', @_) }
1;

View File

@@ -14,10 +14,10 @@ my $jobactions = {
discoverall discoverall
arpwalk arpwalk
macwalk macwalk
nbtwalk
expiry expiry
/ /
# saveconfigs # saveconfigs
# nbtwalk
# backup # backup
}; };

View File

@@ -586,6 +586,31 @@ Value: Number. Default: 0.
Sets the minimum amount of time in seconds which must elapse between any two Sets the minimum amount of time in seconds which must elapse between any two
arpnip jobs for a device. arpnip jobs for a device.
=head3 C<nbtstat_no>
Value: List of Network Identifiers. Default: Empty List.
IP addresses in the list will not be visited for nbtstat. You can include
hostnames, IP addresses, subnets (nbtstat only supports IPv4), YAML Regexp
to match the DNS name, and address ranges (using a hyphen and no whitespace)
in the list.
=head3 C<nbtstat_only>
Value: List of Network Identifiers. Default: Empty List.
If present, nbtstat will be limited to IP addresses matching entries in this
list. You can include hostnames, IP addresses, subnets
(nbtstat only supports IPv4), YAML Regexp to match the DNS name, and address
ranges (using a hyphen and no whitespace).
=head3 C<nbtstat_max_age>
Value: Number. Default: 7.
The maximum age of a node in days for it to be checked for NetBIOS
information.
=head3 C<expire_devices> =head3 C<expire_devices>
Value: Number of Days. Value: Number of Days.
@@ -800,6 +825,8 @@ hour fields (which accept same types as C<cron> notation). For example:
min: 15 min: 15
hour: '*/2' hour: '*/2'
wday: 'mon-fri' wday: 'mon-fri'
nbtwalk:
when: '0 8,13,21 * * *'
expiry: expiry:
when: '20 23 * * *' when: '20 23 * * *'

View File

@@ -438,9 +438,10 @@ Interactive and Poller workers in their C<config.yml> file (zero or more of
each). each).
The fourth kind of worker is called the Scheduler and takes care of adding The fourth kind of worker is called the Scheduler and takes care of adding
discover, macsuck, and arpnip jobs to the queue (which are in turn handled by discover, macsuck, arpnip, and nbtstat jobs to the queue (which are in turn
the Poller worker). This worker is automatically started only if the user has handled by the Poller worker). This worker is automatically started only if
enabled the "C<housekeeping>" section of their C<deployment.yml> site config. the user has enabled the "C<housekeeping>" section of their
C<deployment.yml> site config.
=head2 SNMP::Info =head2 SNMP::Info

View File

@@ -0,0 +1,206 @@
package App::Netdisco::Util::Node;
use Dancer qw/:syntax :script/;
use Dancer::Plugin::DBIC 'schema';
use NetAddr::IP::Lite ':lower';
use App::Netdisco::Util::DNS 'hostname_from_ip';
use base 'Exporter';
our @EXPORT = ();
our @EXPORT_OK = qw/
check_node_acl
check_node_no
check_node_only
is_nbtstatable
/;
our %EXPORT_TAGS = (all => \@EXPORT_OK);
=head1 NAME
App::Netdisco::Util::Node
=head1 DESCRIPTION
A set of helper subroutines to support parts of the Netdisco application.
There are no default exports, however the C<:all> tag will export all
subroutines.
=head1 EXPORT_OK
=head2 check_node_acl( $ip, \@config )
Given the IP address of a node, returns true if any of the items in C<<
\@config >> matches that node, otherwise returns false.
Normally you use C<check_node_no> and C<check_node_only>, passing the name of the
configuration setting to load. This helper instead requires not the name of
the setting, but its value.
=cut
sub check_node_acl {
my ($ip, $config) = @_;
my $device = get_device($ip) or return 0;
my $addr = NetAddr::IP::Lite->new($device->ip);
foreach my $item (@$config) {
if (ref qr// eq ref $item) {
my $name = hostname_from_ip($addr->addr) or next;
return 1 if $name =~ $item;
next;
}
if ($item =~ m/([a-f0-9]+)-([a-f0-9]+)$/i) {
my $first = $1;
my $last = $2;
if ($item =~ m/:/) {
next unless $addr->bits == 128;
$first = hex $first;
$last = hex $last;
(my $header = $item) =~ s/:[^:]+$/:/;
foreach my $part ($first .. $last) {
my $ip = NetAddr::IP::Lite->new($header . sprintf('%x',$part) . '/128')
or next;
return 1 if $ip == $addr;
}
}
else {
next unless $addr->bits == 32;
(my $header = $item) =~ s/\.[^.]+$/./;
foreach my $part ($first .. $last) {
my $ip = NetAddr::IP::Lite->new($header . $part . '/32')
or next;
return 1 if $ip == $addr;
}
}
next;
}
my $ip = NetAddr::IP::Lite->new($item)
or next;
next unless $ip->bits == $addr->bits;
return 1 if $ip->contains($addr);
}
return 0;
}
=head2 check_node_no( $ip, $setting_name )
Given the IP address of a node, returns true if the configuration setting
C<$setting_name> matches that device, else returns false. If the setting
is undefined or empty, then C<check_node_no> also returns false.
print "rejected!" if check_node_no($ip, 'nbtstat_no');
There are several options for what C<$setting_name> can contain:
=over 4
=item *
Hostname, IP address, IP prefix
=item *
IP address range, using a hyphen and no whitespace
=item *
Regular Expression in YAML format which will match the node DNS name, e.g.:
- !!perl/regexp ^sep0.*$
=back
To simply match all nodes, use "C<any>" or IP Prefix "C<0.0.0.0/0>". All
regular expressions are anchored (that is, they must match the whole string).
To match no nodes we recommend an entry of "C<localhost>" in the setting.
=cut
sub check_node_no {
my ($ip, $setting_name) = @_;
my $config = setting($setting_name) || [];
return 0 if not scalar @$config;
return check_acl($ip, $config);
}
=head2 check_node_only( $ip, $setting_name )
Given the IP address of a node, returns true if the configuration setting
C<$setting_name> matches that node, else returns false. If the setting
is undefined or empty, then C<check_node_only> also returns true.
print "rejected!" unless check_node_only($ip, 'nbtstat_only');
There are several options for what C<$setting_name> can contain:
=over 4
=item *
Hostname, IP address, IP prefix
=item *
IP address range, using a hyphen and no whitespace
=item *
Regular Expression in YAML format which will match the node DNS name, e.g.:
- !!perl/regexp ^sep0.*$
=back
To simply match all nodes, use "C<any>" or IP Prefix "C<0.0.0.0/0>". All
regular expressions are anchored (that is, they must match the whole string).
To match no nodes we recommend an entry of "C<localhost>" in the setting.
=cut
sub check_node_only {
my ($ip, $setting_name) = @_;
my $config = setting($setting_name) || [];
return 1 if not scalar @$config;
return check_acl($ip, $config);
}
=head2 is_nbtstatable( $ip )
Given an IP address, returns C<true> if Netdisco on this host is permitted by
the local configuration to nbtstat the node.
The configuration items C<nbtstat_no> and C<nbtstat_only> are checked
against the given IP.
Returns false if the host is not permitted to nbtstat the target node.
=cut
sub is_nbtstatable {
my $ip = shift;
return _bail_msg("is_nbtstatable: node matched nbtstat_no")
if check_node_no($ip, 'nbtstat_no');
return _bail_msg("is_nbtstatable: node failed to match nbtstat_only")
unless check_node_only($ip, 'nbtstat_only');
return 1;
}
1;

View File

@@ -40,6 +40,7 @@ my %jobs_all = map {$_ => 1} qw/
discoverall discoverall
macwalk macwalk
arpwalk arpwalk
nbtwalk
/; /;
foreach my $jobtype (keys %jobs_all, keys %jobs) { foreach my $jobtype (keys %jobs_all, keys %jobs) {

View File

@@ -108,6 +108,9 @@ snmpforce_v3: []
arpnip_no: [] arpnip_no: []
arpnip_only: [] arpnip_only: []
arpnip_min_age: 0 arpnip_min_age: 0
nbtstat_no: []
nbtstat_only: []
nbtstat_max_age: 7
expire_devices: 0 expire_devices: 0
expire_nodes: 0 expire_nodes: 0
expire_nodes_archive: 0 expire_nodes_archive: 0
@@ -173,6 +176,8 @@ dns:
# min: 15 # min: 15
# hour: '*/2' # hour: '*/2'
# wday: 'mon-fri' # wday: 'mon-fri'
# nbtwalk:
# when: '0 8,13,21 * * *'
# --------------- # ---------------
# DANCER INTERNAL # DANCER INTERNAL

View File

@@ -43,6 +43,8 @@ database:
# arpwalk: # arpwalk:
# when: # when:
# min: 50 # min: 50
# nbtwalk:
# when: '0 8,13,21 * * *'
# expiry: # expiry:
# when: '20 23 * * *' # when: '20 23 * * *'

View File

@@ -24,6 +24,9 @@
[% ELSIF NOT dis AND row.action == 'discover' %] [% ELSIF NOT dis AND row.action == 'discover' %]
class="info" class="info"
[% SET dis = 1 %] [% SET dis = 1 %]
[% ELSIF NOT nbt AND row.action == 'nbtstat' %]
class="info"
[% SET nbt = 1 %]
[% END %] [% END %]
> >
<td class="nd_center-cell">[% row.action.ucfirst | html_entity %]</td> <td class="nd_center-cell">[% row.action.ucfirst | html_entity %]</td>

View File

@@ -105,6 +105,11 @@
<button type="submit" class="btn btn-link nd_btn-link">Macsuck All</button> <button type="submit" class="btn btn-link nd_btn-link">Macsuck All</button>
</form> </form>
</li> </li>
<li>
<form method="post" class="nd_inline-form" action="[% uri_for('/admin/nbtwalk') %]">
<button type="submit" class="btn btn-link nd_btn-link">Nbtstat All</button>
</form>
</li>
[% IF settings._admin_tasks.size %] [% IF settings._admin_tasks.size %]
<li class="divider"></li> <li class="divider"></li>
[% FOREACH ai IN settings._admin_order %] [% FOREACH ai IN settings._admin_order %]