1package App::Netdisco::Worker::Plugin::Macsuck::Nodes;
2
3use Dancer ':syntax';
4use App::Netdisco::Worker::Plugin;
5use aliased 'App::Netdisco::Worker::Status';
6
7use App::Netdisco::Transport::SNMP ();
8use App::Netdisco::Util::Permission 'check_acl_no';
9use App::Netdisco::Util::PortMAC 'get_port_macs';
10use App::Netdisco::Util::Device 'match_to_setting';
11use App::Netdisco::Util::Node 'check_mac';
12use App::Netdisco::Util::SNMP 'snmp_comm_reindex';
13use Dancer::Plugin::DBIC 'schema';
14use Time::HiRes 'gettimeofday';
15use Scope::Guard 'guard';
16
17register_worker({ phase => 'main', driver => 'snmp' }, sub {
18  my ($job, $workerconf) = @_;
19
20  my $device = $job->device;
21  my $snmp = App::Netdisco::Transport::SNMP->reader_for($device)
22    or return Status->defer("macsuck failed: could not SNMP connect to $device");
23
24  # would be possible just to use now() on updated records, but by using this
25  # same value for them all, we can if we want add a job at the end to
26  # select and do something with the updated set (see set archive, below)
27  my $now = 'to_timestamp('. (join '.', gettimeofday) .')';
28  my $total_nodes = 0;
29
30  # cache the device ports to save hitting the database for many single rows
31  my $device_ports = {map {($_->port => $_)}
32                          $device->ports(undef, {prefetch => {neighbor_alias => 'device'}})->all};
33
34  my $interfaces = $snmp->interfaces;
35
36  # get forwarding table data via basic snmp connection
37  my $fwtable = walk_fwtable($device, $interfaces, $device_ports);
38
39  # ...then per-vlan if supported
40  my @vlan_list = get_vlan_list($device);
41  {
42    my $guard = guard { snmp_comm_reindex($snmp, $device, 0) };
43    foreach my $vlan (@vlan_list) {
44      snmp_comm_reindex($snmp, $device, $vlan);
45      my $pv_fwtable =
46        walk_fwtable($device, $interfaces, $device_ports, $vlan);
47      $fwtable = {%$fwtable, %$pv_fwtable};
48    }
49  }
50
51  # now it's time to call store_node for every node discovered
52  # on every port on every vlan on this device.
53
54  # reverse sort allows vlan 0 entries to be included only as fallback
55  foreach my $vlan (reverse sort keys %$fwtable) {
56      foreach my $port (keys %{ $fwtable->{$vlan} }) {
57          my $vlabel = ($vlan ? $vlan : 'unknown');
58          debug sprintf ' [%s] macsuck - port %s vlan %s : %s nodes',
59            $device->ip, $port, $vlabel, scalar keys %{ $fwtable->{$vlan}->{$port} };
60
61          # make sure this port is UP in netdisco (unless it's a lag master,
62          # because we can still see nodes without a functioning aggregate)
63          $device_ports->{$port}->update({up_admin => 'up', up => 'up'})
64            if not $device_ports->{$port}->is_master;
65
66          foreach my $mac (keys %{ $fwtable->{$vlan}->{$port} }) {
67
68              # remove vlan 0 entry for this MAC addr
69              delete $fwtable->{0}->{$_}->{$mac}
70                for keys %{ $fwtable->{0} };
71
72              ++$total_nodes;
73              store_node($device->ip, $vlan, $port, $mac, $now);
74          }
75      }
76  }
77
78  debug sprintf ' [%s] macsuck - %s updated forwarding table entries',
79    $device->ip, $total_nodes;
80
81  # a use for $now ... need to archive disappeared nodes
82  my $archived = 0;
83
84  if (setting('node_freshness')) {
85    $archived = schema('netdisco')->resultset('Node')->search({
86      switch => $device->ip,
87      time_last => \[ "< ($now - ?::interval)",
88        setting('node_freshness') .' minutes' ],
89    })->update({ active => \'false' });
90  }
91
92  debug sprintf ' [%s] macsuck - removed %d fwd table entries to archive',
93    $device->ip, $archived;
94
95  $device->update({last_macsuck => \$now});
96  return Status->done("Ended macsuck for $device");
97});
98
99=head2 store_node( $ip, $vlan, $port, $mac, $now? )
100
101Writes a fresh entry to the Netdisco C<node> database table. Will mark old
102entries for this data as no longer C<active>.
103
104All four fields in the tuple are required. If you don't know the VLAN ID,
105Netdisco supports using ID "0".
106
107Optionally, a fifth argument can be the literal string passed to the time_last
108field of the database record. If not provided, it defaults to C<now()>.
109
110=cut
111
112sub store_node {
113  my ($ip, $vlan, $port, $mac, $now) = @_;
114  $now ||= 'now()';
115  $vlan ||= 0;
116
117  schema('netdisco')->txn_do(sub {
118    my $nodes = schema('netdisco')->resultset('Node');
119
120    my $old = $nodes->search(
121        { mac   => $mac,
122          # where vlan is unknown, need to archive on all other vlans
123          ($vlan ? (vlan => $vlan) : ()),
124          -bool => 'active',
125          -not  => {
126                    switch => $ip,
127                    port   => $port,
128                  },
129        })->update( { active => \'false' } );
130
131    # new data
132    $nodes->update_or_create(
133      {
134        switch => $ip,
135        port => $port,
136        vlan => $vlan,
137        mac => $mac,
138        active => \'true',
139        oui => substr($mac,0,8),
140        time_last => \$now,
141        (($old != 0) ? (time_recent => \$now) : ()),
142      },
143      {
144        key => 'primary',
145        for => 'update',
146      }
147    );
148  });
149}
150
151# return a list of vlan numbers which are OK to macsuck on this device
152sub get_vlan_list {
153  my $device = shift;
154
155  my $snmp = App::Netdisco::Transport::SNMP->reader_for($device)
156    or return (); # already checked!
157
158  return () unless $snmp->cisco_comm_indexing;
159
160  my (%vlans, %vlan_names);
161  my $i_vlan = $snmp->i_vlan || {};
162  my $trunks = $snmp->i_vlan_membership || {};
163  my $i_type = $snmp->i_type || {};
164
165  # get list of vlans in use
166  while (my ($idx, $vlan) = each %$i_vlan) {
167      # hack: if vlan id comes as 1.142 instead of 142
168      $vlan =~ s/^\d+\.//;
169
170      # VLANs are ports interfaces capture VLAN, but don't count as in use
171      # Port channels are also 'propVirtual', but capture while checking
172      # trunk VLANs below
173      if (exists $i_type->{$idx} and $i_type->{$idx} eq 'propVirtual') {
174        $vlans{$vlan} ||= 0;
175      }
176      else {
177        ++$vlans{$vlan};
178      }
179      foreach my $t_vlan (@{$trunks->{$idx}}) {
180        ++$vlans{$t_vlan};
181      }
182  }
183
184  unless (scalar keys %vlans) {
185      debug sprintf ' [%s] macsuck - no VLANs found.', $device->ip;
186      return ();
187  }
188
189  my $v_name = $snmp->v_name || {};
190
191  # get vlan names (required for config which filters by name)
192  while (my ($idx, $name) = each %$v_name) {
193      # hack: if vlan id comes as 1.142 instead of 142
194      (my $vlan = $idx) =~ s/^\d+\.//;
195
196      # just in case i_vlan is different to v_name set
197      # capture the VLAN, but it's not in use on a port
198      $vlans{$vlan} ||= 0;
199
200      $vlan_names{$vlan} = $name;
201  }
202
203  debug sprintf ' [%s] macsuck - VLANs: %s', $device->ip,
204    (join ',', sort grep {$_} keys %vlans);
205
206  my @ok_vlans = ();
207  foreach my $vlan (sort keys %vlans) {
208      my $name = $vlan_names{$vlan} || '(unnamed)';
209
210      if (ref [] eq ref setting('macsuck_no_vlan')) {
211          my $ignore = setting('macsuck_no_vlan');
212
213          if ((scalar grep {$_ eq $vlan} @$ignore) or
214              (scalar grep {$_ eq $name} @$ignore)) {
215
216              debug sprintf
217                ' [%s] macsuck VLAN %s - skipped by macsuck_no_vlan config',
218                $device->ip, $vlan;
219              next;
220          }
221      }
222
223      if (ref [] eq ref setting('macsuck_no_devicevlan')) {
224          my $ignore = setting('macsuck_no_devicevlan');
225          my $ip = $device->ip;
226
227          if ((scalar grep {$_ eq "$ip:$vlan"} @$ignore) or
228              (scalar grep {$_ eq "$ip:$name"} @$ignore)) {
229
230              debug sprintf
231                ' [%s] macsuck VLAN %s - skipped by macsuck_no_devicevlan config',
232                $device->ip, $vlan;
233              next;
234          }
235      }
236
237      if (setting('macsuck_no_unnamed') and $name eq '(unnamed)') {
238          debug sprintf
239            ' [%s] macsuck VLAN %s - skipped by macsuck_no_unnamed config',
240            $device->ip, $vlan;
241          next;
242      }
243
244      if ($vlan > 4094) {
245          debug sprintf ' [%s] macsuck - invalid VLAN number %s',
246            $device->ip, $vlan;
247          next;
248      }
249      next if $vlan == 0; # quietly skip
250
251      # check in use by a port on this device
252      if (!$vlans{$vlan} && !setting('macsuck_all_vlans')) {
253          debug sprintf
254            ' [%s] macsuck VLAN %s/%s - not in use by any port - skipping.',
255            $device->ip, $vlan, $name;
256          next;
257      }
258
259      push @ok_vlans, $vlan;
260  }
261
262  return @ok_vlans;
263}
264
265# walks the forwarding table (BRIDGE-MIB) for the device and returns a
266# table of node entries.
267sub walk_fwtable {
268  my ($device, $interfaces, $device_ports, $comm_vlan) = @_;
269  my $skiplist = {}; # ports through which we can see another device
270  my $cache = {};
271
272  my $snmp = App::Netdisco::Transport::SNMP->reader_for($device)
273    or return $cache; # already checked!
274
275  my $fw_mac   = $snmp->fw_mac || {};
276  my $fw_port  = $snmp->fw_port || {};
277  my $fw_vlan  = ($snmp->can('cisco_comm_indexing') && $snmp->cisco_comm_indexing())
278    ? {} : $snmp->qb_fw_vlan;
279  my $bp_index = $snmp->bp_index || {};
280
281  my @fw_mac_list = values %$fw_mac;
282  my $port_macs = get_port_macs(\@fw_mac_list);
283
284  # to map forwarding table port to device port we have
285  #   fw_port -> bp_index -> interfaces
286
287  while (my ($idx, $mac) = each %$fw_mac) {
288      my $bp_id = $fw_port->{$idx};
289      next unless check_mac($mac, $device);
290
291      unless (defined $bp_id) {
292          debug sprintf
293            ' [%s] macsuck %s - %s has no fw_port mapping - skipping.',
294            $device->ip, $mac, $idx;
295          next;
296      }
297
298      my $iid = $bp_index->{$bp_id};
299
300      unless (defined $iid) {
301          debug sprintf
302            ' [%s] macsuck %s - port %s has no bp_index mapping - skipping.',
303            $device->ip, $mac, $bp_id;
304          next;
305      }
306
307      # WRT #475 this is SAFE because we check against known ports below
308      # but we do need the SNMP interface IDs to get the job done
309      my $port = $interfaces->{$iid};
310
311      unless (defined $port) {
312          debug sprintf
313            ' [%s] macsuck %s - iid %s has no port mapping - skipping.',
314            $device->ip, $mac, $iid;
315          next;
316      }
317
318      if (exists $skiplist->{$port}) {
319          debug sprintf
320            ' [%s] macsuck %s - seen another device thru port %s - skipping.',
321            $device->ip, $mac, $port;
322          next;
323      }
324
325      # this uses the cached $ports resultset to limit hits on the db
326      my $device_port = $device_ports->{$port};
327
328      # WRT #475 ... see? :-)
329      unless (defined $device_port) {
330          debug sprintf
331            ' [%s] macsuck %s - port %s is not in database - skipping.',
332            $device->ip, $mac, $port;
333          next;
334      }
335
336      my $vlan = $fw_vlan->{$idx} || $comm_vlan || '0';
337
338      # check to see if the port is connected to another device
339      # and if we have that device in the database.
340
341      # we have several ways to detect "uplink" port status:
342      #  * a neighbor was discovered using CDP/LLDP
343      #  * a mac addr is seen which belongs to any device port/interface
344      #  * (TODO) admin sets is_uplink_admin on the device_port
345
346      # allow to gather MACs on upstream port for some kinds of device that
347      # do not expose MAC address tables via SNMP. relies on prefetched
348      # neighbors otherwise it would kill the DB with device lookups.
349      my $neigh_cannot_macsuck = eval { # can fail
350        check_acl_no(($device_port->neighbor || "0 but true"), 'macsuck_unsupported') ||
351        match_to_setting($device_port->remote_type, 'macsuck_unsupported_type') };
352
353      if ($device_port->is_uplink) {
354          if ($neigh_cannot_macsuck) {
355              debug sprintf
356                ' [%s] macsuck %s - port %s neighbor %s without macsuck support',
357                $device->ip, $mac, $port,
358                (eval { $device_port->neighbor->ip }
359                 || ($device_port->remote_ip
360                     || $device_port->remote_id || '?'));
361              # continue!!
362          }
363          elsif (my $neighbor = $device_port->neighbor) {
364              debug sprintf
365                ' [%s] macsuck %s - port %s has neighbor %s - skipping.',
366                $device->ip, $mac, $port, $neighbor->ip;
367              next;
368          }
369          elsif (my $remote = $device_port->remote_ip) {
370              debug sprintf
371                ' [%s] macsuck %s - port %s has undiscovered neighbor %s',
372                $device->ip, $mac, $port, $remote;
373              # continue!!
374          }
375          elsif (not setting('macsuck_bleed')) {
376              debug sprintf
377                ' [%s] macsuck %s - port %s is detected uplink - skipping.',
378                $device->ip, $mac, $port;
379
380              $skiplist->{$port} = [ $vlan, $mac ] # remember for later
381                if exists $port_macs->{$mac};
382              next;
383          }
384      }
385
386      if (exists $port_macs->{$mac}) {
387          my $switch_ip = $port_macs->{$mac};
388          if ($device->ip eq $switch_ip) {
389              debug sprintf
390                ' [%s] macsuck %s - port %s connects to self - skipping.',
391                $device->ip, $mac, $port;
392              next;
393          }
394
395          debug sprintf ' [%s] macsuck %s - port %s is probably an uplink',
396            $device->ip, $mac, $port;
397          $device_port->update({is_uplink => \'true'});
398
399          # neighbor exists and Netdisco can speak to it, so we don't want
400          # its MAC address. however don't add to skiplist as that would
401          # clear all other MACs on the port.
402          next if $neigh_cannot_macsuck;
403
404          # when there's no CDP/LLDP, we only want to gather macs at the
405          # topology edge, hence skip ports with known device macs.
406          if (not setting('macsuck_bleed')) {
407                debug sprintf ' [%s] macsuck %s - adding port %s to skiplist',
408                    $device->ip, $mac, $port;
409
410                $skiplist->{$port} = [ $vlan, $mac ]; # remember for later
411                next;
412          }
413      }
414
415      # possibly move node to lag master
416      if (defined $device_port->slave_of
417            and exists $device_ports->{$device_port->slave_of}) {
418          $port = $device_port->slave_of;
419          $device_ports->{$port}->update({is_uplink => \'true'});
420      }
421
422      ++$cache->{$vlan}->{$port}->{$mac};
423  }
424
425  # restore MACs of neighbor devices.
426  # this is when we have a "possible uplink" detected but we still want to
427  # record the single MAC of the neighbor device so it works in Node search.
428  foreach my $port (keys %$skiplist) {
429      my ($vlan, $mac) = @{ $skiplist->{$port} };
430      delete $cache->{$_}->{$port} for keys %$cache; # nuke nodes on all VLANs
431      ++$cache->{$vlan}->{$port}->{$mac};
432  }
433
434  return $cache;
435}
436
437true;
438