Explorar el Código

Removed +ssh-dss from ssh connect options
added --changes-only for sync_mikrotik, that applied only to the routers associated with the changed users
simplified the log

Roman Dmitriev hace 3 meses
padre
commit
78cb9c6fba

+ 30 - 12
html/inc/sql.php

@@ -107,7 +107,7 @@ function prepareAuditMessage(PDO $db, string $table, ?array $old_data, ?array $n
                 'dynamic', 'end_life', 'description', 'dns_name', 'dns_ptr_only', 'wikiname',
                 'dhcp_acl', 'queue_id', 'mac', 'dhcp_option_set', 'blocked', 'day_quota',
                 'month_quota', 'device_model_id', 'firmware', 'client_id', 'nagios',
-                'nagios_handler', 'link_check'
+                'nagios_handler', 'link_check', 'deleted'
             ]
         ],
         'user_auth_alias' => [
@@ -1105,14 +1105,20 @@ function update_record($db, $table, $filter, $newvalue, $filter_params = [])
                 if (!preg_match('/user/i', $table)) {
                     LOG_INFO($db, $changed_msg);
                     } else {
-                    LOG_WARNING($db, $changed_msg);
                     if ($table == 'user_auth' && !empty($old_record['ip'])) {
-                        $send_alert_update = isNotifyUpdate(get_notify_subnet($db, $old_record['ip']));
-                        if ($send_alert_update) { email(L_WARNING,$changed_msg); }
+                        if (is_hotspot($db, $old_record['ip'])) {
+                            LOG_INFO($db, $changed_msg, $rec_id);
+                            } else {
+                            LOG_WARNING($db, $changed_msg, $rec_id);
+                            $send_alert_update = isNotifyUpdate(get_notify_subnet($db, $old_record['ip']));
+                            if ($send_alert_update) { email(L_WARNING,$changed_msg); }
+                            }
+                        } else {
+                        LOG_WARNING($db, $changed_msg);
                         }
+                    }
                 }
             }
-        }
 
         return $sql_result;
     } catch (PDOException $e) {
@@ -1204,10 +1210,16 @@ function delete_record($db, $table, $filter, $filter_params = [])
                 }
             insert_record($db, 'dns_queue', $del_dns);
             }
-        LOG_WARNING($db, $changed_msg);
         if (!empty($old_record['ip'])) {
-            $send_alert_delete = isNotifyDelete(get_notify_subnet($db, $old_record['ip']));
-            if ($send_alert_delete) { email(L_WARNING,$changed_msg); }
+            if (is_hotspot($db, $old_record['ip'])) { 
+                LOG_INFO($db, $changed_msg, $rec_id); 
+                } else {
+                LOG_WARNING($db, $changed_msg, $rec_id);
+                $send_alert_delete = isNotifyDelete(get_notify_subnet($db, $old_record['ip']));
+                if ($send_alert_delete) { email(L_WARNING,$changed_msg); }
+                }
+            } else {
+            LOG_WARNING($db, $changed_msg, $rec_id);
             }
         return $old_record;
         }
@@ -1316,14 +1328,20 @@ function insert_record($db, $table, $newvalue)
                 if (!preg_match('/user/i', $table)) {
                     LOG_INFO($db, $changed_msg);
                     } else {
-                    LOG_WARNING($db, $changed_msg);
                     if ($table == 'user_auth' && !empty($newvalue['ip'])) {
-                        $send_alert_create = isNotifyCreate(get_notify_subnet($db, $newvalue['ip']));
-                        if ($send_alert_create) { email(L_WARNING,$changed_msg); }
+                        if (is_hotspot($db, $newvalue['ip'])) {
+                            LOG_INFO($db, $changed_msg, $last_id);
+                            } else {
+                            LOG_WARNING($db, $changed_msg, $last_id);
+                            $send_alert_create = isNotifyCreate(get_notify_subnet($db, $newvalue['ip']));
+                            if ($send_alert_create) { email(L_WARNING,$changed_msg); }
+                            }
+                        } else {
+                        LOG_WARNING($db, $changed_msg);
                         }
+                    }
                 }
             }
-        }
 
         if ($table === 'user_auth_alias') {
             //dns

+ 14 - 3
scripts/eyelib/cmd.pm

@@ -311,9 +311,9 @@ if ($device->{proto} eq 'ssh') {
 	    strict_mode=>0,
 	    master_opts => [ 
 	    -o => "StrictHostKeyChecking=no", 
-	    -o => "PubkeyAcceptedKeyTypes=+ssh-dss", 
+#	    -o => "PubkeyAcceptedKeyTypes=+ssh-dss", 
 	    -o => "KexAlgorithms=+diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1",
-	    -o => "HostKeyAlgorithms=+ssh-dss",
+#	    -o => "HostKeyAlgorithms=+ssh-dss",
 	    -o => "LogLevel=quiet",
 	    -o => "UserKnownHostsFile=/dev/null"
 	    ]
@@ -325,8 +325,19 @@ if ($device->{proto} eq 'ssh') {
 if ($device->{proto} eq 'essh') {
 	if (!$device->{port}) { $device->{port} = '22'; }
 	log_info($dev_ident."Try login to $device->{ip}:$device->{port} by ssh::expect...");
+        my @ssh_opts = (
+            'ssh',
+            '-o', 'StrictHostKeyChecking=no',
+#            '-o', 'PubkeyAcceptedKeyTypes=+ssh-dss',
+            '-o', 'KexAlgorithms=+diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1',
+#            '-o', 'HostKeyAlgorithms=+ssh-dss',
+            '-o', 'LogLevel=quiet',
+            '-o', 'UserKnownHostsFile=/dev/null',
+            "$device->{login}\@$device->{ip}",
+        );
+
+        $t = Expect->spawn(@ssh_opts);
 
-	$t = Expect->spawn("ssh -o StrictHostKeyChecking=no -o PubkeyAcceptedKeyTypes=+ssh-dss -o KexAlgorithms=+diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1 -o HostKeyAlgorithms=+ssh-dss -o LogLevel=quiet -o UserKnownHostsFile=/dev/null $device->{login}\@$device->{ip}");
 	$t->log_stdout(0);  # Disable logging to stdout
 
 	$t->expect(30,

+ 18 - 77
scripts/eyelib/common.pm

@@ -19,6 +19,7 @@ use eyelib::net_utils;
 use Data::Dumper;
 use eyelib::database;
 use DateTime;
+use DateTime::Format::Strptime;
 use POSIX qw(mktime ctime strftime);
 use File::Temp qw(tempfile);
 use DBI;
@@ -89,7 +90,7 @@ return $msg;
 
 sub unbind_ports {
     my ($db, $device_id) = @_;
-    return unless $db && defined $device_id && $device_id =~ /^\d+$/;  # защита от нечисловых ID
+    return unless $db && defined $device_id && $device_id =~ /^\d+$/;  
     # Получаем все порты устройства
     my @target = get_records_sql($db, 
         "SELECT target_port_id, id FROM device_ports WHERE device_id = ?", 
@@ -109,7 +110,6 @@ sub get_dns_name {
     my ($db, $id) = @_;
     return unless $db && defined $id;
 
-    # Защита: убедимся, что $id — положительное целое число
     return unless $id =~ /^\d+$/ && $id > 0;
 
     my $auth_record = get_record_sql(
@@ -146,39 +146,16 @@ sub delete_user_auth {
     my ($db, $id) = @_;
     return 0 unless $db && defined $id;
 
-    # Валидация ID
     return 0 unless $id =~ /^\d+$/ && $id > 0;
 
-    # Получаем основную запись
     my $record = get_record_sql($db, "SELECT * FROM user_auth WHERE id = ?", $id);
-    return 0 unless $record;  # если записи нет — выходим
-
-    # Формируем идентификатор для лога
-    my $auth_ident = $record->{ip} // '';
-    if ($record->{dns_name}) {
-        $auth_ident .= ' [' . $record->{dns_name} . ']';
-    }
-    if ($record->{description}) {
-        $auth_ident .= ' :: ' . $record->{description};
-    }
+    return 0 unless $record;
 
-    my $txt_record = hash_to_text($record) // '';
-    my $msg = "";
-
-    # --- Удаляем алиасы ---
     my @aliases = get_records_sql($db, "SELECT * FROM user_auth_alias WHERE auth_id = ?", $id);
     foreach my $alias (@aliases) {
         my $alias_id = $alias->{id};
         next unless defined $alias_id && $alias_id =~ /^\d+$/;
-
-        # Правильный вызов: таблица + ID (число)
-        my $alias_txt = record_to_txt($db, 'user_auth_alias', $alias_id) // '';
-
-        if (delete_record($db, 'user_auth_alias', 'id = ?', $alias_id)) {
-            $msg = "Deleting an alias: $alias_txt\n::Success!\n" . $msg;
-        } else {
-            $msg = "Deleting an alias: $alias_txt\n::Fail!\n" . $msg;
-        }
+        delete_record($db, 'user_auth_alias', 'id = ?', $alias_id);
     }
 
     # --- Удаляем соединения ---
@@ -187,19 +164,6 @@ sub delete_user_auth {
     # --- Удаляем основную запись ---
     my $changes = delete_record($db, "user_auth", "id = ?", $id);
 
-    if ($changes) {
-        $msg = "Deleting ip-record: $txt_record\n::Success!\n" . $msg;
-    } else {
-        $msg = "Deleting ip-record: $txt_record\n::Fail!\n" . $msg;
-    }
-
-    $msg = "Deleting user ip record $auth_ident\n\n" . $msg;
-    db_log_warning($db, $msg, $id);
-
-    # Отправка уведомления
-    my $send_alert = isNotifyDelete(get_notify_subnet($db, $record->{ip}));
-    sendEmail("WARN! " . get_first_line($msg), $msg, 1) if $send_alert;
-
     return $changes;
 }
 
@@ -209,14 +173,11 @@ sub unblock_user {
     my ($db, $user_id) = @_;
     return 0 unless $db && defined $user_id;
 
-    # Валидация ID
     return 0 unless $user_id =~ /^\d+$/ && $user_id > 0;
 
-    # Получаем пользователя
     my $user_record = get_record_sql($db, "SELECT * FROM user_list WHERE id = ?", $user_id);
-    return 0 unless $user_record;  # если нет — выходим
+    return 0 unless $user_record;
 
-    # Формируем идентификатор
     my $user_ident = 'id:' . ($user_record->{id} // '') . ' ' . ($user_record->{login} // '');
     if ($user_record->{description}) {
         $user_ident .= '[' . $user_record->{description} . ']';
@@ -226,7 +187,6 @@ sub unblock_user {
     my $send_alert = 0;
     my $any_updated = 0;
 
-    # Разблоковываем все активные IP-записи пользователя
     my @user_auth = get_records_sql($db, "SELECT * FROM user_auth WHERE deleted = 0 AND user_id = ?", $user_id);
     foreach my $record (@user_auth) {
         next unless $record->{id} && $record->{id} =~ /^\d+$/;
@@ -252,12 +212,10 @@ sub unblock_user {
         }
     }
 
-    # Разблоковываем самого пользователя в user_list
     my $user_update = { blocked => 0 };
     my $ret_id = update_record($db, 'user_list', $user_update, 'id = ?', $user_id);
 
     if ($ret_id) {
-        # Логируем даже если нет IP-записей
         db_log_info($db, $msg);
         sendEmail("WARN! " . get_first_line($msg), $msg, 1) if $send_alert;
     }
@@ -271,27 +229,22 @@ sub delete_user {
     my ($db, $id) = @_;
     return 0 unless $db && defined $id;
 
-    # Валидация ID: должно быть положительное целое число
     return 0 unless $id =~ /^\d+$/ && $id > 0;
 
-    # Удаляем основную запись пользователя
     my $changes = delete_record($db, "user_list", "permanent = 0 AND id = ?", $id);
-    return 0 unless $changes;  # если не удалось — выходим
+    return 0 unless $changes;
 
-    # Удаляем все IP-записи (user_auth)
     my @user_auth_records = get_records_sql($db, "SELECT id FROM user_auth WHERE user_id = ?", $id);
     foreach my $row (@user_auth_records) {
         next unless defined $row->{id} && $row->{id} =~ /^\d+$/;
         delete_user_auth($db, $row->{id});
     }
 
-    # Удаляем устройство, привязанное к пользователю
     my $device = get_record_sql($db, "SELECT id FROM devices WHERE user_id = ?", $id);
     if ($device && defined $device->{id} && $device->{id} =~ /^\d+$/) {
         delete_device($db, $device->{id});
     }
 
-    # Удаляем правила авторизации
     do_sql($db, "DELETE FROM auth_rules WHERE user_id = ?", $id);
 
     return $changes;
@@ -303,17 +256,14 @@ sub delete_device {
     my ($db, $id) = @_;
     return 0 unless $db && defined $id;
 
-    # Валидация: ID должен быть положительным целым числом
     return 0 unless $id =~ /^\d+$/ && $id > 0;
 
-    # Удаляем запись устройства
     my $changes = delete_record($db, "devices", "id = ?", $id);
-    return 0 unless $changes;  # если не удалось — выходим
+    return 0 unless $changes;
 
     # Отвязываем порты
     unbind_ports($db, $id);
 
-    # Удаляем связанные данные
     do_sql($db, "DELETE FROM connections WHERE device_id = ?", $id);
     do_sql($db, "DELETE FROM device_l3_interfaces WHERE device_id = ?", $id);
     do_sql($db, "DELETE FROM device_ports WHERE device_id = ?", $id);
@@ -516,7 +466,6 @@ sub get_new_user_id {
         }
     }
 
-    # --- Значение по умолчанию ---
     $result->{ou_id} //= $default_user_ou_id;
 
     return $result;
@@ -526,8 +475,6 @@ sub get_new_user_id {
 sub set_changed {
     my ($db, $id) = @_;
     return unless $db && defined $id;
-
-    # Опционально: валидация ID как числа
     return unless $id =~ /^\d+$/;
 
     my $update_record = { changed => 1 };
@@ -544,7 +491,6 @@ return unless defined $auth_id;
 # Валидация: auth_id должен быть положительным целым числом
 return unless $auth_id =~ /^\d+$/ && $auth_id > 0;
 
-# Переподключение
 if (!$hdb || !$hdb->ping) { $hdb = init_db(); }
 return unless $hdb;
 
@@ -912,7 +858,6 @@ if ($fqdn ne '' and !$dynamic_ok) {
 
 #------------------------------------------------------------------------------------------------------------
 
-use DateTime::Format::Strptime;
 
 sub apply_device_lock {
     my $db = shift;
@@ -1432,15 +1377,18 @@ sub resurrection_auth {
         return $record->{id};
     }
 
+    my $send_alert_create = isNotifyCreate(get_notify_subnet($db, $ip));
+
     # --- Проверка статической подсети ---
     my $user_subnet = $office_networks->match_string($ip);
     if ($user_subnet && $user_subnet->{static}) {
-        db_log_warning($db, "Unknown ip+mac found in static subnet! Abort create record for ip: $ip mac: [$mac]");
+        my $msg = "Found new unknown ip+mac in static subnet! Abort create record for this: $ip [$mac]";
+        db_log_warning($db, $msg);
+        sendEmail("WARN! " . get_first_line($msg), $msg, 1) if $send_alert_create;
         return 0;
     }
 
     my $send_alert_update = isNotifyUpdate(get_notify_subnet($db, $ip));
-    my $send_alert_create = isNotifyCreate(get_notify_subnet($db, $ip));
 
     # --- Ищем другие записи с этим MAC в той же подсети ---
     my $mac_exists = find_mac_in_subnet($db, $ip, $mac);
@@ -1451,13 +1399,9 @@ sub resurrection_auth {
         $ip_aton
     );
 
-    my $msg = '';
-
     if ($ip_record_same && $ip_record_same->{id}) {
         if (!$ip_record_same->{mac}) {
             # Обновляем запись без MAC
-            $msg = "$auth_ident\nUse auth record with no mac: " . hash_to_text($ip_record_same);
-            db_log_verbose($db, $msg);
             $new_record->{mac} = $mac;
             $new_record->{dhcp} = 0 if $mac_exists && $mac_exists->{count};
             if ($action =~ /^(add|old|del)$/i) {
@@ -1469,12 +1413,11 @@ sub resurrection_auth {
                 $new_record->{created_by}  = $action // get_creation_method();
                 }
             update_record($db, 'user_auth', $new_record, 'id = ?', $ip_record_same->{id});
-            sendEmail("WARN! " . get_first_line($msg), $msg, 1) if $send_alert_update;
             return $ip_record_same->{id};
         } elsif ($ip_record_same->{mac}) {
             # MAC изменился — удаляем старую запись
             if (!$ip_record->{hotspot}) {
-                $msg = "For ip: $ip mac change detected! Old mac: [$ip_record_same->{mac}] New mac: [$mac]. Disable old auth_id: $ip_record_same->{id}";
+                my $msg = "For ip: $ip mac change detected! Old mac: [$ip_record_same->{mac}] New mac: [$mac]. Disable old auth_id: $ip_record_same->{id}";
                 db_log_warning($db, $msg, $ip_record_same->{id});
                 sendEmail("WARN! " . get_first_line($msg), $msg, 1) if $send_alert_update;
             }
@@ -1522,13 +1465,14 @@ sub resurrection_auth {
     );
 
     my $cur_auth_id;
+    my $msg = '';
     if ($auth_exists && $auth_exists->{id}) {
         # Воскрешаем старую запись
         $cur_auth_id = $auth_exists->{id};
         $msg = "$auth_ident Resurrection auth_id: $cur_auth_id with ip: $ip and mac: $mac";
         if (!$ip_record->{hotspot}) { db_log_warning($db, $msg); } else { db_log_info($db, $msg); }
         update_record($db, 'user_auth', $new_record, 'id = ?', $cur_auth_id);
-    } else {
+        } else {
         # Создаём новую
         $cur_auth_id = insert_record($db, 'user_auth', $new_record);
         if ($cur_auth_id) {
@@ -1559,7 +1503,9 @@ sub resurrection_auth {
         $new_record->{enabled}         = $user_record->{enabled} // 0;
         update_record($db, 'user_auth', $new_record, 'id = ?', $cur_auth_id);
     }
-
+    my $final_record = get_record_sql($db, "SELECT * FROM user_auth WHERE id = ?", $cur_auth_id);
+    my $changed_msg = prepare_audit_message($db, 'user_auth', undef, $final_record , $cur_auth_id, 'insert');
+    $msg .= "\n". $changed_msg;
     db_log_warning($db, $msg, $cur_auth_id);
     sendEmail("WARN! " . get_first_line($msg), $msg . "\n" . record_to_txt($db, 'user_auth', $cur_auth_id), 1)  if $send_alert_create;
     return $cur_auth_id;
@@ -1619,11 +1565,6 @@ sub new_auth {
     $new_record->{description} = $user_record->{description} if $user_record->{description};
 
     my $cur_auth_id = insert_record($db, 'user_auth', $new_record);
-    if ($cur_auth_id) {
-        my $msg = "New ip created by ".get_creation_method()."! ip: $ip";
-        db_log_warning($db, $msg, $cur_auth_id);
-        sendEmail("WARN! " . get_first_line($msg), $msg, 1) if $send_alert;
-    }
 
     return $cur_auth_id;
 }

+ 1 - 2
scripts/eyelib/config.pm

@@ -24,7 +24,6 @@ use constant {
     NOTIFY_DELETE => 1 << 2, # 0100 - удаление
 };
 
-# Предопределенная комбинация
 use constant NOTIFY_ALL => NOTIFY_CREATE | NOTIFY_UPDATE | NOTIFY_DELETE; # 0111
 
 @ISA = qw(Exporter);
@@ -137,8 +136,8 @@ our %config_ref;
 
 ### current script pathname
 our @FN=split("/",$0);
-### script pid file name
 
+### script pid file name
 $config_ref{my_name}=$FN[-1];
 
 $config_ref{pid_dir} ='/run';

+ 385 - 29
scripts/eyelib/database.pm

@@ -45,6 +45,7 @@ our @ISA = qw(Exporter);
 our @EXPORT = qw(
 StrToIp
 IpToStr
+prepare_audit_message
 batch_db_sql_cached
 batch_db_sql_csv
 reconnect_db
@@ -134,6 +135,312 @@ our %db_schema;
 
 #---------------------------------------------------------------------------------------------------------------
 
+sub prepare_audit_message {
+    my ($dbh, $table, $old_data, $new_data, $record_id, $operation) = @_;
+
+    # === 1. Конфигурация отслеживаемых таблиц ===
+    my %audit_config = (
+        'auth_rules' => {
+            summary => ['rule'],
+            fields  => ['user_id', 'ou_id', 'rule_type', 'rule', 'description']
+        },
+        'building' => {
+            summary => ['name'],
+            fields  => ['name', 'description']
+        },
+        'customers' => {
+            summary => ['login'],
+            fields  => ['login', 'description', 'rights']
+        },
+        'devices' => {
+            summary => ['device_name'],
+            fields  => [
+                'device_type', 'device_model_id', 'vendor_id', 'device_name', 'building_id',
+                'ip', 'login', 'protocol', 'control_port', 'port_count', 'sn',
+                'description', 'snmp_version', 'snmp3_auth_proto', 'snmp3_priv_proto',
+                'snmp3_user_rw', 'snmp3_user_ro', 'community', 'rw_community',
+                'discovery', 'netflow_save', 'user_acl', 'dhcp', 'nagios',
+                'active', 'queue_enabled', 'connected_user_only', 'user_id'
+            ]
+        },
+        'device_filter_instances' => {
+            summary => [],
+            fields  => ['instance_id', 'device_id']
+        },
+        'device_l3_interfaces' => {
+            summary => ['name'],
+            fields  => ['device_id', 'snmpin', 'interface_type', 'name']
+        },
+        'device_models' => {
+            summary => ['model_name'],
+            fields  => ['model_name', 'vendor_id', 'poe_in', 'poe_out', 'nagios_template']
+        },
+        'device_ports' => {
+            summary => ['port', 'ifname'],
+            fields  => [
+                'device_id', 'snmp_index', 'port', 'ifname', 'port_name', 'description',
+                'target_port_id', 'auth_id', 'last_mac_count', 'uplink', 'nagios',
+                'skip', 'vlan', 'tagged_vlan', 'untagged_vlan', 'forbidden_vlan'
+            ]
+        },
+        'filter_instances' => {
+            summary => ['name'],
+            fields  => ['name', 'description']
+        },
+        'filter_list' => {
+            summary => ['name'],
+            fields  => ['name', 'description', 'proto', 'dst', 'dstport', 'srcport', 'filter_type']
+        },
+        'gateway_subnets' => {
+            summary => [],
+            fields  => ['device_id', 'subnet_id']
+        },
+        'group_filters' => {
+            summary => [],
+            fields  => ['group_id', 'filter_id', 'rule_order', 'action']
+        },
+        'group_list' => {
+            summary => ['group_name'],
+            fields  => ['instance_id', 'group_name', 'description']
+        },
+        'ou' => {
+            summary => ['ou_name'],
+            fields  => [
+                'ou_name', 'description', 'default_users', 'default_hotspot',
+                'nagios_dir', 'nagios_host_use', 'nagios_ping', 'nagios_default_service',
+                'enabled', 'filter_group_id', 'queue_id', 'dynamic', 'life_duration', 'parent_id'
+            ]
+        },
+        'queue_list' => {
+            summary => ['queue_name'],
+            fields  => ['queue_name', 'download', 'upload']
+        },
+        'subnets' => {
+            summary => ['subnet'],
+            fields  => [
+                'subnet', 'vlan_tag', 'ip_int_start', 'ip_int_stop', 'dhcp_start', 'dhcp_stop',
+                'dhcp_lease_time', 'gateway', 'office', 'hotspot', 'vpn', 'free', 'dhcp',
+                'static', 'dhcp_update_hostname', 'discovery', 'notify', 'description'
+            ]
+        },
+        'user_auth' => {
+            summary => ['ip', 'dns_name'],
+            fields  => [
+                'user_id', 'ou_id', 'ip', 'save_traf', 'enabled', 'dhcp', 'filter_group_id',
+                'dynamic', 'end_life', 'description', 'dns_name', 'dns_ptr_only', 'wikiname',
+                'dhcp_acl', 'queue_id', 'mac', 'dhcp_option_set', 'blocked', 'day_quota',
+                'month_quota', 'device_model_id', 'firmware', 'client_id', 'nagios',
+                'nagios_handler', 'link_check', 'deleted'
+            ]
+        },
+        'user_auth_alias' => {
+            summary => ['alias'],
+            fields  => ['auth_id', 'alias', 'description']
+        },
+        'user_list' => {
+            summary => ['login'],
+            fields  => [
+                'login', 'description', 'enabled', 'blocked', 'deleted', 'ou_id',
+                'device_id', 'filter_group_id', 'queue_id', 'day_quota', 'month_quota', 'permanent'
+            ]
+        },
+        'vendors' => {
+            summary => ['name'],
+            fields  => ['name']
+        }
+    );
+
+    return undef unless exists $audit_config{$table};
+
+    my $summary_fields   = $audit_config{$table}{summary};
+    my $monitored_fields = $audit_config{$table}{fields};
+
+    # === 2. Нормализация данных и определение изменений ===
+    my %changes;
+
+    if ($operation eq 'insert') {
+        for my $field (@$monitored_fields) {
+            if (exists $new_data->{$field}) {
+                $changes{$field} = { old => undef, new => $new_data->{$field} };
+            }
+        }
+    }
+    elsif ($operation eq 'delete') {
+        for my $field (@$monitored_fields) {
+            if (exists $old_data->{$field}) {
+                $changes{$field} = { old => $old_data->{$field}, new => undef };
+            }
+        }
+    }
+    elsif ($operation eq 'update') {
+        $old_data //= {};
+        $new_data //= {};
+        for my $field (@$monitored_fields) {
+            next unless exists $new_data->{$field};  # частичное обновление
+            my $old_val = exists $old_data->{$field} ? $old_data->{$field} : undef;
+            my $new_val = $new_data->{$field};
+
+            my $old_str = !defined($old_val) ? '' : "$old_val";
+            my $new_str = !defined($new_val) ? '' : "$new_val";
+
+            if ($old_str ne $new_str) {
+                $changes{$field} = { old => $old_val, new => $new_val };
+            }
+        }
+    }
+
+    return undef unless %changes;
+
+    # === 3. Краткое описание записи ===
+    my @summary_parts;
+    for my $field (@$summary_fields) {
+        my $val = defined($new_data->{$field}) ? $new_data->{$field}
+                : (defined($old_data->{$field}) ? $old_data->{$field} : undef);
+        push @summary_parts, "$val" if defined $val && $val ne '';
+    }
+
+    my $summary_label = @summary_parts
+        ? '"' . join(' | ', @summary_parts) . '"'
+        : "ID=$record_id";
+
+    # === 4. Расшифровка *_id полей ===
+    my %resolved_changes;
+    for my $field (keys %changes) {
+        my $old_resolved = resolve_reference_value($dbh, $field, $changes{$field}{old});
+        my $new_resolved = resolve_reference_value($dbh, $field, $changes{$field}{new});
+        $resolved_changes{$field} = { old => $old_resolved, new => $new_resolved };
+    }
+
+    # === 5. Формирование сообщения ===
+    my $op_label = 'Updated';
+    if ($operation eq 'insert') {
+        $op_label = 'Created';
+    } elsif ($operation eq 'delete') {
+        $op_label = 'Deleted';
+    } else {
+        $op_label = ucfirst($operation);
+    }
+
+    my $message = sprintf("[%s] %s (%s) in table `%s`:\n",
+        $op_label,
+        ucfirst($table),
+        $summary_label,
+        $table
+    );
+
+    for my $field (sort keys %resolved_changes) {
+        my $change = $resolved_changes{$field};
+        if ($operation eq 'insert') {
+            if (defined $change->{new}) {
+                $message .= sprintf("  %s: %s\n", $field, $change->{new});
+            }
+        } elsif ($operation eq 'delete') {
+            if (defined $change->{old}) {
+                $message .= sprintf("  %s: %s\n", $field, $change->{old});
+            }
+        } else { # update
+            my $old_display = !defined($change->{old}) ? '[NULL]' : $change->{old};
+            my $new_display = !defined($change->{new}) ? '[NULL]' : $change->{new};
+            $message .= sprintf("  %s: \"%s\" → \"%s\"\n", $field, $old_display, $new_display);
+        }
+    }
+
+    chomp $message;
+    return $message;
+}
+
+#---------------------------------------------------------------------------------------------------------------
+
+sub resolve_reference_value {
+    my ($dbh, $field, $value) = @_;
+
+    return undef if !defined $value || $value eq '';
+
+    # Проверка на целое число (как в PHP)
+    if ($value !~ /^[+-]?\d+$/) {
+        return "$value";
+    }
+    my $as_int = int($value);
+    if ("$as_int" ne "$value") {
+        return "$value";
+    }
+
+    my $id = $as_int;
+
+    if ($field eq 'device_id') {
+        return get_device_name($dbh, $id) // "Device#$id";
+    }
+    elsif ($field eq 'building_id') {
+        return get_building($dbh, $id) // "Building#$id";
+    }
+    elsif ($field eq 'user_id') {
+        return get_login($dbh, $id) // "User#$id";
+    }
+    elsif ($field eq 'ou_id') {
+        return get_ou($dbh, $id) // "OU#$id";
+    }
+    elsif ($field eq 'vendor_id') {
+        return get_vendor_name($dbh, $id) // "Vendor#$id";
+    }
+    elsif ($field eq 'device_model_id') {
+        return get_device_model_name($dbh, $id) // "Model#$id";
+    }
+    elsif ($field eq 'instance_id') {
+        return get_filter_instance_description($dbh, $id) // "FilterInstance#$id";
+    }
+    elsif ($field eq 'subnet_id') {
+        return get_subnet_description($dbh, $id) // "Subnet#$id";
+    }
+    elsif ($field eq 'group_id') {
+        return get_group($dbh, $id) // "FilterGroup#$id";
+    }
+    elsif ($field eq 'filter_id') {
+        return get_filter($dbh, $id) // "Filter#$id";
+    }
+    elsif ($field eq 'filter_group_id') {
+        return get_group($dbh, $id) // "FilterGroup#$id";
+    }
+    elsif ($field eq 'queue_id') {
+        return get_queue($dbh, $id) // "Queue#$id";
+    }
+    elsif ($field eq 'auth_id') {
+        return 'None' if $id <= 0;
+        my $sql = "
+            SELECT
+                COALESCE(ul.login, CONCAT('User#', ua.user_id)) AS login,
+                ua.ip,
+                ua.dns_name
+            FROM user_auth ua
+            LEFT JOIN user_list ul ON ul.id = ua.user_id
+            WHERE ua.id = ?
+        ";
+        my $row = get_record_sql($dbh, $sql, $id);
+        return "Auth#$id" unless $row;
+
+        my @parts;
+        push @parts, "login: $row->{login}" if $row->{login} && $row->{login} ne '';
+        push @parts, "IP: $row->{ip}"       if $row->{ip} && $row->{ip} ne '';
+        push @parts, "DNS: $row->{dns_name}" if $row->{dns_name} && $row->{dns_name} ne '';
+        return @parts ? join(', ', @parts) : "Auth#$id";
+    }
+    elsif ($field eq 'target_port_id') {
+        return 'None' if $id == 0;
+        my $sql = "
+            SELECT CONCAT(d.device_name, '[', dp.port, ']')
+            FROM device_ports dp
+            JOIN devices d ON d.id = dp.device_id
+            WHERE dp.id = ?
+        ";
+        my $name = $dbh->selectrow_array($sql, undef, $id);
+        return $name // "Port#$id";
+    }
+    else {
+        return "$value";
+    }
+}
+
+#---------------------------------------------------------------------------------------------------------------
+
 sub build_db_schema {
     my ($dbh) = @_;
 
@@ -958,7 +1265,6 @@ if ($table eq "user_auth") {
 my @insert_params;
 my $fields = '';
 my $values = '';
-my $new_str = '';
 
 foreach my $field (keys %$record) {
     my $val =  normalize_value($record->{$field}, $db_schema{$db_info->{db_type}}{$db_info->{db_name}}{$table}{$field});
@@ -969,10 +1275,6 @@ foreach my $field (keys %$record) {
     $fields .= "$quoted_field, ";
     $values .= "?, ";
     push @insert_params, $val;
-    # Для лога — безопасное представление
-    my $log_val = defined $val ? substr($val, 0, 200) : 'NULL';
-    $log_val =~ s/[^[:print:]]/_/g;
-    $new_str .= " $field => $log_val,";
 }
 
 $fields =~ s/,\s*$//;
@@ -980,9 +1282,30 @@ $values =~ s/,\s*$//;
 
 my $sSQL = "INSERT INTO $table($fields) VALUES($values)";
 my $result = do_sql($db,$sSQL,@insert_params);
+
 if ($result) {
-    $rec_id = $result if ($table eq "user_auth");
-    $new_str='id: '.$result.' '.$new_str;
+    $rec_id = $result;
+
+    my $changed_msg = prepare_audit_message($db, $table, undef, $record, $rec_id, 'insert');
+    if ($table !~ /session/i) {
+    if (defined $changed_msg && $changed_msg ne '') {
+        if ($table !~ /user/i) {
+            db_log_info($db, $changed_msg);
+            } else {
+            if ($table eq 'user_auth' && defined $record->{ip} && $record->{ip} ne '') {
+                if (is_hotspot($db, $record->{ip})) {
+                    db_log_info($db, $changed_msg, $rec_id);
+                    } else {
+                    db_log_warning($db, $changed_msg, $rec_id);
+                    my $send_alert_create = isNotifyCreate(get_notify_subnet($db, $record->{ip}));
+                    sendEmail("WARN! " . get_first_line($changed_msg), $changed_msg, 1) if $send_alert_create;
+                    }
+                } else {
+                db_log_warning($db, $changed_msg);
+                }
+            }
+        }
+
     if ($table eq 'user_auth_alias' and $dns_changed) {
         if ($record->{'alias'} and $record->{'alias'}!~/\.$/) {
             my $add_dns;
@@ -1015,7 +1338,7 @@ if ($result) {
             }
         }
     }
-db_log_debug($db,'Add record to table '.$table.' '.$new_str,$rec_id);
+}
 return $result;
 }
 
@@ -1062,12 +1385,10 @@ if ($table eq "user_auth") {
         }
     }
 
-my $diff = '';
 for my $field (keys %$record) {
         my $old_val = defined $old_record->{$field} ? $old_record->{$field} : '';
         my $new_val =  normalize_value($record->{$field}, $db_schema{$db_info->{db_type}}{$db_info->{db_name}}{$table}{$field});
         if ($new_val ne $old_val) {
-            $diff .= " $field => $new_val (old: $old_val),";
             $set_clause .= " $field = ?, ";
             push @update_params, $new_val;
         }
@@ -1082,7 +1403,6 @@ if ($table eq 'user_auth') {
     }
 
 $set_clause =~ s/,\s*$//;
-$diff =~ s/,\s*$//;
 
 if ($table eq 'user_auth') {
         if ($dns_changed) {
@@ -1152,11 +1472,32 @@ if ($table eq 'user_auth_alias') {
             }
         }
 
-# Формируем полный список параметров: сначала SET, потом WHERE
 my @all_params = (@update_params, @filter_params);
 my $update_sql = "UPDATE $table SET $set_clause WHERE $filter_sql";
-db_log_debug($db, "Change table $table for $filter_sql set: $diff", $rec_id);
-return do_sql($db, $update_sql, @all_params);
+my $result = do_sql($db, $update_sql, @all_params);
+
+if ($result) {
+    my $changed_msg = prepare_audit_message($db, $table, $old_record, $record , $rec_id, 'update');
+    if ($table !~ /session/i) {
+        if (defined $changed_msg && $changed_msg ne '') {
+            if ($table !~ /user/i) {
+                db_log_info($db, $changed_msg);
+                } else {
+                if (is_hotspot($db, $old_record->{ip})) {
+                    db_log_info($db, $changed_msg, $rec_id);
+                    } else {
+                    db_log_warning($db, $changed_msg, $rec_id);
+                    if ($table eq 'user_auth' && defined $old_record->{ip} && $old_record->{ip} ne '') {
+                        my $send_alert_update = isNotifyUpdate(get_notify_subnet($db, $old_record->{ip}));
+                        sendEmail("WARN! " . get_first_line($changed_msg), $changed_msg, 1) if $send_alert_update;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+return $result;
 }
 
 #---------------------------------------------------------------------------------------------------------------
@@ -1182,20 +1523,7 @@ my $select_sql = "SELECT * FROM $table WHERE $filter_sql";
 my $old_record = get_record_sql($db, $select_sql, @filter_params);
 return unless $old_record;
 
-my $rec_id = 0;
-
-my $diff='';
-foreach my $field (keys %$old_record) {
-    next if (!$old_record->{$field});
-    $diff = $diff." $field => $old_record->{$field},";
-    }
-$diff=~s/,\s*$//;
-
-if ($table eq 'user_auth') {
-    $rec_id = $old_record->{'id'} if ($old_record->{'id'});
-    }
-
-db_log_debug($db,'Delete record from table '.$table.' value: '.$diff, $rec_id);
+my $rec_id = $old_record->{'id'};
 
 #never delete user ip record!
 if ($table eq 'user_auth') {
@@ -1219,6 +1547,21 @@ if ($table eq 'user_auth') {
 	$del_dns->{'auth_id'}=$old_record->{'id'};
 	insert_record($db,'dns_queue',$del_dns);
 	}
+
+    my $changed_msg = prepare_audit_message($db, $table, $old_record, undef , $rec_id, 'delete');
+    if ($ret) {
+        if (defined $changed_msg && $changed_msg ne '') {
+            if (defined $old_record->{ip} && $old_record->{ip} ne '') {
+                if (is_hotspot($db, $old_record->{ip})) {
+                    db_log_info($db, $changed_msg, $rec_id);
+                    } else {
+                    db_log_warning($db, $changed_msg, $rec_id);
+                    my $send_alert_delete = isNotifyDelete(get_notify_subnet($db, $old_record->{ip}));
+                    sendEmail("WARN! " . get_first_line($changed_msg), $changed_msg, 1) if $send_alert_delete;
+                    }
+                }
+            }
+        }
     return $ret;
     }
 
@@ -1237,7 +1580,20 @@ if ($table eq 'user_auth_alias') {
     }
 
 my $sSQL = "DELETE FROM ".$table." WHERE ".$filter_sql;
-return do_sql($db,$sSQL,@filter_params);
+my $result = do_sql($db,$sSQL,@filter_params);
+
+my $changed_msg = prepare_audit_message($db, $table, $old_record, undef , $rec_id, 'delete');
+if ($result && $table !~ /session/i) {
+    if (defined $changed_msg && $changed_msg ne '') {
+        if ($table !~ /user/i) {
+            db_log_info($db, $changed_msg);
+            } else {
+            db_log_warning($db, $changed_msg);
+            }
+        }
+    }
+
+return $result;
 }
 
 #---------------------------------------------------------------------------------------------------------------

+ 1 - 1
scripts/stat-sync.pl

@@ -139,7 +139,7 @@ if (!$pid) {
 	            if ($changed->{"c_count"}>0) {
                             log_info("Found changed records: ".$changed->{'c_count'});
                             my $acl_exec=get_option($hdb,37);
-                            my %result=do_exec_ref($acl_exec);
+                            my %result=do_exec_ref($acl_exec.' --changes-only');
 	                    if ($result{status} ne 0) { log_error("Error sync status at gateways"); }
 		            }
 	            }

+ 85 - 11
scripts/sync_mikrotik.pl

@@ -30,6 +30,7 @@ use DBI;
 use Fcntl qw(:flock);
 use Parallel::ForkManager;
 use Net::DNS;
+use Getopt::Long;
 
 #$debug = 1;
 
@@ -45,14 +46,19 @@ my $fork_count = $cpu_count*10;
 #flag for operation status
 my $all_ok = 1;
 
-my @gateways =();
-#select undeleted mikrotik routers only
-if ($ARGV[0]) {
-    my $router = get_record_sql($dbh,'SELECT * FROM devices WHERE (device_type=2 OR device_type=0) and protocol>=0 and (user_acl=1 or dhcp=1) and deleted=0 and vendor_id=9 and id=?',$ARGV[0]);
-    if ($router) { push(@gateways,$router); }
-    } else {
-    @gateways = get_records_sql($dbh,'SELECT * FROM devices WHERE (device_type=2 OR device_type=0) and protocol>=0 and (user_acl=1 or dhcp=1) and deleted=0 and vendor_id=9');
-    }
+my $changes_only = 0;
+my $router_id    = undef;
+
+# Парсим аргументы
+GetOptions(
+    'changes-only|c' => \$changes_only,
+    'router-id|r=i'  => \$router_id,
+) or die "Ошибка в параметрах!\n";
+
+my @gateways = ();
+
+#save changed records
+my @changes_found = get_records_sql($dbh,"SELECT id, ip FROM user_auth WHERE changed=1");
 
 #все сети организации, работающие по dhcp
 my $dhcp_networks = new Net::Patricia;
@@ -76,10 +82,78 @@ $dhcp_conf{$subnet->{subnet}}->{first_ip_aton}=StrToIp($dhcp_info->{first_ip});
 $dhcp_conf{$subnet->{subnet}}->{last_ip_aton}=StrToIp($dhcp_info->{last_ip});
 }
 
-my $pm = Parallel::ForkManager->new($fork_count);
+#@office_network_list - все рабочие сети
+
+if ($changes_only) {
+    my @all_gateways = get_records_sql($dbh,'SELECT * FROM devices WHERE (device_type=2 OR device_type=0) AND protocol>=0 AND (user_acl=1 OR dhcp=1) AND deleted=0 AND vendor_id=9' );
+    my %network_to_routers;
+
+    for my $gate (@all_gateways) {
+        my $router_id = $gate->{id};
+        my $connected_only = $gate->{connected_user_only} // 0;
+        my @subnets_for_router=();
+        if ($connected_only) {
+            # Только привязанные подсети
+            my @gw_subnets = get_records_sql($dbh,"SELECT s.subnet FROM gateway_subnets gs JOIN subnets s ON gs.subnet_id = s.id WHERE gs.device_id = ? AND s.subnet IS NOT NULL", $router_id );
+            @subnets_for_router = map { $_->{subnet} } @gw_subnets;
+            } else {
+            # Все офисные сети
+            push(@subnets_for_router,@office_network_list);
+            }
+        # Добавляем роутер ко всем его подсетям
+        for my $subnet (@subnets_for_router) {
+            next unless $subnet && $subnet =~ m{^\d+\.\d+\.\d+\.\d+/\d+$};
+            $network_to_routers{$subnet} //= {};
+            $network_to_routers{$subnet}{$router_id} = 1;
+            }
+        }
 
-#save changed records
-my @changes_found = get_records_sql($dbh,"SELECT id FROM user_auth WHERE changed=1");
+    my $GwPat = Net::Patricia->new(AF_INET);
+    for my $subnet (keys %network_to_routers) {
+        # Храним ссылку на хеш роутеров
+        $GwPat->add_string($subnet, \%{$network_to_routers{$subnet}});
+        }
+
+    my %selected_router_ids;
+    for my $user (@changes_found) {
+        my $ip = $user->{ip};
+        next unless $ip && $ip =~ /^\d+\.\d+\.\d+\.\d+$/;
+        my $data_ref = $GwPat->match_string($ip);
+        if ($data_ref) {
+            for my $rid (keys %$data_ref) {
+                if (defined $router_id) {
+                        $selected_router_ids{$rid} = 1 if ($router_id == $rid);
+                        } else {
+                        $selected_router_ids{$rid} = 1;
+                        }
+                }
+            }
+        }
+
+    if (%selected_router_ids) {
+        my @ids = keys %selected_router_ids;
+        my $ph = join ',', ('?') x @ids;
+        @gateways = get_records_sql($dbh, "SELECT * FROM devices WHERE id IN ($ph) AND (device_type=2 OR device_type=0) AND protocol >= 0 AND (user_acl=1 OR dhcp=1) AND deleted = 0  AND vendor_id = 9", @ids );
+        } else {
+        @gateways = ();  # Нет затронутых роутеров
+        }
+
+    if (!scalar @gateways) { exit 0; }
+
+    }
+
+    else {
+    # Если задан router_id — выбираем один роутер
+    if (defined $router_id) {
+        my $router = get_record_sql($dbh, 'SELECT * FROM devices WHERE (device_type=2 OR device_type=0) AND protocol>=0 AND (user_acl=1 OR dhcp=1) AND deleted=0 AND vendor_id=9 AND id=?', $router_id );
+        if ($router) { push(@gateways, $router); }
+        } else {
+        # Иначе выбираем все подходящие роутеры
+        @gateways = get_records_sql($dbh,'SELECT * FROM devices WHERE (device_type=2 OR device_type=0) AND protocol>=0 AND (user_acl=1 OR dhcp=1) AND deleted=0 AND vendor_id=9' );
+        }
+    }
+
+my $pm = Parallel::ForkManager->new($fork_count);
 
 foreach my $gate (@gateways) {
 next if (!$gate);