dhcp-log.pl 16 KB

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