dhcp-log.pl 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. #!/usr/bin/perl
  2. #
  3. # Author: Roman Dmitriev <rnd@rajven.ru>
  4. # Purpose: Script to parse DHCP logs, detect client connections via switches
  5. # using DHCP Option 82 (remote-id / circuit-id), and store link data in the database.
  6. #
  7. use utf8;
  8. use warnings;
  9. use Encode;
  10. use open qw(:std :encoding(UTF-8));
  11. no warnings 'utf8';
  12. use English;
  13. use base;
  14. use FindBin '$Bin';
  15. use lib "/opt/Eye/scripts";
  16. use Data::Dumper;
  17. use eyelib::config;
  18. use eyelib::main;
  19. use eyelib::database;
  20. use eyelib::common;
  21. use eyelib::net_utils;
  22. use strict;
  23. use Getopt::Long;
  24. use Proc::Daemon;
  25. use POSIX;
  26. use Net::Netmask;
  27. use Text::Iconv;
  28. use File::Tail;
  29. use Fcntl qw(:flock);
  30. # === LOCKING AND INITIALIZATION ===
  31. # Prevent multiple instances of the script
  32. open(SELF, "<", $0) or die "Cannot open $0 - $!";
  33. flock(SELF, LOCK_EX | LOCK_NB) or exit 1;
  34. # Set low process priority (nice = 19)
  35. setpriority(0, 0, 19);
  36. # === GLOBAL VARIABLES ===
  37. my $mute_time = 300; # Time (in seconds) to suppress duplicate DHCP events
  38. my $log_file = '/var/log/dhcp.log';
  39. # Determine process name and PID file
  40. my $proc_name = $MY_NAME;
  41. $proc_name =~ s/\.[^.]+$//;
  42. my $pid_file = '/run/eye/' . $proc_name;
  43. my $pf = $pid_file . '.pid';
  44. # Daemon setup
  45. my $daemon = Proc::Daemon->new(
  46. pid_file => $pf,
  47. work_dir => $HOME_DIR
  48. );
  49. # Check if process is already running
  50. my $pid = $daemon->Status($pf);
  51. my $daemonize = 1; # Default: run in background
  52. # === COMMAND-LINE ARGUMENT HANDLING ===
  53. GetOptions(
  54. 'daemon!' => \$daemonize,
  55. "help" => \&usage,
  56. "reload" => \&reload,
  57. "restart" => \&restart,
  58. "start" => \&run,
  59. "status" => \&status,
  60. "stop" => \&stop
  61. ) or &usage;
  62. exit(0);
  63. # === DAEMON CONTROL FUNCTIONS ===
  64. sub stop {
  65. log_info("Stop requested...");
  66. if ($pid) {
  67. print "Stopping pid $pid...";
  68. if ($daemon->Kill_Daemon($pf)) {
  69. print "Successfully stopped.\n";
  70. log_info("Daemon stopped successfully (PID $pid).");
  71. } else {
  72. print "Could not find $pid. Was it running?\n";
  73. log_warning("Failed to stop process PID $pid — possibly already terminated.");
  74. }
  75. } else {
  76. print "Not running, nothing to stop.\n";
  77. log_info("Daemon is not running — nothing to stop.");
  78. }
  79. }
  80. sub status {
  81. if ($pid) {
  82. print "Running with pid $pid.\n";
  83. log_info("Status: daemon is running (PID $pid).");
  84. } else {
  85. print "Not running.\n";
  86. log_info("Status: daemon is not running.");
  87. }
  88. }
  89. sub run {
  90. log_info("Starting main DHCP log processing loop...");
  91. if ($pid) {
  92. print "Already Running with pid $pid\n";
  93. log_warning("Attempt to start already running daemon (PID $pid).");
  94. return;
  95. }
  96. print "Starting...\n";
  97. log_info("Initializing daemon...");
  98. if ($daemonize) {
  99. $daemon->Init;
  100. log_debug("Daemon initialized in background mode.");
  101. }
  102. setpriority(0, 0, 19); # Ensure priority is set in child process
  103. # Converter for legacy cp866-encoded logs
  104. my $converter = Text::Iconv->new("cp866", "utf8");
  105. # Main infinite log-processing loop
  106. while (1) {
  107. eval {
  108. log_debug("Starting new DHCP log processing cycle.");
  109. my %leases; # cache to suppress duplicates
  110. # Establish fresh DB connection
  111. my $hdb = init_db();
  112. log_debug("Database connection established.");
  113. # Open log file for tail-like reading
  114. my $dhcp_log = File::Tail->new(
  115. name => $log_file,
  116. maxinterval => 5,
  117. interval => 1,
  118. ignore_nonexistent => 1
  119. ) || die "$log_file not found!";
  120. log_info("Beginning to read logs from $log_file...");
  121. while (my $logline = $dhcp_log->read) {
  122. next unless $logline;
  123. chomp($logline);
  124. log_info("Log line received: $logline");
  125. # Remove non-printable characters (keep letters, digits, punctuation, whitespace)
  126. $logline =~ s/[^\p{L}\p{N}\p{P}\p{Z}]//g;
  127. log_debug("Line after filtering: $logline");
  128. my @field_names = qw(
  129. type mac ip hostname timestamp
  130. tags sup_hostname old_hostname
  131. circuit_id remote_id client_id
  132. decoded_circuit_id decoded_remote_id
  133. );
  134. # Parse fields by semicolon
  135. my @values = split(/;/, $logline);
  136. my %dhcp_event;
  137. log_verbose("GET::");
  138. @dhcp_event{@field_names} = @values;
  139. for my $name (@field_names) {
  140. my $val = defined $dhcp_event{$name} ? $dhcp_event{$name} : '';
  141. log_verbose("Param '$name': $val");
  142. }
  143. # Skip lines without valid event type
  144. next unless $dhcp_event{'type'} && $dhcp_event{'type'} =~ /^(old|add|del)$/i;
  145. log_info("Processing event: type='$dhcp_event{'type'}', MAC='$dhcp_event{'mac'}', IP='$dhcp_event{'ip'}', NAME='$dhcp_event{'hostname'}', client-id='$dhcp_event{'client_id'}', circuit_id='$dhcp_event{'decoded_circuit_id'}', remote_id='$dhcp_event{'$decoded_remote_id'}'");
  146. # Suppress duplicate events within $mute_time window
  147. if (exists $leases{$dhcp_event{'ip'}} && $leases{$dhcp_event{'ip'}}{type} eq $dhcp_event{'type'} && (time() - $leases{$dhcp_event{'ip'}}{last_time} <= $mute_time)) {
  148. log_debug("Skipping duplicate: IP=$dhcp_event{'ip'}, type=$dhcp_event{'type'} (within $mute_time sec window)");
  149. next;
  150. }
  151. # Refresh config every 60 seconds
  152. if (time() - $last_refresh_config >= 60) {
  153. log_debug("Refreshing configuration...");
  154. init_option($hdb);
  155. }
  156. # Process DHCP request: update/create DB record
  157. my $dhcp_record = process_dhcp_request($hdb, $dhcp_event{'type'}, $dhcp_event{'mac'}, $dhcp_event{'ip'}, $dhcp_event{'hostname'}, $dhcp_event{'client_id'}, $dhcp_event{'decoded_circuit_id'}, $dhcp_event{'$decoded_remote_id'});
  158. next unless $dhcp_record;
  159. # Cache to suppress duplicates
  160. $leases{$dhcp_event{'ip'}} = {
  161. type => $dhcp_event{'type'},
  162. last_time => time()
  163. };
  164. my $auth_id = $dhcp_record->{auth_id};
  165. # === SWITCH AND PORT IDENTIFICATION LOGIC ===
  166. my ($switch, $switch_port);
  167. my ($t_remote_id, $t_circuit_id) = ($dhcp_event{'remote_id'}, $dhcp_event{'circuit_id'});
  168. # Only process connection events (add/old)
  169. if ($dhcp_event{'type'} =~ /^(add|old)$/i) {
  170. log_debug("Attempting to identify switch using Option 82 data...");
  171. # 1. Try decoded_remote_id as MAC address
  172. if ($dhcp_event{'$decoded_remote_id'}) {
  173. $t_remote_id = $dhcp_event{'$decoded_remote_id'};
  174. $t_remote_id .= "0" x (12 - length($t_remote_id)) if length($t_remote_id) < 12;
  175. $t_remote_id = mac_splitted(isc_mac_simplify($t_remote_id));
  176. my $devSQL = "SELECT D.id, D.device_name, D.ip, A.mac " .
  177. "FROM devices AS D, user_auth AS A " .
  178. "WHERE D.user_id = A.User_id AND D.ip = A.ip AND A.deleted = 0 " .
  179. "AND A.mac = ?";
  180. $switch = get_record_sql($hdb, $devSQL, $t_remote_id);
  181. if ($switch) {
  182. $dhcp_event{'remote_id'} = $t_remote_id;
  183. $dhcp_event{'circuit_id'} = $dhcp_event{'decoded_circuit_id'};
  184. $dhcp_record->{circuit_id} = $dhcp_event{'circuit_id'};
  185. $dhcp_record->{remote_id} = $dhcp_event{'remote_id'};
  186. log_info("Switch found via decoded_remote_id: " . $switch->{device_name});
  187. }
  188. }
  189. # 2. If not found, try raw remote_id as MAC
  190. if (!$switch && $dhcp_event{'remote_id'}) {
  191. $t_remote_id = $dhcp_event{'remote_id'};
  192. $t_remote_id .= "0" x (12 - length($t_remote_id)) if length($t_remote_id) < 12;
  193. $t_remote_id = mac_splitted(isc_mac_simplify($t_remote_id));
  194. my $devSQL = "SELECT D.id, D.device_name, D.ip, A.mac " .
  195. "FROM devices AS D, user_auth AS A " .
  196. "WHERE D.user_id = A.User_id AND D.ip = A.ip AND A.deleted = 0 " .
  197. "AND A.mac = ?";
  198. $switch = get_record_sql($hdb, $devSQL, $t_remote_id);
  199. if ($switch) {
  200. $dhcp_event{'remote_id'} = $t_remote_id;
  201. $dhcp_record->{circuit_id} = $dhcp_event{'circuit_id'};
  202. $dhcp_record->{remote_id} = $dhcp_event{'remote_id'};
  203. log_info("Switch found via remote_id: " . $switch->{device_name});
  204. }
  205. }
  206. # 3. If still not found, try remote_id as device name prefix
  207. if (!$switch && $dhcp_event{'remote_id'}) {
  208. my @id_words = split(/ /, $dhcp_event{'remote_id'});
  209. if ($id_words[0]) {
  210. my $devSQL = "SELECT D.id, D.device_name, D.ip, A.mac " .
  211. "FROM devices AS D, user_auth AS A " .
  212. "WHERE D.user_id = A.User_id AND D.ip = A.ip AND A.deleted = 0 " .
  213. "AND D.device_name LIKE ?";
  214. $switch = get_record_sql($hdb, $devSQL, $id_words[0] . '%');
  215. if ($switch) {
  216. log_info("Switch found by name: " . $switch->{device_name});
  217. }
  218. }
  219. }
  220. # 4. Special case: MikroTik (circuit-id may contain device name)
  221. if (!$switch && $dhcp_event{'circuit_id'}) {
  222. my @id_words = split(/ /, $dhcp_event{'circuit_id'});
  223. if ($id_words[0]) {
  224. my $devSQL = "SELECT D.id, D.device_name, D.ip, A.mac " .
  225. "FROM devices AS D, user_auth AS A " .
  226. "WHERE D.user_id = A.User_id AND D.ip = A.ip AND A.deleted = 0 " .
  227. "AND D.device_name LIKE ?";
  228. $switch = get_record_sql($hdb, $devSQL, $id_words[0] . '%');
  229. if ($switch) {
  230. # MikroTik often swaps circuit-id and remote-id
  231. ($dhcp_event{'circuit_id'}, $dhcp_event{'remote_id'}) = ($dhcp_event{'remote_id'}, $t_circuit_id);
  232. $dhcp_record->{circuit_id} = $dhcp_event{'circuit_id'};
  233. $dhcp_record->{remote_id} = $dhcp_event{'remote_id'};
  234. log_debug("Detected MikroTik — swapped circuit-id and remote-id");
  235. }
  236. }
  237. }
  238. # === LOG IF NO SWITCH MATCH FOUND ===
  239. unless ($switch) {
  240. log_info("No matching switch found for DHCP event: IP=$dhcp_event{'ip'}, MAC=$dhcp_event{'mac'}, remote_id='$dhcp_event{'remote_id'}', circuit_id='$dhcp_event{'circuit_id'}'");
  241. }
  242. # === PORT IDENTIFICATION ===
  243. if ($switch) {
  244. # Normalize circuit_id for port matching
  245. $t_circuit_id =~ s/[\+\-\s]+/ /g;
  246. # Load switch ports
  247. my @device_ports = get_records_sql($hdb, "SELECT * FROM device_ports WHERE device_id = ?", $switch->{id});
  248. my %device_ports_h;
  249. foreach my $port_data (@device_ports) {
  250. $port_data->{snmp_index} //= $port_data->{port};
  251. $device_ports_h{$port_data->{port}} = $port_data;
  252. }
  253. # Try to match by interface name (ifName)
  254. $switch_port = undef;
  255. foreach my $port_data (@device_ports) {
  256. if ($t_circuit_id =~ /\s*$port_data->{ifname}$/i ||
  257. $t_circuit_id =~ /^$port_data->{ifname}\s+/i) {
  258. $switch_port = $port_data;
  259. last;
  260. }
  261. }
  262. # If not found by name, try hex port (last 2 bytes of decoded_circuit_id)
  263. if (!$switch_port && $dhcp_event{'decoded_circuit_id'}) {
  264. my $hex_port = substr($dhcp_event{'decoded_circuit_id'}, -2);
  265. if ($hex_port && $hex_port =~ /^[0-9a-fA-F]{2}$/) {
  266. my $t_port = hex($hex_port);
  267. $switch_port = $device_ports_h{$t_port} if exists $device_ports_h{$t_port};
  268. log_info("Port identified via hex: $t_port") if $switch_port;
  269. }
  270. }
  271. # Log and update connection
  272. if ($switch_port) {
  273. db_log_verbose($hdb, "DHCP $dhcp_event{'type'}: IP=$dhcp_event{'ip'}, MAC=$dhcp_event{'mac'} " . $switch->{device_name} . " / " . $switch_port->{ifname});
  274. # Check if connection already exists
  275. my $connection = get_records_sql($hdb, "SELECT * FROM connections WHERE auth_id = ?", $auth_id);
  276. if (!$connection || !@{$connection}) {
  277. my $new_connection = {
  278. port_id => $switch_port->{id},
  279. device_id => $switch->{id},
  280. auth_id => $auth_id
  281. };
  282. insert_record($hdb, 'connections', $new_connection);
  283. log_debug("New connection created: auth_id=$auth_id");
  284. }
  285. } else {
  286. db_log_verbose($hdb, "DHCP $dhcp_event{'type'}: IP=$dhcp_event{'ip'}, MAC=$dhcp_event{'mac'} " . $switch->{device_name} . " (port not identified)");
  287. log_info("Failed to identify port for IP=$dhcp_event{'ip'} on switch=" . $switch->{device_name});
  288. }
  289. }
  290. log_verbose("Identified Switch: " . ($switch ? $switch->{device_name} : "NONE") . " Port : " . ($switch_port ? $switch_port->{ifname} : "NONE"));
  291. }
  292. } # end while log reading
  293. }; # end eval
  294. # Exception handling
  295. if ($@) {
  296. log_error("Critical error in main loop: $@");
  297. sleep(60); # pause before retry
  298. }
  299. } # end while(1)
  300. }
  301. # === HELPER FUNCTIONS ===
  302. sub usage {
  303. print "usage: $MY_NAME (start|stop|status|restart)\n";
  304. exit(0);
  305. }
  306. sub reload {
  307. print "reload process not implemented.\n";
  308. log_warning("Command 'reload' is not supported.");
  309. }
  310. sub restart {
  311. log_info("Restart requested...");
  312. stop();
  313. sleep(2);
  314. run();
  315. }