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