feat: Add IP ranges management API endpoints and integrate with frontend for viewing and updating IP ranges
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m20s

This commit is contained in:
2025-07-14 19:40:27 +07:00
parent a54c09ae15
commit 2bee891548
16 changed files with 804 additions and 0 deletions
+50
View File
@@ -176,6 +176,56 @@ app.post('/api/domains-new', async (req, res) => {
}
});
// --- IP Ranges Routes ---
// Get IP ranges from S3
app.get('/api/ip-ranges', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'domains/ips.txt',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const ipRanges = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
const ipRange = parts[0] || '';
const community = parts[1] || '';
return { ipRange, community };
});
res.json(ipRanges);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading from S3');
}
}
});
// Update IP ranges in S3
app.post('/api/ip-ranges', async (req, res) => {
const { ipRanges } = req.body;
const fileContent = ipRanges.map(ip => `${ip.ipRange} ${ip.community}`).join('\n');
const params = {
Bucket: BUCKET_NAME,
Key: 'domains/ips.txt',
Body: fileContent,
ContentType: 'text/plain',
};
try {
await s3.putObject(params).promise();
res.send('File updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing to S3');
}
});
// --- Servers Routes (JSON format) ---
// Get servers from S3
+112
View File
@@ -0,0 +1,112 @@
# Примеры файлов S3 для Router Lists UI
Эта папка содержит примеры файлов, которые хранятся в S3 для работы с FilterManager.
## Структура папок
```
s3/
├── domains/ # Файлы доменов и IP
│ ├── domains.txt # Основные домены
│ ├── domains_comunity.txt # Домены с community
│ ├── asns.txt # ASN списки
│ └── ips.txt # IP-диапазоны с community
├── filter-manager/ # Файлы FilterManager
│ ├── simple-filters.json # Фильтры упрощённого режима
│ ├── server-filters-*.json # Фильтры для каждого сервера
│ └── config-*.txt # Сгенерированные конфигурации MikroTik
└── server-configs.json # Список серверов (в корне S3)
```
## Описание файлов
### `server-configs.json`
Список всех серверов в расширенном режиме.
```json
[
{
"id": "server-001",
"name": "SWE-HIPHOST",
"description": "Шведский сервер для стриминга и игр",
"enabled": true,
"createdAt": "2024-01-15T10:30:00.000Z"
}
]
```
### `filter-manager/simple-filters.json`
Фильтры для упрощённого режима (применяются ко всем серверам).
```json
[
{
"community": "65001:100",
"gateway": "SWE-HIPHOST",
"description": "Шведский сервер для стриминга"
}
]
```
### `filter-manager/server-filters-{serverId}.json`
Фильтры для конкретного сервера в расширенном режиме.
```json
[
{
"community": "65001:100",
"gateway": "SWE-HIPHOST",
"description": "Стриминг сервисы"
}
]
```
### `domains/ips.txt`
IP-диапазоны с community для маршрутизации.
```
192.168.1.0/24 65001:100
10.0.0.0/8 65001:200
172.16.0.0/12 65001:300
8.8.8.0/24 65001:400
```
### `filter-manager/config-{serverId}.txt`
Сгенерированная конфигурация MikroTik для конкретного сервера.
```
// Frouting filter configuration for MikroTik 7.14+
// Generated automatically
// Date: 2024-01-20T12:00:00.000Z
if (
(bgp-communities includes 65001:100)
or
(bgp-communities includes 65001:101)
)
{
set gw SWE-HIPHOST; accept;
}
else
{
reject;
}
```
## Примеры серверов
1. **SWE-HIPHOST** (server-001) - Шведский сервер для стриминга
2. **NL-AMSTERDAM** (server-002) - Голландский сервер с низкой задержкой
3. **DE-FRANKFURT** (server-003) - Немецкий сервер для торрентов (отключен)
4. **US-NEWYORK** (server-004) - Американский сервер для Netflix (с каскадной структурой)
5. **JP-TOKYO** (server-005) - Японский сервер для аниме и игр
## Особенности
- **Каскадная структура**: Сервер US-NEWYORK демонстрирует конфигурацию с несколькими gateway
- **Отключённые серверы**: DE-FRANKFURT показывает неактивный сервер
- **Разные типы трафика**: Каждый сервер специализируется на определённом типе контента
- **Реалистичные community**: Используются AS:community в формате 65001:XXX
## Использование
Эти файлы можно использовать для:
- Тестирования FilterManager
- Демонстрации функциональности
- Обучения работе с системой
- Восстановления данных после сбоя
+8
View File
@@ -0,0 +1,8 @@
192.168.1.0/24 65001:100
10.0.0.0/8 65001:200
172.16.0.0/12 65001:300
8.8.8.0/24 65001:400
1.1.1.0/24 65001:500
208.67.222.0/24 65001:600
9.9.9.0/24 65001:700
149.112.112.0/24 65001:800
@@ -0,0 +1,18 @@
// Frouting filter configuration for MikroTik 7.14+
// Generated automatically
// Date: 2024-01-20T12:00:00.000Z
if (
(bgp-communities includes 65001:100)
or
(bgp-communities includes 65001:101)
or
(bgp-communities includes 65001:102)
)
{
set gw SWE-HIPHOST; accept;
}
else
{
reject;
}
@@ -0,0 +1,20 @@
// Frouting filter configuration for MikroTik 7.14+
// Generated automatically
// Date: 2024-01-20T12:00:00.000Z
if (
(bgp-communities includes 65001:200)
or
(bgp-communities includes 65001:201)
or
(bgp-communities includes 65001:202)
or
(bgp-communities includes 65001:203)
)
{
set gw NL-AMSTERDAM; accept;
}
else
{
reject;
}
@@ -0,0 +1,30 @@
// Frouting filter configuration for MikroTik 7.14+
// Generated automatically
// Date: 2024-01-20T12:00:00.000Z
if (
(bgp-communities includes 65001:400)
or
(bgp-communities includes 65001:401)
)
{
set gw US-NEWYORK; accept;
}
else if (
(bgp-communities includes 65001:402)
or
(bgp-communities includes 65001:403)
)
{
set gw US-LOSANGELES; accept;
}
else if (
(bgp-communities includes 65001:404)
)
{
set gw US-CHICAGO; accept;
}
else
{
reject;
}
@@ -0,0 +1,20 @@
// Frouting filter configuration for MikroTik 7.14+
// Generated automatically
// Date: 2024-01-20T12:00:00.000Z
if (
(bgp-communities includes 65001:500)
or
(bgp-communities includes 65001:501)
or
(bgp-communities includes 65001:502)
or
(bgp-communities includes 65001:503)
)
{
set gw JP-TOKYO; accept;
}
else
{
reject;
}
@@ -0,0 +1,17 @@
[
{
"community": "65001:100",
"gateway": "SWE-HIPHOST",
"description": "Стриминг сервисы"
},
{
"community": "65001:101",
"gateway": "SWE-HIPHOST",
"description": "Игровые серверы"
},
{
"community": "65001:102",
"gateway": "SWE-HIPHOST",
"description": "Социальные сети"
}
]
@@ -0,0 +1,22 @@
[
{
"community": "65001:200",
"gateway": "NL-AMSTERDAM",
"description": "Игровые платформы"
},
{
"community": "65001:201",
"gateway": "NL-AMSTERDAM",
"description": "VoIP сервисы"
},
{
"community": "65001:202",
"gateway": "NL-AMSTERDAM",
"description": "Облачные сервисы"
},
{
"community": "65001:203",
"gateway": "NL-AMSTERDAM",
"description": "CDN сети"
}
]
@@ -0,0 +1,17 @@
[
{
"community": "65001:300",
"gateway": "DE-FRANKFURT",
"description": "Торрент трекеры"
},
{
"community": "65001:301",
"gateway": "DE-FRANKFURT",
"description": "Файлообменники"
},
{
"community": "65001:302",
"gateway": "DE-FRANKFURT",
"description": "P2P сети"
}
]
@@ -0,0 +1,27 @@
[
{
"community": "65001:400",
"gateway": "US-NEWYORK",
"description": "Netflix US"
},
{
"community": "65001:401",
"gateway": "US-NEWYORK",
"description": "Hulu US"
},
{
"community": "65001:402",
"gateway": "US-LOSANGELES",
"description": "Disney+ US"
},
{
"community": "65001:403",
"gateway": "US-LOSANGELES",
"description": "HBO Max US"
},
{
"community": "65001:404",
"gateway": "US-CHICAGO",
"description": "Amazon Prime US"
}
]
@@ -0,0 +1,22 @@
[
{
"community": "65001:500",
"gateway": "JP-TOKYO",
"description": "Crunchyroll"
},
{
"community": "65001:501",
"gateway": "JP-TOKYO",
"description": "Funimation"
},
{
"community": "65001:502",
"gateway": "JP-TOKYO",
"description": "Steam JP"
},
{
"community": "65001:503",
"gateway": "JP-TOKYO",
"description": "Nintendo Online"
}
]
@@ -0,0 +1,27 @@
[
{
"community": "65001:100",
"gateway": "SWE-HIPHOST",
"description": "Шведский сервер для стриминга"
},
{
"community": "65001:200",
"gateway": "NL-AMSTERDAM",
"description": "Голландский сервер для игр"
},
{
"community": "65001:300",
"gateway": "DE-FRANKFURT",
"description": "Немецкий сервер для торрентов"
},
{
"community": "65001:400",
"gateway": "US-NEWYORK",
"description": "Американский сервер для Netflix"
},
{
"community": "65001:500",
"gateway": "JP-TOKYO",
"description": "Японский сервер для аниме"
}
]
+37
View File
@@ -0,0 +1,37 @@
[
{
"id": "server-001",
"name": "SWE-HIPHOST",
"description": "Шведский сервер для стриминга и игр",
"enabled": true,
"createdAt": "2024-01-15T10:30:00.000Z"
},
{
"id": "server-002",
"name": "NL-AMSTERDAM",
"description": "Голландский сервер с низкой задержкой",
"enabled": true,
"createdAt": "2024-01-16T14:20:00.000Z"
},
{
"id": "server-003",
"name": "DE-FRANKFURT",
"description": "Немецкий сервер для торрентов",
"enabled": false,
"createdAt": "2024-01-17T09:15:00.000Z"
},
{
"id": "server-004",
"name": "US-NEWYORK",
"description": "Американский сервер для Netflix",
"enabled": true,
"createdAt": "2024-01-18T16:45:00.000Z"
},
{
"id": "server-005",
"name": "JP-TOKYO",
"description": "Японский сервер для аниме и игр",
"enabled": true,
"createdAt": "2024-01-19T11:30:00.000Z"
}
]
+3
View File
@@ -23,6 +23,7 @@ import DataManager from './DataManager';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
import DomainsNewManager from './DomainsNewManager';
import IPRangesManager from './IPRangesManager';
import './App.css';
import axios from 'axios';
import {
@@ -52,6 +53,7 @@ function MainLayout() {
const navItems = [
{ id: 'domains', title: 'Домены', icon: IconWorld, path: '/domains' },
{ id: 'domains-new', title: 'Домены New', icon: IconWorld, path: '/domains-new' },
{ id: 'ip-ranges', title: 'IP-диапазоны', icon: IconNetwork, path: '/ip-ranges' },
{ id: 'asns', title: 'AS', icon: IconNetwork, path: '/asns' },
{ id: 'servers', title: 'Серверы', icon: IconServer, path: '/servers' },
{ id: 'filters', title: 'Фильтры', icon: IconFilter, path: '/filters' },
@@ -98,6 +100,7 @@ function MainLayout() {
/>
} />
<Route path="/domains-new" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={
<DataManager
entityName="AS"
+374
View File
@@ -0,0 +1,374 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconRefresh,
IconDownload,
IconUpload,
IconAlertTriangle,
IconFilter,
IconChevronDown,
IconLink,
IconCopy,
IconEye,
IconServer,
IconSettings,
IconDeviceFloppy,
IconHash,
IconRocket,
IconWorld,
IconNetwork
} from '@tabler/icons-react';
const API_URL = '/api';
function IPRangesManager() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [editingIndex, setEditingIndex] = useState(null);
const [editingItem, setEditingItem] = useState({ ipRange: '', community: '' });
useEffect(() => {
fetchItems();
}, []);
const fetchItems = async () => {
setLoading(true);
try {
const response = await axios.get(`${API_URL}/ip-ranges`);
setItems(response.data);
setError('');
} catch (error) {
console.error('Error fetching ip-ranges:', error);
setItems([]);
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setEditingIndex(null);
setEditingItem({ ipRange: '', community: '' });
};
const handleEdit = (index) => {
setEditingIndex(index);
setEditingItem({ ...items[index] });
};
const handleSave = () => {
if (!editingItem.ipRange.trim() || !editingItem.community.trim()) {
setError('IP-диапазон и Community обязательны для заполнения.');
return;
}
const updatedItems = [...items];
if (editingIndex !== null) {
updatedItems[editingIndex] = { ...editingItem };
} else {
updatedItems.push({ ...editingItem });
}
setItems(updatedItems);
setEditingIndex(null);
setEditingItem({ ipRange: '', community: '' });
};
const handleCancel = () => {
setEditingIndex(null);
setEditingItem({ ipRange: '', community: '' });
};
const handleDelete = (index) => {
const updatedItems = items.filter((_, i) => i !== index);
setItems(updatedItems);
};
const handleSaveToS3 = async () => {
setLoading(true);
try {
await axios.post(`${API_URL}/ip-ranges`, { ipRanges: items });
setSuccess('IP-диапазоны успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error saving ip-ranges:', error);
setError('Не удалось сохранить IP-диапазоны.');
} finally {
setLoading(false);
}
};
const handleUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
const lines = content.split('\n').filter(line => line.trim());
const parsedItems = lines.map(line => {
const parts = line.trim().split(/\s+/);
return {
ipRange: parts[0] || '',
community: parts[1] || ''
};
});
setItems(parsedItems);
setSuccess(`Загружено ${parsedItems.length} IP-диапазонов!`);
setTimeout(() => setSuccess(''), 3000);
};
reader.readAsText(file);
};
const handleDownload = () => {
const content = items.map(item => `${item.ipRange} ${item.community}`).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ips.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const filteredItems = items.filter(item =>
item.ipRange.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.community.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="container-xl">
<div className="page-header d-print-none mb-4">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">Управление IP-диапазонами</h2>
<div className="page-pretitle">Главная / IP-диапазоны</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
<button
className="btn btn-outline-primary"
onClick={handleDownload}
disabled={items.length === 0}
>
<IconDownload className="me-2" />
Скачать
</button>
<label className="btn btn-outline-primary">
<IconUpload className="me-2" />
Загрузить
<input
type="file"
accept=".txt"
onChange={handleUpload}
style={{ display: 'none' }}
/>
</label>
<button
className="btn btn-primary"
onClick={handleAdd}
>
<IconPlus className="me-2" />
Добавить IP-диапазон
</button>
</div>
</div>
</div>
</div>
{/* Уведомления */}
{error && (
<div className="alert alert-danger alert-dismissible" role="alert">
<IconAlertTriangle className="me-2" />
{error}
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<IconCheck className="me-2" />
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Панель поиска и сохранения */}
<div className="card mb-4">
<div className="card-body">
<div className="row g-3 align-items-center">
<div className="col-md-6">
<div className="input-group">
<span className="input-group-text">
<IconSearch />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по IP-диапазону или Community..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="col-md-6">
<div className="d-flex gap-2 justify-content-end">
<button
className="btn btn-outline-secondary"
onClick={fetchItems}
disabled={loading}
>
<IconRefresh className="me-2" />
Обновить
</button>
<button
className="btn btn-success"
onClick={handleSaveToS3}
disabled={loading}
>
<IconDeviceFloppy className="me-2" />
Сохранить в S3
</button>
</div>
</div>
</div>
</div>
</div>
{/* Таблица IP-диапазонов */}
<div className="card">
<div className="card-header">
<h3 className="card-title">
<IconNetwork className="me-2" />
IP-диапазоны ({filteredItems.length} из {items.length})
</h3>
</div>
<div className="card-body">
{loading ? (
<div className="text-center text-muted py-4">
<div className="spinner-border spinner-border-sm me-2" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
Загрузка IP-диапазонов...
</div>
) : filteredItems.length === 0 ? (
<div className="text-center text-muted py-4">
<IconNetwork className="icon-lg mb-2" />
<p>Нет IP-диапазонов</p>
<button
className="btn btn-primary"
onClick={handleAdd}
>
<IconPlus className="me-2" />
Добавить первый IP-диапазон
</button>
</div>
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40%' }}>IP-ДИАПАЗОН</th>
<th style={{ width: '40%' }}>COMMUNITY</th>
<th style={{ width: '20%' }} className="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, index) => (
<tr key={index}>
<td>
{editingIndex === index ? (
<input
type="text"
className="form-control form-control-sm"
value={editingItem.ipRange}
onChange={(e) => setEditingItem({ ...editingItem, ipRange: e.target.value })}
placeholder="192.168.1.0/24"
/>
) : (
<div className="d-flex align-items-center">
<span className="badge bg-blue-lt text-blue me-2">
<IconNetwork size={12} />
</span>
<code className="text-blue">{item.ipRange}</code>
</div>
)}
</td>
<td>
{editingIndex === index ? (
<input
type="text"
className="form-control form-control-sm"
value={editingItem.community}
onChange={(e) => setEditingItem({ ...editingItem, community: e.target.value })}
placeholder="65001:100"
/>
) : (
<div className="d-flex align-items-center">
<span className="badge bg-green-lt text-green me-2">
<IconHash size={12} />
</span>
<code className="text-green">{item.community}</code>
</div>
)}
</td>
<td className="text-end">
{editingIndex === index ? (
<div className="btn-list">
<button
className="btn btn-outline-success btn-icon btn-sm"
onClick={handleSave}
title="Сохранить"
>
<IconCheck size={16} />
</button>
<button
className="btn btn-outline-secondary btn-icon btn-sm"
onClick={handleCancel}
title="Отмена"
>
<IconX size={16} />
</button>
</div>
) : (
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => handleEdit(index)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => handleDelete(index)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
export default IPRangesManager;