dhcp-log.pl 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  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 open ":encoding(utf8)";
  9. use Encode;
  10. no warnings 'utf8';
  11. use English;
  12. use base;
  13. use FindBin '$Bin';
  14. use lib "/opt/Eye/scripts";
  15. use Data::Dumper;
  16. use eyelib::config;
  17. use eyelib::main;
  18. use eyelib::database;
  19. use eyelib::common;
  20. use eyelib::net_utils;
  21. use strict;
  22. use warnings;
  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_verbose("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. # Parse fields by semicolon
  129. my (
  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. ) = split(/;/, $logline);
  135. # Skip lines without valid event type
  136. next unless $type && $type =~ /^(old|add|del)$/i;
  137. log_debug("Processing DHCP event: type='$type', MAC='$mac', IP='$ip'");
  138. # Suppress duplicate events within $mute_time window
  139. if (exists $leases{$ip} && $leases{$ip}{type} eq $type && (time() - $leases{$ip}{last_time} <= $mute_time)) {
  140. log_debug("Skipping duplicate: IP=$ip, type=$type (within $mute_time sec window)");
  141. next;
  142. }
  143. # Refresh config every 60 seconds
  144. if (time() - $last_refresh_config >= 60) {
  145. log_debug("Refreshing configuration...");
  146. init_option($hdb);
  147. }
  148. # Process DHCP request: update/create DB record
  149. my $dhcp_record = process_dhcp_request($hdb, $type, $mac, $ip, $hostname, $client_id, $decoded_circuit_id, $decoded_remote_id);
  150. next unless $dhcp_record;
  151. # Cache to suppress duplicates
  152. $leases{$ip} = {
  153. type => $type,
  154. last_time => time()
  155. };
  156. my $auth_id = $dhcp_record->{auth_id};
  157. # === SWITCH AND PORT IDENTIFICATION LOGIC ===
  158. my ($switch, $switch_port);
  159. my ($t_remote_id, $t_circuit_id) = ($remote_id, $circuit_id);
  160. # Only process connection events (add/old)
  161. if ($type =~ /^(add|old)$/i) {
  162. log_debug("Attempting to identify switch using Option 82 data...");
  163. # 1. Try decoded_remote_id as MAC address
  164. if ($decoded_remote_id) {
  165. $t_remote_id = $decoded_remote_id;
  166. $t_remote_id .= "0" x (12 - length($t_remote_id)) if length($t_remote_id) < 12;
  167. $t_remote_id = mac_splitted(isc_mac_simplify($t_remote_id));
  168. my $devSQL = "SELECT D.id, D.device_name, D.ip, A.mac " .
  169. "FROM devices AS D, user_auth AS A " .
  170. "WHERE D.user_id = A.User_id AND D.ip = A.ip AND A.deleted = 0 " .
  171. "AND A.mac = ?";
  172. $switch = get_record_sql($hdb, $devSQL, $t_remote_id);
  173. if ($switch) {
  174. $remote_id = $t_remote_id;
  175. $circuit_id = $decoded_circuit_id;
  176. $dhcp_record->{circuit_id} = $circuit_id;
  177. $dhcp_record->{remote_id} = $remote_id;
  178. log_debug("Switch found via decoded_remote_id: " . $switch->{device_name});
  179. }
  180. }
  181. # 2. If not found, try raw remote_id as MAC
  182. if (!$switch && $remote_id) {
  183. $t_remote_id = $remote_id;
  184. $t_remote_id .= "0" x (12 - length($t_remote_id)) if length($t_remote_id) < 12;
  185. $t_remote_id = mac_splitted(isc_mac_simplify($t_remote_id));
  186. my $devSQL = "SELECT D.id, D.device_name, D.ip, A.mac " .
  187. "FROM devices AS D, user_auth AS A " .
  188. "WHERE D.user_id = A.User_id AND D.ip = A.ip AND A.deleted = 0 " .
  189. "AND A.mac = ?";
  190. $switch = get_record_sql($hdb, $devSQL, $t_remote_id);
  191. if ($switch) {
  192. $remote_id = $t_remote_id;
  193. $dhcp_record->{circuit_id} = $circuit_id;
  194. $dhcp_record->{remote_id} = $remote_id;
  195. log_debug("Switch found via remote_id: " . $switch->{device_name});
  196. }
  197. }
  198. # 3. If still not found, try remote_id as device name prefix
  199. if (!$switch && $remote_id) {
  200. my @id_words = split(/ /, $remote_id);
  201. if ($id_words[0]) {
  202. my $devSQL = "SELECT D.id, D.device_name, D.ip, A.mac " .
  203. "FROM devices AS D, user_auth AS A " .
  204. "WHERE D.user_id = A.User_id AND D.ip = A.ip AND A.deleted = 0 " .
  205. "AND D.device_name LIKE ?";
  206. $switch = get_record_sql($hdb, $devSQL, $id_words[0] . '%');
  207. if ($switch) {
  208. log_debug("Switch found by name: " . $switch->{device_name});
  209. }
  210. }
  211. }
  212. # 4. Special case: MikroTik (circuit-id may contain device name)
  213. if (!$switch && $circuit_id) {
  214. my @id_words = split(/ /, $circuit_id);
  215. if ($id_words[0]) {
  216. my $devSQL = "SELECT D.id, D.device_name, D.ip, A.mac " .
  217. "FROM devices AS D, user_auth AS A " .
  218. "WHERE D.user_id = A.User_id AND D.ip = A.ip AND A.deleted = 0 " .
  219. "AND D.device_name LIKE ?";
  220. $switch = get_record_sql($hdb, $devSQL, $id_words[0] . '%');
  221. if ($switch) {
  222. # MikroTik often swaps circuit-id and remote-id
  223. ($circuit_id, $remote_id) = ($remote_id, $t_circuit_id);
  224. $dhcp_record->{circuit_id} = $circuit_id;
  225. $dhcp_record->{remote_id} = $remote_id;
  226. log_debug("Detected MikroTik — swapped circuit-id and remote-id");
  227. }
  228. }
  229. }
  230. # === LOG IF NO SWITCH MATCH FOUND ===
  231. unless ($switch) {
  232. log_warning("No matching switch found for DHCP event: IP=$ip, MAC=$mac, remote_id='$remote_id', circuit_id='$circuit_id'");
  233. }
  234. # === PORT IDENTIFICATION ===
  235. if ($switch) {
  236. # Normalize circuit_id for port matching
  237. $t_circuit_id =~ s/[\+\-\s]+/ /g;
  238. # Load switch ports
  239. my @device_ports = get_records_sql($hdb, "SELECT * FROM device_ports WHERE device_id = ?", $switch->{id});
  240. my %device_ports_h;
  241. foreach my $port_data (@device_ports) {
  242. $port_data->{snmp_index} //= $port_data->{port};
  243. $device_ports_h{$port_data->{port}} = $port_data;
  244. }
  245. # Try to match by interface name (ifName)
  246. $switch_port = undef;
  247. foreach my $port_data (@device_ports) {
  248. if ($t_circuit_id =~ /\s*$port_data->{ifname}$/i ||
  249. $t_circuit_id =~ /^$port_data->{ifname}\s+/i) {
  250. $switch_port = $port_data;
  251. last;
  252. }
  253. }
  254. # If not found by name, try hex port (last 2 bytes of decoded_circuit_id)
  255. if (!$switch_port && $decoded_circuit_id) {
  256. my $hex_port = substr($decoded_circuit_id, -2);
  257. if ($hex_port && $hex_port =~ /^[0-9a-fA-F]{2}$/) {
  258. my $t_port = hex($hex_port);
  259. $switch_port = $device_ports_h{$t_port} if exists $device_ports_h{$t_port};
  260. log_debug("Port identified via hex: $t_port") if $switch_port;
  261. }
  262. }
  263. # Log and update connection
  264. if ($switch_port) {
  265. db_log_verbose($hdb, "DHCP $type: IP=$ip, MAC=$mac " . $switch->{device_name} . " / " . $switch_port->{ifname});
  266. # Check if connection already exists
  267. my $connection = get_records_sql($hdb, "SELECT * FROM connections WHERE auth_id = ?", $auth_id);
  268. if (!$connection || !@{$connection}) {
  269. my $new_connection = {
  270. port_id => $switch_port->{id},
  271. device_id => $switch->{id},
  272. auth_id => $auth_id
  273. };
  274. insert_record($hdb, 'connections', $new_connection);
  275. log_debug("New connection created: auth_id=$auth_id");
  276. }
  277. } else {
  278. db_log_verbose($hdb, "DHCP $type: IP=$ip, MAC=$mac " . $switch->{device_name} . " (port not identified)");
  279. log_warning("Failed to identify port for IP=$ip on switch=" . $switch->{device_name});
  280. }
  281. }
  282. log_debug("Switch identified: " . ($switch ? $switch->{device_name} : "NONE"));
  283. log_debug("Port identified: " . ($switch_port ? $switch_port->{ifname} : "NONE"));
  284. }
  285. } # end while log reading
  286. }; # end eval
  287. # Exception handling
  288. if ($@) {
  289. log_error("Critical error in main loop: $@");
  290. sleep(60); # pause before retry
  291. }
  292. } # end while(1)
  293. }
  294. # === HELPER FUNCTIONS ===
  295. sub usage {
  296. print "usage: $MY_NAME (start|stop|status|restart)\n";
  297. exit(0);
  298. }
  299. sub reload {
  300. print "reload process not implemented.\n";
  301. log_warning("Command 'reload' is not supported.");
  302. }
  303. sub restart {
  304. log_info("Restart requested...");
  305. stop();
  306. sleep(2);
  307. run();
  308. }