Просмотр исходного кода

added address-list for ip group filtering

Roman Dmitriev 1 месяц назад
Родитель
Сommit
fb85e9fc2a

+ 19 - 0
docs/databases/mysql/en/create_db.sql

@@ -213,6 +213,7 @@ CREATE TABLE `filter_list` (
   `description` varchar(250) DEFAULT NULL,
   `proto` varchar(10) DEFAULT NULL,
   `dst` text DEFAULT NULL,
+  `ipset_id` INT(11) DEFAULT NULL,
   `dstport` varchar(20) DEFAULT NULL,
   `srcport` varchar(20) DEFAULT NULL,
   `filter_type` int(10) UNSIGNED NOT NULL DEFAULT 0
@@ -483,6 +484,24 @@ CREATE TABLE `worklog` (
   `level` int(11) NOT NULL DEFAULT 1
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci KEY_BLOCK_SIZE=8 ROW_FORMAT=COMPRESSED;
 
+CREATE TABLE `ipset_list` (
+  `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `name` VARCHAR(64) NOT NULL UNIQUE COMMENT 'ipset name',
+  `description` VARCHAR(255) DEFAULT NULL,
+  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE `ipset_members` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `ipset_id` INT NOT NULL,
+  `ip` VARCHAR(39) NOT NULL COMMENT 'IPv4 or IPv6 address',
+  `description` VARCHAR(255) DEFAULT NULL,
+  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY `uniq_ipset_ip` (`ipset_id`, `ip`),
+  CONSTRAINT `fk_ipset_members_ipset` 
+    FOREIGN KEY (`ipset_id`) REFERENCES `ipset_list` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
 ALTER TABLE `acl`
   ADD PRIMARY KEY (`id`);

+ 19 - 0
docs/databases/mysql/ru/create_db.sql

@@ -213,6 +213,7 @@ CREATE TABLE `filter_list` (
   `description` varchar(250) DEFAULT NULL,
   `proto` varchar(10) DEFAULT NULL,
   `dst` text DEFAULT NULL,
+  `ipset_id` INT(11) DEFAULT NULL,
   `dstport` varchar(20) DEFAULT NULL,
   `srcport` varchar(20) DEFAULT NULL,
   `filter_type` int(10) UNSIGNED NOT NULL DEFAULT 0
@@ -483,6 +484,24 @@ CREATE TABLE `worklog` (
   `level` int(11) NOT NULL DEFAULT 1
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci KEY_BLOCK_SIZE=8 ROW_FORMAT=COMPRESSED;
 
+CREATE TABLE `ipset_list` (
+  `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `name` VARCHAR(64) NOT NULL UNIQUE COMMENT 'Имя ipset',
+  `description` VARCHAR(255) DEFAULT NULL,
+  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE `ipset_members` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `ipset_id` INT NOT NULL,
+  `ip` VARCHAR(39) NOT NULL COMMENT 'IPv4 или IPv6 адрес',
+  `description` VARCHAR(255) DEFAULT NULL,
+  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY `uniq_ipset_ip` (`ipset_id`, `ip`),
+  CONSTRAINT `fk_ipset_members_ipset`.
+    FOREIGN KEY (`ipset_id`) REFERENCES `ipset_list` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
 ALTER TABLE `acl`
   ADD PRIMARY KEY (`id`);

+ 22 - 1
docs/databases/postgres/en/create_db.sql

@@ -280,6 +280,7 @@ name VARCHAR(50),
 description VARCHAR(250),
 proto VARCHAR(10),
 dst TEXT,
+ipset_id INTEGER DEFAULT NULL,
 dstport VARCHAR(20),
 srcport VARCHAR(20),
 filter_type SMALLINT NOT NULL DEFAULT 0
@@ -627,7 +628,27 @@ level SMALLINT NOT NULL DEFAULT 1
 COMMENT ON TABLE worklog IS 'System activity and audit log';
 COMMENT ON COLUMN worklog.level IS 'Log level: 1=info, 2=warning, 3=error, 4=debug';
 
--- Indexes (same as in the original schema)
+CREATE TABLE ipset_list (
+    id SERIAL PRIMARY KEY,
+    name VARCHAR(64) NOT NULL UNIQUE,
+    description VARCHAR(255),
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE ipset_members (
+    id BIGSERIAL PRIMARY KEY,
+    ipset_id INTEGER NOT NULL REFERENCES ipset_list(id) ON DELETE CASCADE,
+    ip INET NOT NULL,
+    description VARCHAR(255),
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE (ipset_id, ip)
+);
+
+-- Indexes
+
+CREATE INDEX idx_ipset_members_ip ON ipset_members USING BTREE (ip inet_ops);
+
 CREATE INDEX idx_devices_ip ON devices(ip);
 CREATE INDEX idx_devices_device_type ON devices(device_type);
 CREATE INDEX idx_devices_active ON devices(active) WHERE active = 1;

+ 22 - 1
docs/databases/postgres/ru/create_db.sql

@@ -280,6 +280,7 @@ name VARCHAR(50),
 description VARCHAR(250),
 proto VARCHAR(10),
 dst TEXT,
+ipset_id INTEGER DEFAULT NULL,
 dstport VARCHAR(20),
 srcport VARCHAR(20),
 filter_type SMALLINT NOT NULL DEFAULT 0
@@ -628,7 +629,27 @@ level SMALLINT NOT NULL DEFAULT 1
 COMMENT ON TABLE worklog IS 'Журнал активности и аудита системы';
 COMMENT ON COLUMN worklog.level IS 'Уровень логирования: 1=инфо, 2=предупреждение, 3=ошибка, 4=отладка';
 
--- Индексы (такие же как в оригинальной структуре)
+CREATE TABLE ipset_list (
+    id SERIAL PRIMARY KEY,
+    name VARCHAR(64) NOT NULL UNIQUE,
+    description VARCHAR(255),
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE ipset_members (
+    id BIGSERIAL PRIMARY KEY,
+    ipset_id INTEGER NOT NULL REFERENCES ipset_list(id) ON DELETE CASCADE,
+    ip INET NOT NULL,
+    description VARCHAR(255),
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE (ipset_id, ip)
+);
+
+-- Indexes
+
+CREATE INDEX idx_ipset_members_ip ON ipset_members USING BTREE (ip inet_ops);
+
 CREATE INDEX idx_devices_ip ON devices(ip);
 CREATE INDEX idx_devices_device_type ON devices(device_type);
 CREATE INDEX idx_devices_active ON devices(active) WHERE active = 1;

+ 116 - 44
html/admin/filters/editfilter.php

@@ -3,12 +3,22 @@ require_once ($_SERVER['DOCUMENT_ROOT']."/inc/auth.php");
 require_once ($_SERVER['DOCUMENT_ROOT']."/inc/languages/" . HTML_LANG . ".php");
 require_once ($_SERVER['DOCUMENT_ROOT']."/inc/idfilter.php");
 
-$filter = get_record($db_link, 'filter_list','id=?', [ $id ]);
+$filter = get_record($db_link, 'filter_list', 'id=?', [ $id ]);
 
+// ==================== СОХРАНЕНИЕ ФИЛЬТРА ====================
 if (getPOST("editfilter") !== null) {
+    $use_ipset = (int)getPOST("f_use_ipset", null, 0);
+    if ($use_ipset) {
+        $ipset_id = (int)getPOST("f_ipset_id", null, 0);
+        $dst = '';
+    } else {
+        $ipset_id = 0;
+        $dst = trim(getPOST("f_dst", null, ''));
+    }
     $new = [
         'name'        => trim(getPOST("f_name", null, $filter['name'])),
-        'dst'         => trim(getPOST("f_dst", null, '')),
+        'dst'         => $dst,
+        'ipset_id'    => $ipset_id > 0 ? $ipset_id : 0,
         'proto'       => trim(getPOST("f_proto", null, '')),
         'dstport'     => str_replace(':', '-', trim(getPOST("f_dstport", null, ''))),
         'srcport'     => str_replace(':', '-', trim(getPOST("f_srcport", null, ''))),
@@ -24,52 +34,114 @@ if (getPOST("editfilter") !== null) {
 unset($_POST);
 
 require_once ($_SERVER['DOCUMENT_ROOT']."/inc/header.php");
-
-
 print_filters_submenu($page_url);
 
 print "<div id=cont>";
+print "<br><b>".WEB_title_filter."</b><br>";
 
-print "<br> <b>".WEB_title_filter."</b> <br>";
+$ipsets = get_records_sql($db_link, "SELECT id, name, description FROM ipset_list ORDER BY name");
+$has_ipsets = !empty($ipsets);
+$current_ipset_id = $filter['ipset_id'] ?? 0;
+$use_ipset = ($current_ipset_id > 0) ? 1 : 0;
+?>
 
-print "<form name=def action='editfilter.php?id=".$id."' method=post>";
-print "<input type=hidden name=id value=$id>";
+<form name="def" action="editfilter.php?id=<?php echo $id; ?>" method="post" id="filterForm">
+    <input type="hidden" name="id" value="<?php echo $id; ?>">
+    <input type="hidden" name="f_use_ipset" id="f_use_ipset" value="<?php echo $use_ipset; ?>">
 
-if (isset($filter['filter_type']) and $filter['filter_type'] == 0) {
-    print "<table class=\"data\" cellspacing=\"0\" cellpadding=\"4\">";
-    print "<tr><td><b>".WEB_cell_forename."</b></td>";
-    print "<td colspan=2><b>".WEB_cell_description."</b></td>";
-    print "</tr>";
-    print "<tr>";
-    print "<td align=left><input type=text name=f_name value='".$filter['name']."'></td>";
-    print "<td colspan=2><input type=text name=f_description value='".$filter['description']."' class='full-width'></td>";
-    print "<td><input type=submit name=editfilter value='".WEB_btn_save."'></td>";
-    print "</tr>";
-    print "<tr>";
-    print "<td ><b>".WEB_traffic_proto."</b></td>";
-    print "<td ><b>".WEB_traffic_dest_address."</b></td>";
-    print "<td ><b>".WEB_traffic_dst_port."</b></td>";
-    print "<td ><b>".WEB_traffic_src_port."</b></td>";
-    print "</tr>";
-    print "<tr>";
-    print "<td ><input type=text name=f_proto value='".$filter['proto']."'></td>";
-    print "<td ><input type=text name=f_dst value='".$filter['dst']."'></td>";
-    print "<td ><input type=text name=f_dstport value='".$filter['dstport']."'></td>";
-    print "<td ><input type=text name=f_srcport value='".$filter['srcport']."'></td>";
-    print "</tr>";
-    print "</table>";
-} else {
-    print "<table class=\"data\" cellspacing=\"0\" cellpadding=\"4\">";
-    print "<tr><td><b>".WEB_cell_forename."</b></td>";
-    print "<td><b>".WEB_cell_description."</b></td>";
-    print "<td><input type=submit name=editfilter value=".WEB_btn_save."></td>";
-    print "</tr>";
-    print "<tr>";
-    print "<td align=left><input type=text name=f_name value='".$filter['name']."'></td>";
-    print "<td ><input type=text name=f_description value='".$filter['description']."'></td>";
-    print "<td ><input type=text name=f_dst value='".$filter['dst']."'></td>";
-    print "</tr>";
-    print "</table>";
-}
-print "</form>";
+    <table class="data" cellspacing="0" cellpadding="4">
+        <tr>
+            <td><b><?php echo WEB_cell_forename; ?></b></td>
+            <td colspan="3"><b><?php echo WEB_cell_description; ?></b></td>
+        </tr>
+        <tr>
+            <td align="left">
+                <input type="text" name="f_name" value="<?php echo htmlspecialchars($filter['name']); ?>" size="30">
+            </td>
+            <td colspan="3">
+                <input type="text" name="f_description" value="<?php echo htmlspecialchars($filter['description']); ?>" class="full-width" style="width:100%">
+            </td>
+            <td><input type="submit" name="editfilter" value="<?php echo WEB_btn_save; ?>"></td>
+        </tr>
+        <tr>
+            <td><b><?php echo WEB_traffic_proto; ?></b></td>
+            <td><b><?php echo WEB_traffic_dest_address; ?> (Dst / IPSet)</b></td>
+            <td><b><?php echo WEB_traffic_dst_port; ?></b></td>
+            <td><b><?php echo WEB_traffic_src_port; ?></b></td>
+        </tr>
+        <tr>
+            <td>
+                <input type="text" name="f_proto" value="<?php echo htmlspecialchars($filter['proto']); ?>" size="10">
+            </td>
+            <td>
+                <div style="margin-bottom:8px">
+                    <label>
+                        <input type="radio" name="dst_mode" value="ip" 
+                               <?php if (!$use_ipset) echo 'checked'; ?> 
+                               onclick="toggleDstMode('ip')">
+                        <?php echo WEB_traffic_dst_subnet; ?>
+                    </label>
+                    <?php if ($has_ipsets): ?>
+                    <label style="margin-left:15px">
+                        <input type="radio" name="dst_mode" value="ipset" 
+                               <?php if ($use_ipset) echo 'checked'; ?> 
+                               onclick="toggleDstMode('ipset')">
+                        <?php echo WEB_traffic_dst_ipset; ?>
+                    </label>
+                    <?php endif; ?>
+                </div>
+                <div id="dst_ip_container" style="<?php echo $use_ipset ? 'display:none' : 'display:block'; ?>">
+                    <input type="text" name="f_dst" id="f_dst" 
+                           value="<?php echo htmlspecialchars($filter['dst']); ?>" 
+                           size="25" placeholder="192.168.1.0/24">
+                </div>
+                <div id="dst_ipset_container" style="<?php echo $use_ipset ? 'display:block' : 'display:none'; ?>">
+                    <select name="f_ipset_id" id="f_ipset_id" style="width:100%; max-width:300px">
+                        <option value="0">-- <?php echo WEB_traffic_select_ipset; ?> --</option>
+                        <?php foreach ($ipsets as $ipset): ?>
+                        <option value="<?php echo $ipset['id']; ?>" 
+                                <?php if ($ipset['id'] == $current_ipset_id) echo 'selected'; ?>>
+                            <?php echo htmlspecialchars($ipset['name']); ?>
+                            <?php if (!empty($ipset['description'])): ?>
+                                (<?php echo htmlspecialchars(mb_substr($ipset['description'], 0, 40)); ?>...)
+                            <?php endif; ?>
+                        </option>
+                        <?php endforeach; ?>
+                    </select>
+                </div>
+            </td>
+            <td>
+                <input type="text" name="f_dstport" value="<?php echo htmlspecialchars(str_replace('-', ':', $filter['dstport'])); ?>" size="10">
+            </td>
+            <td>
+                <input type="text" name="f_srcport" value="<?php echo htmlspecialchars(str_replace('-', ':', $filter['srcport'])); ?>" size="10">
+            </td>
+        </tr>
+    </table>
+</form>
+
+<script>
+function toggleDstMode(mode) {
+    const ipContainer = document.getElementById('dst_ip_container');
+    const ipsetContainer = document.getElementById('dst_ipset_container');
+    const useIpsetField = document.getElementById('f_use_ipset');
+    const dstInput = document.getElementById('f_dst');
+    const ipsetSelect = document.getElementById('f_ipset_id');
+    if (mode === 'ipset') {
+        ipContainer.style.display = 'none';
+        ipsetContainer.style.display = 'block';
+        useIpsetField.value = '1';
+        dstInput.value = '';
+        dstInput.removeAttribute('required');
+    } else {
+        ipContainer.style.display = 'block';
+        ipsetContainer.style.display = 'none';
+        useIpsetField.value = '0';
+        ipsetSelect.value = '0';
+    }
+};
+</script>
+
+<?php
 require_once ($_SERVER['DOCUMENT_ROOT']."/inc/footer.php");
+?>

+ 276 - 0
html/admin/filters/editipset.php

@@ -0,0 +1,276 @@
+<?php
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/auth.php");
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/languages/" . HTML_LANG . ".php");
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/idfilter.php");
+
+// Загружаем данные ipset
+$ipset = get_record_sql($db_link, "SELECT * FROM ipset_list WHERE id=?", [ $id ]);
+if (!$ipset) {
+    die("IPSet not found");
+}
+
+// ==================== СОХРАНЕНИЕ НАСТРОЕК IPSET ====================
+if (getPOST("save_ipset") !== null) {
+    $new = [
+        'name'        => trim(getPOST("f_name", null, $ipset['name'])),
+        'description' => trim(getPOST("f_description", null, $ipset['description']))
+    ];
+    // Валидация имени (только латиница, цифры, подчёркивание, дефис)
+    if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/', $new['name'])) {
+        $error = WEB_error_ipset_name;
+    } else {
+        update_record($db_link, "ipset_list", "id = ?", $new, [$id]);
+        header("Location: " . $_SERVER["REQUEST_URI"]);
+        exit;
+    }
+}
+
+// ==================== ДОБАВЛЕНИЕ ОДНОГО ЭЛЕМЕНТА ====================
+if (getPOST("add_member") !== null) {
+    $ip          = trim(getPOST("f_ip", null, ''));
+    $description = trim(getPOST("f_member_desc", null, ''));
+    
+    if ($ip !== '') {
+        // Валидация IP (IPv4 или IPv6)
+        if (filter_var($ip, FILTER_VALIDATE_IP)) {
+            $new = [
+                'ipset_id'    => $id,
+                'ip'          => $ip,
+                'description' => $description
+            ];
+            // insert_record автоматически обработает дубликат благодаря UNIQUE ключу
+            @insert_record($db_link, "ipset_members", $new);
+        } else {
+            $error = WEB_error_ip_address . htmlspecialchars($ip);
+        }
+    }
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+// ==================== МАССОВОЕ ДОБАВЛЕНИЕ ====================
+if (getPOST("add_members_bulk") !== null) {
+    $bulk = trim(getPOST("f_bulk_ips", null, ''));
+    if ($bulk !== '') {
+        $lines = preg_split('/[\r\n]+/', $bulk);
+        foreach ($lines as $line) {
+            $line = trim($line);
+            if ($line === '' || strpos($line, '#') === 0) continue;
+            
+            // Парсинг: "ip [description]"
+            $parts = preg_split('/\s{2,}|\t/', $line, 2);
+            $ip = trim($parts[0]);
+            $desc = isset($parts[1]) ? trim($parts[1]) : '';
+            
+            if (filter_var($ip, FILTER_VALIDATE_IP)) {
+                @insert_record($db_link, "ipset_members", [
+                    'ipset_id'    => $id,
+                    'ip'          => $ip,
+                    'description' => $desc
+                ]);
+            }
+        }
+    }
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+// ==================== ОБНОВЛЕНИЕ ЭЛЕМЕНТОВ ====================
+if (getPOST("update_members") !== null) {
+    $member_ids = getPOST("f_member_id", null, []);
+    if (!empty($member_ids) && is_array($member_ids)) {
+        $f_desc = getPOST("f_member_desc_edit", null, []);
+        
+        foreach ($member_ids as $mid) {
+            $mid = (int)$mid;
+            if ($mid <= 0) continue;
+            
+            $new = [
+                'description' => isset($f_desc[$mid]) ? trim($f_desc[$mid]) : ''
+            ];
+            update_record($db_link, "ipset_members", "id = ?", $new, [$mid]);
+        }
+    }
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+// ==================== УДАЛЕНИЕ ЭЛЕМЕНТОВ ====================
+if (getPOST("remove_members") !== null) {
+    $member_ids = getPOST("f_member_id", null, []);
+    if (!empty($member_ids) && is_array($member_ids)) {
+        foreach ($member_ids as $val) {
+            if ($val !== '' && (int)$val > 0) {
+                delete_record($db_link, "ipset_members", "id = ?", [(int)$val]);
+            }
+        }
+    }
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+// ==================== ОЧИСТКА ВСЕГО IPSET ====================
+if (getPOST("clear_all") !== null) {
+    if (getPOST("confirm_clear") === 'yes') {
+        delete_record($db_link, "ipset_members", "ipset_id = ?", [$id]);
+    }
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+unset($_POST);
+
+print_filters_submenu($page_url);
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/header.php");
+?>
+
+<div id="cont">
+    <br><b>✏️ <?php echo WEB_ipset_edit.":&nbsp".htmlspecialchars($ipset['name']); ?></b><br>
+    <?php if (!empty($error)): ?>
+        <div style="color:red; margin:10px 0; padding:8px; background:#ffe0e0; border:1px solid #f99;">
+            ⚠️ <?php echo $error; ?>
+        </div>
+    <?php endif; ?>
+
+    <form method="post" action="?id=<?php echo $id; ?>" style="margin:15px 0">
+        <table class="data">
+            <tr>
+                <td width="120"><b>Имя:</b></td>
+                <td>
+                    <input type="text" name="f_name" 
+                           value="<?php echo htmlspecialchars($ipset['name']); ?>" 
+                           size="40" maxlength="64" required 
+                           pattern="[a-zA-Z0-9_-]+" 
+                           title=<?php echo WEB_ipset_name_hint; ?>>
+                </td>
+            </tr>
+            <tr>
+                <td><b><?php echo WEB_cell_description; ?>:</b></td>
+                <td>
+                    <input type="text" name="f_description" 
+                           value="<?php echo htmlspecialchars($ipset['description']); ?>" 
+                           size="70" maxlength="255">
+                </td>
+            </tr>
+            <tr>
+                <td><b><?php echo WEB_cell_created; ?>:</b></td>
+                <td><?php echo $ipset['created_at']; ?></td>
+            </tr>
+            <tr>
+                <td><b><?php echo WEB_cell_update; ?>:</b></td>
+                <td><?php echo $ipset['updated_at']; ?></td>
+            </tr>
+            <tr>
+                <td colspan="2" align="right">
+                    <input type="submit" name="save_ipset" value="💾<?php echo WEB_btn_save; ?>">
+                </td>
+            </tr>
+        </table>
+    </form>
+
+    <hr>
+
+    <!-- ========== ДОБАВЛЕНИЕ ЭЛЕМЕНТОВ (ipset_members) ========== -->
+    <b>➕ Добавить IP-адрес</b>
+    <form method="post" action="?id=<?php echo $id; ?>" style="margin:10px 0">
+        <table class="data">
+            <tr>
+                <td><?php echo WEB_msg_IP; ?>:</td>
+                <td>
+                    <input type="text" name="f_ip" size="35" 
+                           placeholder="192.168.1.1 or 2001:db8::1" 
+                           pattern="^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$"
+                           required>
+                </td>
+                <td><?php echo WEB_cell_description; ?>:</td>
+                <td><input type="text" name="f_member_desc" size="30" maxlength="255"></td>
+                <td><input type="submit" name="add_member" value="<?php echo WEB_btn_add; ?>"></td>
+            </tr>
+        </table>
+    </form>
+
+    <details style="margin:10px 0">
+        <summary><b>📋 <?php echo WEB_ipset_massadd; ?></b></summary>
+        <form method="post" action="?id=<?php echo $id; ?>" style="margin:10px 0">
+            <textarea name="f_bulk_ips" rows="8" cols="80" 
+                      placeholder="192.168.1.1&#10;10.0.0.0/24&#10;2001:db8::1&#9;<?php echo WEB_cell_description; ?>&#10;"></textarea><br>
+            <small><?php echo WEB_ipset_massadd_hint; ?></small><br><br>
+            <input type="submit" name="add_members_bulk" value="📥 <?php echo WEB_btn_add; ?>">
+        </form>
+    </details>
+
+    <hr>
+
+    <!-- ========== СПИСОК ЭЛЕМЕНТОВ (ipset_members) ========== -->
+    <b>📦 <?php echo WEB_record_count; ?>
+        <?php 
+        $count = get_record_sql($db_link, "SELECT COUNT(*) as c FROM ipset_members WHERE ipset_id=?", [$id]);
+        echo $count['c'] ?? 0; 
+        ?>)</b>
+    
+    <form method="post" action="?id=<?php echo $id; ?>" style="margin:10px 0">
+        <table class="data">
+            <thead>
+                <tr>
+                    <td><input type="checkbox" id="chk_all" onclick="toggleAll(this)"></td>
+                    <td><b>ID</b></td>
+                    <td><b><?php echo WEB_msg_IP; ?></b></td>
+                    <td><b><?php echo WEB_cell_description; ?></b></td>
+                    <td><b><?php echo WEB_cell_created; ?></b></td>
+                    <td class="up"><input type="submit" name="update_members" value="✏️ <?php echo WEB_btn_save; ?>"></td>
+                    <td class="warn"><input type="submit" name="remove_members" value="🗑️ <?php echo WEB_btn_delete; ?>" 
+                           onclick="return confirm('<?php echo WEB_msg_delete_selected; ?>')"></td>
+                </tr>
+            </thead>
+            <tbody>
+                <?php
+                $members = get_records_sql($db_link, 
+                    "SELECT id, ip, description, created_at FROM ipset_members WHERE ipset_id=? ORDER BY ip", 
+                    [$id]);
+                
+                if (!empty($members)):
+                    foreach ($members as $m):
+                ?>
+                <tr>
+                    <td><input type="checkbox" name="f_member_id[]" value="<?php echo $m['id']; ?>"></td>
+                    <td><?php echo $m['id']; ?></td>
+                    <td><code><?php echo htmlspecialchars($m['ip']); ?></code></td>
+                    <td>
+                        <input type="text" name="f_member_desc_edit[<?php echo $m['id']; ?>]" 
+                               value="<?php echo htmlspecialchars($m['description']); ?>" size="35" maxlength="255">
+                    </td>
+                    <td><small><?php echo $m['created_at']; ?></small></td>
+                    <td colspan="2"></td>
+                </tr>
+                <?php 
+                    endforeach;
+                else:
+                ?>
+                <tr>
+                    <td colspan="7" align="center" style="color:#666; padding:20px;">
+                        ⚪ <?php echo WEB_ipset_empty; ?>
+                    </td>
+                </tr>
+                <?php endif; ?>
+            </tbody>
+        </table>
+    </form>
+
+    <?php if (!empty($members)): ?>
+    <form method="post" action="?id=<?php echo $id; ?>" onsubmit="return confirm('⚠️<?php echo WEB_ipset_clear_qa; ?>')">
+        <input type="hidden" name="confirm_clear" value="yes">
+        <input type="submit" name="clear_all" value="<?php echo WEB_ipset_clear; ?>"
+               style="background:#c33; color:white; border:none; padding:8px 16px; cursor:pointer;">
+    </form>
+    <?php endif; ?>
+
+<script>
+function toggleAll(source) {
+    const checkboxes = document.querySelectorAll('input[name="f_member_id[]"]');
+    checkboxes.forEach(cb => cb.checked = source.checked);
+}
+</script>
+
+<?php
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/footer.php");
+?>

+ 61 - 33
html/admin/filters/index.php

@@ -6,19 +6,19 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/languages/" . HTML_LANG . ".php")
 if (getPOST("create") !== null) {
     $fname = trim(getPOST("newfilter", null, ''));
     $ftype = (int)getPOST("filter_type", null, 0);
-    
+
     if ($fname !== '') {
         $new_id = insert_record($db_link, "filter_list", [
             'name'         => $fname,
             'filter_type'  => $ftype
         ]);
-        
+
         if ($new_id) {
             header("Location: editfilter.php?id=$new_id");
             exit;
         }
     }
-    
+
     header("Location: " . $_SERVER["REQUEST_URI"]);
     exit;
 }
@@ -26,20 +26,20 @@ if (getPOST("create") !== null) {
 // Удаление фильтров
 if (getPOST("remove") !== null) {
     $fid = getPOST("fid", null, []);
-    
+
     if (!empty($fid) && is_array($fid)) {
         foreach ($fid as $val) {
             $val = trim($val);
             if ($val === '') continue;
-            
+
             // Удаляем из связей
             delete_records($db_link, "group_filters", "filter_id = ?", [(int)$val]);
-            
+
             // Удаляем сам фильтр
             delete_record($db_link, "filter_list", "id = ?", [(int)$val]);
         }
     }
-    
+
     header("Location: " . $_SERVER["REQUEST_URI"]);
     exit;
 }
@@ -64,41 +64,69 @@ print_filters_submenu($page_url);
                 <td><input type="submit" onclick="return confirm('<?php echo WEB_msg_delete; ?>?')" name="remove" value="<?php echo WEB_btn_delete; ?>"></td>
             </tr>
             <?php
-            $filters = get_records_sql($db_link, 'SELECT * FROM filter_list ORDER BY name');
+            // Запрос с подгрузкой имени IPSet через LEFT JOIN
+            $filters = get_records_sql($db_link, 
+                'SELECT f.*, i.name AS ipset_name 
+                 FROM filter_list f 
+                 LEFT JOIN ipset_list i ON f.ipset_id = i.id 
+                 ORDER BY f.name');
+            
             foreach ($filters as $row) {
                 print "<tr align=center>\n";
                 print "<td class=\"data\" style='padding:0'><input type=checkbox name=fid[] value=" . $row['id'] . "></td>\n";
                 print "<td class=\"data\" ><input type=hidden name=\"id\" value=" . $row['id'] . ">" . $row['id'] . "</td>\n";
-                print "<td class=\"data\" align=left><a href=editfilter.php?id=" . $row['id'] . ">" . $row['name'] . "</a></td>\n";
-                if (empty($row['description'])) {
-                    $row['description'] = '';
-                }
-                if (empty($row['proto'])) {
-                    $row['proto'] = '';
-                }
-                if (empty($row['dst'])) {
-                    $row['dst'] = '';
-                }
-                if (empty($row['dstport'])) {
-                    $row['dstport'] = '';
-                }
-                if (empty($row['srcport'])) {
-                    $row['srcport'] = '';
-                }
+                print "<td class=\"data\" align=left><a href=editfilter.php?id=" . $row['id'] . ">" . htmlspecialchars($row['name']) . "</a></td>\n";
+                
+                // Инициализация пустых полей
+                $row['description'] = $row['description'] ?? '';
+                $row['proto']       = $row['proto'] ?? '';
+                $row['dst']         = $row['dst'] ?? '';
+                $row['dstport']     = $row['dstport'] ?? '';
+                $row['srcport']     = $row['srcport'] ?? '';
+                
                 if ($row['filter_type'] == 0) {
+                    // === Формирование отображения dst / ipset ===
+                    $dst_display = '';
+                    if (!empty($row['ipset_id']) && !empty($row['ipset_name'])) {
+                        // Режим IPSet: показываем имя с ссылкой на редактирование
+                        $dst_display = '<span title="IPSet">' . 
+                            '<a href="editipset.php?id=' . (int)$row['ipset_id'] . '" ' .
+                            'style="color:#06c; text-decoration:none; font-weight:500">' .
+                            '📦 ' . htmlspecialchars($row['ipset_name']) . '</a></span>';
+                    } elseif (!empty($row['dst'])) {
+                        // Режим прямого IP: показываем dst
+                        $dst_display = htmlspecialchars($row['dst']);
+                    } else {
+                        // Пустое значение
+                        $dst_display = '<span style="color:#999">0/0</span>';
+                    }
+                    
                     print "<td class=\"data\">IP фильтр</td>\n";
-                    print "<td class=\"data\">" . $row['proto'] . "</td>\n";
-                    print "<td class=\"data\">" . $row['dst'] . "</td>\n";
-                    print "<td class=\"data\">" . $row['dstport'] . "</td>\n";
-                    print "<td class=\"data\">" . $row['srcport'] . "</td>\n";
-                    print "<td class=\"data\">" . $row['description'] . "</td>\n";
+                    print "<td class=\"data\">" . htmlspecialchars($row['proto']) . "</td>\n";
+                    print "<td class=\"data\" align=left>" . $dst_display . "</td>\n";
+                    print "<td class=\"data\">" . htmlspecialchars($row['dstport']) . "</td>\n";
+                    print "<td class=\"data\">" . htmlspecialchars($row['srcport']) . "</td>\n";
+                    print "<td class=\"data\">" . htmlspecialchars($row['description']) . "</td>\n";
                 } else {
+                    // Name фильтр (упрощённый режим)
+                    $dst_display = '';
+                    if (!empty($row['ipset_id']) && !empty($row['ipset_name'])) {
+                        $dst_display = '<span title="IPSet">' . 
+                            '<a href="editipset.php?id=' . (int)$row['ipset_id'] . '" ' .
+                            'style="color:#06c; text-decoration:none; font-weight:500">' .
+                            '📦 ' . htmlspecialchars($row['ipset_name']) . '</a></span>';
+                    } elseif (!empty($row['dst'])) {
+                        $dst_display = htmlspecialchars($row['dst']);
+                    } else {
+                        $dst_display = '<span style="color:#999">—</span>';
+                    }
+                    
                     print "<td class=\"data\">Name фильтр</td>\n";
                     print "<td class=\"data\"></td>\n";
-                    print "<td class=\"data\">" . $row['dst'] . "</td>\n";
+                    print "<td class=\"data\" align=left>" . $dst_display . "</td>\n";
                     print "<td class=\"data\"></td>\n";
                     print "<td class=\"data\"></td>\n";
-                    print "<td class=\"data\">" . $row['description'] . "</td>\n";
+                    print "<td class=\"data\">" . htmlspecialchars($row['description']) . "</td>\n";
                 }
                 print "<td></td></tr>";
             }
@@ -107,7 +135,7 @@ print_filters_submenu($page_url);
         <div>
             <?php echo WEB_cell_name; ?>
             <input type=text name=newfilter value="Unknown">
-            <?php echo Web_filter_type; ?>
+            <?php echo WEB_filter_type; ?>
             <select name="filter_type" disabled=true>
                 <option value=0 selected>IP фильтр</option>
                 <option value=1>Name фильтр</option>
@@ -116,4 +144,4 @@ print_filters_submenu($page_url);
     </form>
     <?php
     require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/footer.php");
-    ?>
+    ?>

+ 119 - 0
html/admin/filters/index1.php

@@ -0,0 +1,119 @@
+<?php
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/auth.php");
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/languages/" . HTML_LANG . ".php");
+
+// Создание нового фильтра
+if (getPOST("create") !== null) {
+    $fname = trim(getPOST("newfilter", null, ''));
+    $ftype = (int)getPOST("filter_type", null, 0);
+    
+    if ($fname !== '') {
+        $new_id = insert_record($db_link, "filter_list", [
+            'name'         => $fname,
+            'filter_type'  => $ftype
+        ]);
+        
+        if ($new_id) {
+            header("Location: editfilter.php?id=$new_id");
+            exit;
+        }
+    }
+    
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+// Удаление фильтров
+if (getPOST("remove") !== null) {
+    $fid = getPOST("fid", null, []);
+    
+    if (!empty($fid) && is_array($fid)) {
+        foreach ($fid as $val) {
+            $val = trim($val);
+            if ($val === '') continue;
+            
+            // Удаляем из связей
+            delete_records($db_link, "group_filters", "filter_id = ?", [(int)$val]);
+            
+            // Удаляем сам фильтр
+            delete_record($db_link, "filter_list", "id = ?", [(int)$val]);
+        }
+    }
+    
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+unset($_POST);
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/header.php");
+print_filters_submenu($page_url);
+?>
+<div id="cont">
+    <form name="def" action="index.php" method="post">
+        <table class="data">
+            <tr align="center">
+                <td><input type="checkbox" onClick="checkAll(this.checked);"></td>
+                <td><b>id</b></td>
+                <td><b><?php echo WEB_cell_forename; ?></b></td>
+                <td><b><?php echo WEB_cell_type; ?></b></td>
+                <td><b><?php echo WEB_traffic_proto; ?></b></td>
+                <td><b><?php echo WEB_traffic_dest_address; ?></b></td>
+                <td><b><?php echo WEB_traffic_dst_port; ?></b></td>
+                <td><b><?php echo WEB_traffic_src_port; ?></b></td>
+                <td><b><?php echo WEB_cell_description; ?></b></td>
+                <td><input type="submit" onclick="return confirm('<?php echo WEB_msg_delete; ?>?')" name="remove" value="<?php echo WEB_btn_delete; ?>"></td>
+            </tr>
+            <?php
+            $filters = get_records_sql($db_link, 'SELECT * FROM filter_list ORDER BY name');
+            foreach ($filters as $row) {
+                print "<tr align=center>\n";
+                print "<td class=\"data\" style='padding:0'><input type=checkbox name=fid[] value=" . $row['id'] . "></td>\n";
+                print "<td class=\"data\" ><input type=hidden name=\"id\" value=" . $row['id'] . ">" . $row['id'] . "</td>\n";
+                print "<td class=\"data\" align=left><a href=editfilter.php?id=" . $row['id'] . ">" . $row['name'] . "</a></td>\n";
+                if (empty($row['description'])) {
+                    $row['description'] = '';
+                }
+                if (empty($row['proto'])) {
+                    $row['proto'] = '';
+                }
+                if (empty($row['dst'])) {
+                    $row['dst'] = '';
+                }
+                if (empty($row['dstport'])) {
+                    $row['dstport'] = '';
+                }
+                if (empty($row['srcport'])) {
+                    $row['srcport'] = '';
+                }
+                if ($row['filter_type'] == 0) {
+                    print "<td class=\"data\">IP фильтр</td>\n";
+                    print "<td class=\"data\">" . $row['proto'] . "</td>\n";
+                    print "<td class=\"data\">" . $row['dst'] . "</td>\n";
+                    print "<td class=\"data\">" . $row['dstport'] . "</td>\n";
+                    print "<td class=\"data\">" . $row['srcport'] . "</td>\n";
+                    print "<td class=\"data\">" . $row['description'] . "</td>\n";
+                } else {
+                    print "<td class=\"data\">Name фильтр</td>\n";
+                    print "<td class=\"data\"></td>\n";
+                    print "<td class=\"data\">" . $row['dst'] . "</td>\n";
+                    print "<td class=\"data\"></td>\n";
+                    print "<td class=\"data\"></td>\n";
+                    print "<td class=\"data\">" . $row['description'] . "</td>\n";
+                }
+                print "<td></td></tr>";
+            }
+            ?>
+        </table>
+        <div>
+            <?php echo WEB_cell_name; ?>
+            <input type=text name=newfilter value="Unknown">
+            <?php echo WEB_filter_type; ?>
+            <select name="filter_type" disabled=true>
+                <option value=0 selected>IP фильтр</option>
+                <option value=1>Name фильтр</option>
+            </select>
+            <input type="submit" name="create" value="<?php echo WEB_btn_add; ?>">
+    </form>
+    <?php
+    require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/footer.php");
+    ?>

+ 67 - 0
html/admin/filters/ipsets.php

@@ -0,0 +1,67 @@
+<?php
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/auth.php");
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/languages/" . HTML_LANG . ".php");
+
+if (getPOST("create") !== null) {
+    $fname = trim(getPOST("newipset", null, ''));
+    if ($fname !== '') {
+        $new_id = insert_record($db_link, "ipset_list", ['name' => $fname]);
+        if ($new_id) {
+            header("Location: editipset.php?id=$new_id");
+            exit;
+        }
+    }
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+if (getPOST("remove") !== null) {
+    $fgid = getPOST("fid", null, []);
+    if (!empty($fgid) && is_array($fgid)) {
+        foreach ($fgid as $val) {
+            $val = trim($val);
+            if ($val === '') continue;
+            delete_records($db_link, "ipset_members", "ipset_id = ?", [$val]);
+            delete_records($db_link, "group_filters", "ipset_id = ?", [$val]);
+            delete_records($db_link, "ipset_list", "id = ?", [$val]);
+        }
+    }
+    header("Location: " . $_SERVER["REQUEST_URI"]);
+    exit;
+}
+
+unset($_POST);
+require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/header.php");
+
+print_filters_submenu($page_url);
+?>
+<div id="cont">
+    <form name="def" action="ipsets.php" method="post">
+        <table class="data">
+            <tr align="center">
+                <td><input type="checkbox" onClick="checkAll(this.checked);"></td>
+                <td><b>Id</b></td>
+                <td width=200><b><?php echo WEB_cell_name; ?></b></td>
+                <td width=200><b><?php echo WEB_cell_description; ?></b></td>
+                <td><input type="submit" onclick="return confirm('<?php echo WEB_msg_delete; ?>?')" name="remove" value="<?php echo WEB_btn_delete; ?>"></td>
+            </tr>
+            <?php
+            $ipsets = get_records_sql($db_link, 'SELECT * FROM ipset_list ORDER BY id');
+            foreach ($ipsets as $row) {
+                print "<tr align=center>\n";
+                print "<td class=\"data\" style='padding:0'><input type=checkbox name=fid[] value=" . $row["id"] . "></td>\n";
+                print "<td class=\"data\" ><input type=\"hidden\" name=\"" . $row["id"] . "\" value=" . $row["id"] . ">" . $row["id"] . "</td>\n";
+                print "<td class=\"data\"><a href=editipset.php?id=" . $row["id"] . ">" . $row["name"] . "</a></td>\n";
+                print "<td class=\"data\">" . $row["description"] . "</td>\n";
+                print "<td></td></tr>";
+            }
+            ?>
+        </table>
+        <div>
+            <?php echo WEB_cell_name; ?><input type=text name=newipset value="Unknown">
+            <input type="submit" name="create" value="<?php echo WEB_btn_add; ?>">
+        </div>
+    </form>
+    <?php
+    require_once($_SERVER['DOCUMENT_ROOT'] . "/inc/footer.php");
+    ?>

+ 1 - 0
html/inc/common.php

@@ -1401,6 +1401,7 @@ function print_filters_submenu($current_page)
     print "<div id='submenu'>\n";
     print_submenu_url(WEB_submenu_filter_list, '/admin/filters/index.php', $current_page, 0);
     print_submenu_url(WEB_submenu_filter_group, '/admin/filters/groups.php', $current_page, 0);
+    print_submenu_url(WEB_submenu_filter_ipset, '/admin/filters/ipsets.php', $current_page, 0);
     print_submenu_url(WEB_submenu_filter_instances, '/admin/filters/instances.php', $current_page, 1);
     print "</div>\n";
 }

+ 2 - 1
html/inc/languages/english.php

@@ -124,6 +124,7 @@ define("WEB_submenu_network_stats","Networks (Statistics)");
 define("WEB_submenu_options","Parameters");
 define("WEB_submenu_customers","Users");
 define("WEB_submenu_filter_list","Filter List");
+define("WEB_submenu_filter_ipset","Ipsets");
 define("WEB_submenu_filter_group","Filter Groups");
 define("WEB_submenu_filter_instances","Filter instances");
 define("WEB_submenu_filter_instance","Filter instance");
@@ -421,7 +422,7 @@ define("WEB_device_poe_off","Turn off POE");
 
 /* edit filter */
 define("WEB_title_filter","Filter");
-define("Web_filter_type","Filter type");
+define("WEB_filter_type","Filter type");
 define("WEB_traffic_action","Action");
 define("WEB_traffic_dest_address","Dst ip");
 define("WEB_traffic_source_address","Src ip");

+ 20 - 2
html/inc/languages/russian.php

@@ -124,6 +124,7 @@ define("WEB_submenu_network_stats","Сети (Статистика)");
 define("WEB_submenu_options","Параметры");
 define("WEB_submenu_customers","Пользователи");
 define("WEB_submenu_filter_list","Список фильтров");
+define("WEB_submenu_filter_ipset","Группы адресов");
 define("WEB_submenu_filter_instances","Экземпляры фильтрации");
 define("WEB_submenu_filter_instance","Экземпляр фильтрации");
 define("WEB_submenu_filter_group","Группы фильтров");
@@ -189,7 +190,7 @@ define("WEB_cell_mac","MAC");
 define("WEB_cell_clientid","Client-id");
 define("WEB_cell_host_firmware","Прошивка");
 define("WEB_cell_sn","SN");
-define("WEB_cell_description","Комментарий");
+define("WEB_cell_description","Описание");
 define("WEB_cell_wikiname","Wiki Name");
 define("WEB_cell_filter","Фильтр");
 define("WEB_cell_proxy","Proxy");
@@ -224,6 +225,7 @@ define("WEB_cell_mac_count","Mac count");
 define("WEB_cell_forename","Имя");
 define("WEB_cell_flags","Флаги");
 define("WEB_cell_created","Создан");
+define("WEB_cell_update","Обновлено");
 define("WEB_cell_created_by","Источник");
 define("WEB_cell_deleted","Удалён");
 define("WEB_cell_gateway","Шлюз");
@@ -248,6 +250,18 @@ define("WEB_list_models","Список моделей устройств");
 define("WEB_list_vendors","Список вендоров");
 define("WEB_list_queues","Список шейперов");
 
+/* ipset */
+define("WEB_error_ipset_name","Недопустимое имя ipset (только a-z, A-Z, 0-9, _, -)");
+define("WEB_error_ip_address","Неверный формат IP-адреса: ");
+define("WEB_ipset_edit","Редактирование IPSet");
+define("WEB_ipset_name_hint","Только a-z, A-Z, 0-9, _, -");
+define("WEB_ipset_massadd","Массовое добавление");
+define("WEB_ipset_massadd_hint","Формат: <code>IP-адрес [табуляция] описание</code>. Строки с # игнорируются.");
+define("WEB_ipset_empty","Список пуст");
+define("WEB_ipset_clear","Очистить ВСЁ");
+define("WEB_ipset_clear_qa","Вы уверены? Будут удалены ВСЕ записи!");
+define("WEB_record_count","Элементы (всего: ");
+
 /* button names */
 define("WEB_btn_remove","Удалить");
 define("WEB_btn_add","Добавить");
@@ -421,13 +435,17 @@ define("WEB_device_poe_off","Выключить POE");
 
 /* edit filter */
 define("WEB_title_filter","Фильтр");
-define("Web_filter_type","Тип фильтра");
+define("WEB_filter_type","Тип фильтра");
 define("WEB_traffic_action","Действие");
 define("WEB_traffic_dest_address","Адрес назначения");
+define("WEB_traffic_dst_subnet","IP-адрес/сеть");
+define("WEB_traffic_dst_ipset","Список адресов");
+define("WEB_traffic_select_ipset","Выберите список адресов");
 define("WEB_traffic_source_address","Адрес источника");
 define("WEB_traffic_proto","Протокол");
 define("WEB_traffic_src_port","Порт источник");
 define("WEB_traffic_dst_port","Порт назначения");
+define("WEB_traffic_dst_hint","Укажите IP-адрес или сеть в поле назначения");
 
 /* edit group filters */
 define("WEB_title_group","Группа");

+ 3 - 0
scripts/updates/3-1-0/ipset.msql

@@ -0,0 +1,3 @@
+CREATE TABLE `ipset_list` ( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(64) NOT NULL UNIQUE COMMENT 'Имя ipset', `description` VARCHAR(255) DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE `ipset_members` ( `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `ipset_id` INT NOT NULL, `ip` VARCHAR(39) NOT NULL COMMENT 'IPv4 или IPv6 адрес', `description` VARCHAR(255) DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uniq_ipset_ip` (`ipset_id`, `ip`),  CONSTRAINT `fk_ipset_members_ipset` FOREIGN KEY (`ipset_id`) REFERENCES `ipset_list` (`id`) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+ALTER TABLE `filter_list` ADD COLUMN `ipset_id` INT(11) DEFAULT NULL AFTER `dst`;

+ 4 - 0
scripts/updates/3-1-0/ipset.psql

@@ -0,0 +1,4 @@
+CREATE TABLE ipset_list ( id SERIAL PRIMARY KEY, name VARCHAR(64) NOT NULL UNIQUE, description VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );
+CREATE TABLE ipset_members ( id BIGSERIAL PRIMARY KEY, ipset_id INTEGER NOT NULL REFERENCES ipset_list(id) ON DELETE CASCADE, ip INET NOT NULL, description VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (ipset_id, ip));
+CREATE INDEX idx_ipset_members_ip ON ipset_members USING BTREE (ip inet_ops);
+ALTER TABLE filter_list ADD COLUMN ipset_id INTEGER DEFAULT NULL;