#!/usr/bin/perl # # Copyright (C) Roman Dmitriev, rnd@rajven.ru # use utf8; use warnings; use Encode; use open qw(:std :encoding(UTF-8)); no warnings 'utf8'; use English; use base; use FindBin '$Bin'; use lib "/opt/Eye/scripts"; use strict; use DBI; use Time::Local; use Net::Patricia; use Data::Dumper; use Date::Parse; use Socket; use eyelib::config; use eyelib::main; use eyelib::logconfig; use eyelib::net_utils; use eyelib::snmp; use eyelib::database; use eyelib::common; use NetAddr::IP; use Fcntl qw(:flock); use Parallel::ForkManager; # Ensure only one instance of the script runs at a time open(SELF, "<", $0) or die "Cannot open $0 - $!"; flock(SELF, LOCK_EX | LOCK_NB) or exit 1; # Lower process priority to minimize system impact setpriority(0, 0, 19); # Skip discovery if the system is in configuration mode if ($config_ref{config_mode}) { log_info("System in configuration mode! Skip discovery."); exit; } # Clean up empty user accounts and associated devices for dynamic users and hotspot db_log_verbose($dbh, 'Clearing empty records.'); db_log_info($dbh, 'Clearing empty user accounts and associated devices for dynamic users and hotspot'); my $u_sql = "SELECT * FROM user_list AS U WHERE (U.ou_id = ? OR U.ou_id = ?) AND (SELECT COUNT(*) FROM user_auth WHERE user_auth.deleted = 0 AND user_auth.user_id = U.id) = 0"; my @u_ref = get_records_sql($dbh, $u_sql, $default_user_ou_id, $default_hotspot_ou_id); foreach my $row (@u_ref) { db_log_info($dbh, "Remove empty dynamic user with id: $row->{id} login: $row->{login}"); delete_user($dbh, $row->{id}); } # Clean up empty non-permanent user accounts that have no authentications or auth rules #if ($config_ref{clean_empty_user}) { # db_log_info($dbh, 'Clearing empty non-permanent user accounts and associated devices'); # my $u_sql = "SELECT * FROM user_list AS U WHERE U.permanent = 0 AND (SELECT COUNT(*) FROM user_auth WHERE user_auth.deleted = 0 AND user_auth.user_id = U.id) = 0 AND (SELECT COUNT(*) FROM auth_rules WHERE auth_rules.user_id = U.id) = 0;"; # my @u_ref = get_records_sql($dbh, $u_sql); # foreach my $row (@u_ref) { # db_log_info($dbh, "Remove empty user with id: $row->{id} login: $row->{login}"); # delete_user($dbh, $row->{id}); # } #} # Clean temporary (dynamic) user authentication records that have expired my $now = DateTime->now(time_zone => 'local'); my $clear_time_str = $now->strftime('%Y-%m-%d %H:%M:%S'); my $users_sql = "SELECT * FROM user_auth WHERE deleted = 0 AND dynamic = 1 AND end_life <= ?"; my @users_auth = get_records_sql($dbh, $users_sql, $clear_time_str); if (@users_auth and scalar @users_auth) { foreach my $row (@users_auth) { delete_user_auth($dbh, $row->{id}); db_log_info($dbh, "Removed dynamic user auth record for auth_id: $row->{'id'} by end_life time: $row->{'end_life'}", $row->{'id'}); my $u_count = get_count_records($dbh, 'user_auth', 'deleted = 0 AND user_id = ? ', $row->{user_id}); if (!$u_count) { delete_user($dbh, $row->{'user_id'}); } } } # Track MAC address history for change detection my %mac_history; # Get current timestamp components my ($sec, $min, $hour, $day, $month, $year, $zone) = localtime(time()); $month += 1; $year += 1900; # Set parallelization level: 5 processes per CPU core my $fork_count = $cpu_count * 5; # Optional: disable forking during debugging (currently descriptioned out) # if ($debug) { $fork_count = 0; } my $now_str = sprintf "%04d-%02d-%02d %02d:%02d:%02d", $year, $month, $day, $hour, $min, $sec; my $now_day = sprintf "%04d-%02d-%02d", $year, $month, $day; db_log_verbose($dbh, 'ARP discovery started.'); # If script is called with an argument, perform active ping-based network discovery if ($ARGV[0]) { db_log_verbose($dbh, 'Active network check started!'); my $subnets = get_subnets_ref($dbh); my @fping_cmd = (); foreach my $net (keys %$subnets) { next if (!$net); next if (!$subnets->{$net}{discovery}); my $run_cmd = "$fping -g $subnets->{$net}{subnet} -B1.0 -c 1 >/dev/null 2>&1"; db_log_debug($dbh, "Checked network $subnets->{$net}{subnet}") if ($debug); push(@fping_cmd, $run_cmd); } $parallel_process_count = $cpu_count * 2; run_in_parallel(@fping_cmd); } # Fetch all SNMP-enabled routers and L3 switches eligible for ARP discovery my @router_ref = get_records_sql($dbh, "SELECT * FROM devices WHERE deleted = 0 AND (device_type = 2 OR device_type = 0) AND discovery = 1 AND snmp_version > 0 ORDER BY ip"); # Release main DB handle before forking $dbh->disconnect; my @arp_array = (); # Initialize parallel manager for ARP table collection my $pm_arp = Parallel::ForkManager->new($fork_count); # Callback to collect ARP table results from child processes $pm_arp->run_on_finish( sub { my ($pid, $exit_code, $ident, $exit_signal, $core_dump, $data_structure_reference) = @_; if (defined($data_structure_reference)) { my $result = ${$data_structure_reference}; push(@arp_array, $result); } } ); # Iterate over each router and collect its ARP table in parallel DATA_LOOP: foreach my $router (@router_ref) { my $router_ip = $router->{ip}; setCommunity($router); if (!(HostIsLive($router_ip) && snmp_ping($router_ip, $router->{snmp}))) { log_info("Host id: $router->{id} name: $router->{device_name} ip: $router_ip is down or not snmp access! Skip."); next; } $pm_arp->start() and next DATA_LOOP; my $arp_table; my $tmp_dbh = init_db(); if (apply_device_lock($tmp_dbh, $router->{id},0)) { $arp_table = get_arp_table($router_ip, $router->{snmp}); log_debug("ARP table for $router_ip: ".Dumper($arp_table)); unset_lock_discovery($tmp_dbh, $router->{id}); } $tmp_dbh->disconnect; $pm_arp->finish(0, \$arp_table); } # Wait for all ARP collection processes to finish $pm_arp->wait_all_children; ########################### End ARP collection forks ######################### # Reconnect to database after forking $dbh = init_db(); # Load all active user authentications indexed by IP my @authlist_ref = get_records_sql($dbh, "SELECT * FROM user_auth WHERE deleted = 0 ORDER BY ip_int"); # full user ip records my $users = Net::Patricia->new; my %ip_list; foreach my $row (@authlist_ref) { $users->add_string($row->{ip}, $row->{id}); $ip_list{$row->{id}}->{id} = $row->{id}; $ip_list{$row->{id}}->{ip} = $row->{ip}; $ip_list{$row->{id}}->{mac} = mac_splitted($row->{mac}) || ''; } log_debug("Found ARP tables: ".Dumper(\@arp_array)); # Process all collected ARP tables foreach my $arp_table (@arp_array) { foreach my $ip (keys %$arp_table) { next if (!$arp_table->{$ip}); my $mac = trim($arp_table->{$ip}); $mac = mac_splitted($mac); next if (!$mac); next if ($mac =~ /ff:ff:ff:ff:ff:ff/i); # Skip broadcast MAC next if ($mac !~ /(\S{2}):(\S{2}):(\S{2}):(\S{2}):(\S{2}):(\S{2})/); # Validate MAC format my $simple_mac = mac_simplify($mac); $ip = trim($ip); my $ip_aton = StrToIp($ip); # Skip IPs outside configured office networks next if (!$office_networks->match_string($ip)); log_debug("Analyze ip: $ip mac: $mac") if ($debug); my $auth_id = $users->match_string($ip); my $arp_record; $arp_record->{ip} = $ip; $arp_record->{mac} = $mac; $arp_record->{type} = 'arp'; $arp_record->{ip_aton} = $ip_aton; $arp_record->{hotspot} = is_hotspot($dbh, $ip); # Attempt to resurrect or map this ARP entry to a known auth record my $cur_auth_id = resurrection_auth($dbh, $arp_record); if (!$cur_auth_id) { log_warning("Unknown record " . Dumper($arp_record)) } } } db_log_verbose($dbh, 'MAC (FDB) discovery started.'); # Load existing port connections for authenticated users my %connections = (); my @connections_list = get_records_sql($dbh, "SELECT * FROM connections ORDER BY auth_id"); foreach my $connection (@connections_list) { next if (!$connection); $connections{$connection->{auth_id}}{port} = $connection->{port_id}; $connections{$connection->{auth_id}}{id} = $connection->{id}; } # Build operational and full MAC-to-auth lookup tables my $auth_sql = "SELECT id, mac FROM user_auth WHERE mac IS NOT NULL AND deleted = 0 AND arp_found >= ? ORDER BY id ASC"; my @auth_list = get_records_sql($dbh, $auth_sql, $now_day); my %auth_table; foreach my $auth (@auth_list) { next if (!$auth || !$auth->{mac}); my $auth_mac = mac_simplify($auth->{mac}); $auth_table{oper_table}{$auth_mac} = $auth->{id}; } $auth_sql = "SELECT id, mac FROM user_auth WHERE mac IS NOT NULL AND deleted = 0 ORDER BY arp_found DESC, id DESC"; my @auth_full_list = get_records_sql($dbh, $auth_sql); foreach my $auth (@auth_full_list) { next if (!$auth || !$auth->{mac}); my $auth_mac = mac_simplify($auth->{mac}); next if (exists $auth_table{full_table}{$auth_mac}); $auth_table{full_table}{$auth_mac} = $auth->{id}; } # Load unknown MAC addresses from the database my @unknown_list = get_records_sql($dbh, "SELECT id, mac, port_id, device_id FROM unknown_mac WHERE mac IS NOT NULL ORDER BY mac;"); my %unknown_table; foreach my $unknown (@unknown_list) { next if (!$unknown); next if (!$unknown->{mac}); my $unknown_mac = mac_simplify($unknown->{mac}); $unknown_table{$unknown_mac}{unknown_id} = $unknown->{id}; $unknown_table{$unknown_mac}{port_id} = $unknown->{port_id}; $unknown_table{$unknown_mac}{device_id} = $unknown->{device_id}; } # Fetch all SNMP-enabled devices (switches, routers, etc.) for FDB discovery my @device_list = get_records_sql($dbh,"SELECT * FROM devices WHERE deleted = 0 AND device_type <= 2 AND discovery = 1 AND snmp_version > 0"); my @fdb_array = (); # Initialize parallel manager for FDB (forwarding database) collection my $pm_fdb = Parallel::ForkManager->new($fork_count); # Callback to collect FDB results from child processes $pm_fdb->run_on_finish( sub { my ($pid, $exit_code, $ident, $exit_signal, $core_dump, $data_structure_reference) = @_; if (defined($data_structure_reference)) { my $result = ${$data_structure_reference}; push(@fdb_array, $result); } } ); # Release main DB handle before forking $dbh->disconnect; # Collect FDB tables from each device in parallel FDB_LOOP: foreach my $device (@device_list) { setCommunity($device); if (!(HostIsLive($device->{ip}) && snmp_ping($device->{ip}, $device->{snmp}))) { log_info("Host id: $device->{id} name: $device->{device_name} ip: $device->{ip} is down or not snmp access! Skip."); next; } $pm_fdb->start() and next FDB_LOOP; my $result; my $tmp_dbh = init_db(); if (apply_device_lock($tmp_dbh, $device->{id},0)) { my $fdb = get_fdb_table($device->{ip}, $device->{snmp}); log_debug("MAC table for device $device->{ip}: ".Dumper($fdb)); unset_lock_discovery($tmp_dbh, $device->{id}); $result->{id} = $device->{id}; $result->{fdb} = $fdb; } $tmp_dbh->disconnect; $pm_fdb->finish(0, \$result); } # Wait for all FDB collection processes to finish $pm_fdb->wait_all_children; # Index FDB results by device ID my %fdb_ref; foreach my $fdb_table (@fdb_array) { next if (!$fdb_table); $fdb_ref{$fdb_table->{id}}{fdb} = $fdb_table->{fdb}; } ################################ End FDB collection forks ############################## # Reconnect to database after forking $dbh = init_db(); # Process FDB data for each device foreach my $device (@device_list) { my %port_snmp_index = (); # SNMP index → logical port number my %port_index = (); # logical port number → DB port ID my %mac_port_count = (); # port → number of learned MACs my %mac_address_table = (); # MAC → port my %port_links = (); # port → uplink/downlink target port ID my $dev_id = $device->{id}; my $dev_name = $device->{device_name}; my $fdb = $fdb_ref{$dev_id}{fdb}; next if (!$fdb); # Load device port mappings from database my @device_ports = get_records_sql($dbh, "SELECT * FROM device_ports WHERE device_id = ?", $dev_id); foreach my $port_data (@device_ports) { my $fdb_port_index = $port_data->{port}; my $port_id = $port_data->{id}; if (!$port_data->{snmp_index}) { $port_data->{snmp_index} = $port_data->{port}; } $fdb_port_index = $port_data->{snmp_index}; next if ($port_data->{skip}); $port_snmp_index{$port_data->{snmp_index}} = $port_data->{port}; $port_index{$port_data->{port}} = $port_id; $port_links{$port_data->{port}} = $port_data->{target_port_id}; $mac_port_count{$port_data->{port}} = 0; } # Special handling for MikroTik: skip device's own MAC addresses my $sw_mac; if ($device->{vendor_id} eq '9') { my $sw_auth = get_record_sql($dbh, "SELECT mac FROM user_auth WHERE deleted = 0 AND ip = ?", $device->{ip}); if (!$sw_auth->{mac}) { log_error("Mac for $dev_name is undefined!"); $sw_mac='undef'; } else { $sw_mac = mac_simplify($sw_auth->{mac}); $sw_mac =~ s/.{2}$//s; # Strip last two hex chars for prefix match } } # Process each MAC in the FDB foreach my $mac (keys %$fdb) { my $port = $fdb->{$mac}; next if (!$port); # Resolve SNMP index to logical port number if (exists $port_snmp_index{$port}) { $port = $port_snmp_index{$port}; } next if (!exists $port_index{$port}); # Skip MikroTik's own MACs if ($sw_mac && $mac =~ /^$sw_mac/i) { next; } $mac_port_count{$port}++; $mac_address_table{$mac} = $port; } # Update MAC count per port in the database (skip uplinks/downlinks) foreach my $port (keys %mac_port_count) { next if (!$port || !exists $port_index{$port} || $port_links{$port} > 0); my $dev_ports; $dev_ports->{last_mac_count} = $mac_port_count{$port}; update_record($dbh, 'device_ports', $dev_ports, "device_id = ? AND port = ?", $dev_id, $port); } # Process each learned MAC address foreach my $mac (keys %mac_address_table) { my $port = $mac_address_table{$mac}; next if (!$port || !exists $port_index{$port} || $port_links{$port} > 0); my $simple_mac = mac_simplify($mac); my $mac_splitted = mac_splitted($mac); $mac_history{$simple_mac}{port_id} = $port_index{$port}; $mac_history{$simple_mac}{dev_id} = $dev_id; $mac_history{$simple_mac}{changed} //= 0; my $port_id = $port_index{$port}; # Case 1: MAC belongs to a known authenticated user if (exists $auth_table{full_table}{$simple_mac} || exists $auth_table{oper_table}{$simple_mac}) { my $auth_id = exists $auth_table{oper_table}{$simple_mac} ? $auth_table{oper_table}{$simple_mac} : $auth_table{full_table}{$simple_mac}; unless (exists $auth_table{oper_table}{$simple_mac}) { log_debug($dbh, "MAC not found in current ARP table. Using historical auth_id: $auth_id [$simple_mac] at device $dev_name [$port]", $auth_id); } $mac_history{$simple_mac}{auth_id} = $auth_id; $mac_history{$simple_mac}{changed} = 0; my $auth_rec; $auth_rec->{last_found} = $now_str; $auth_rec->{mac_found} = $now_str; #update_record($dbh, 'user_auth', $auth_rec, "id = ?", $auth_id); # update ALL active ip record update_records($dbh, 'user_auth', "deleted = 0 AND mac = ?", $auth_rec, $mac_splitted); if (exists $connections{$auth_id}) { if ($port_id == $connections{$auth_id}{port}) { # No port change: just update last seen time if in current MAC if (exists $auth_table{oper_table}{$simple_mac}) { my $auth_rec; $auth_rec->{last_found} = $now_str; $auth_rec->{mac_found} = $now_str; update_record($dbh, 'user_auth', $auth_rec, "id = ?", $auth_id); } next; } # Port changed: update connection and log $connections{$auth_id}{port} = $port_id; $mac_history{$simple_mac}{changed} = 1; db_log_info($dbh, "Found auth_id: $auth_id mac: [$mac_splitted] at device $dev_name [$port]. Update connection.", $auth_id); my $conn_rec; $conn_rec->{port_id} = $port_id; $conn_rec->{device_id} = $dev_id; update_record($dbh, 'connections', $conn_rec, "auth_id = ?", $auth_id); } else { # New connection for known user $mac_history{$simple_mac}{changed} = 1; $connections{$auth_id}{port} = $port_id; db_log_info($dbh, "Found auth_id: $auth_id mac: [$mac_splitted] at device $dev_name [$port]. Create connection.", $auth_id); my $conn_rec; $conn_rec->{port_id} = $port_id; $conn_rec->{device_id} = $dev_id; $conn_rec->{auth_id} = $auth_id; insert_record($dbh, 'connections', $conn_rec); } } # Case 2: MAC is unknown else { if (exists $unknown_table{$simple_mac}{unknown_id}) { # MAC already known but moved next if ($unknown_table{$simple_mac}{port_id} == $port_id && $unknown_table{$simple_mac}{device_id} == $dev_id); $mac_history{$simple_mac}{changed} = 1; $mac_history{$simple_mac}{auth_id} = 0; db_log_debug($dbh, "Unknown MAC $mac_splitted moved to $dev_name [$port]") if ($debug); my $unknown_rec; $unknown_rec->{port_id} = $port_id; $unknown_rec->{device_id} = $dev_id; update_record($dbh, 'unknown_mac', $unknown_rec, "id = ?", $unknown_table{$simple_mac}{unknown_id}); } else { # Brand new unknown MAC $mac_history{$simple_mac}{changed} = 1; $mac_history{$simple_mac}{auth_id} = 0; db_log_debug($dbh, "Unknown MAC $mac_splitted found at $dev_name [$port]") if ($debug); my $unknown_rec; $unknown_rec->{port_id} = $port_id; $unknown_rec->{device_id} = $dev_id; $unknown_rec->{mac} = mac_splitted($simple_mac); insert_record($dbh, 'unknown_mac', $unknown_rec); } } } } # Log all MAC movement/history events foreach my $mac (keys %mac_history) { next if (!$mac || !$mac_history{$mac}->{changed}); my $h_dev_id = $mac_history{$mac}->{dev_id} || ''; my $h_port_id = $mac_history{$mac}->{port_id} || ''; my $h_ip = $mac_history{$mac}->{ip}; my $h_auth_id = $mac_history{$mac}->{auth_id} || 0; next if (!$h_dev_id); my $history_rec; $history_rec->{device_id} = $h_dev_id; $history_rec->{port_id} = $h_port_id; $history_rec->{mac} = mac_splitted($mac); $history_rec->{ip} = $h_ip if ($h_ip); $history_rec->{auth_id} = $h_auth_id; insert_record($dbh, 'mac_history', $history_rec); } log_info("Work finished."); $dbh->disconnect; exit 0;