This commit is contained in:
Alexander gritsenko 2026-04-20 08:59:59 +03:00
parent 9133480863
commit 77087fb7b3
4 changed files with 204 additions and 73 deletions

View File

@ -3,9 +3,8 @@ app.py — Flask backend для Firewall Rules Builder
"""
import json
import os
import sys
import re
from flask import Flask, jsonify, request, render_template, send_from_directory
import tempfile
from flask import Flask, jsonify, request, render_template
app = Flask(__name__, template_folder='templates', static_folder='static')
@ -473,19 +472,35 @@ def _rule_ref_list(items):
return parts
# ─── API: Импорт fw_settings.py ──────────────────────────────────────────────
@app.route('/api/import', methods=['POST'])
def import_settings():
"""Импортирует данные из fw_settings.py через exec()."""
try:
fw_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'fw_settings.py')
if not os.path.exists(fw_path):
return jsonify({'error': 'fw_settings.py не найден'}), 404
# ─── API: Сброс всех данных ──────────────────────────────────────────────────
@app.route('/api/reset', methods=['POST'])
def reset_data():
save_data(json.loads(json.dumps(EMPTY_DATA)))
return jsonify({'ok': True})
# ─── API: Загрузка fw_settings.py через браузер ──────────────────────────────
@app.route('/api/upload', methods=['POST'])
def upload_settings():
"""Принимает файл fw_settings.py через multipart/form-data и импортирует его."""
if 'file' not in request.files:
return jsonify({'error': 'Файл не передан'}), 400
f = request.files['file']
if not f.filename:
return jsonify({'error': 'Имя файла пустое'}), 400
try:
source = f.read().decode('utf-8')
return _parse_and_save(source)
except Exception as e:
return jsonify({'error': str(e)}), 500
def _parse_and_save(source):
"""Парсит исходный код fw_settings.py и сохраняет данные."""
try:
ns = {}
with open(fw_path, 'r', encoding='utf-8') as f:
source = f.read()
exec(compile(source, fw_path, 'exec'), ns)
exec(compile(source, '<uploaded>', 'exec'), ns)
raw_servers = ns.get('servers', {})
raw_nets = ns.get('nets', {})

View File

@ -70,21 +70,45 @@ async function loadAll() {
State.rules = data.rules || [];
}
// ─── App (импорт) ─────────────────────────────────────────────────────────────
function renderAll() {
Objects.render();
Groups.render();
Services.render();
SvcGroups.render();
Rules.render();
}
// ─── App (загрузка файла, сброс) ─────────────────────────────────────────────
const App = {
async importSettings() {
if (!confirm('Импортировать данные из fw_settings.py? Текущие данные будут заменены.')) return;
const r = await api('POST', '/api/import');
async uploadFile(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch('/api/upload', { method: 'POST', body: formData });
const r = await resp.json();
if (r.ok) {
showToast('Файл загружен и импортирован успешно');
await loadAll();
renderAll();
} else {
showToast('Ошибка импорта: ' + (r.error || ''), 'danger');
}
} catch (e) {
showToast('Ошибка: ' + e.message, 'danger');
}
// Сбрасываем input чтобы можно было загрузить тот же файл повторно
input.value = '';
},
async resetAll() {
if (!confirm('Сбросить ВСЕ данные? Это действие необратимо.')) return;
const r = await api('POST', '/api/reset');
if (r.ok) {
showToast('Импорт выполнен успешно');
showToast('Все данные сброшены', 'warning');
await loadAll();
Objects.render();
Groups.render();
Services.render();
SvcGroups.render();
Rules.render();
} else {
showToast('Ошибка импорта: ' + (r.error || ''), 'danger');
renderAll();
}
}
};
@ -197,12 +221,14 @@ const Objects = {
},
async delete(key) {
if (!confirm(`Удалить объект "${key}"?`)) return;
const r = await api('DELETE', `/api/objects/${encodeURIComponent(key)}`);
if (r.ok) {
showToast('Объект удалён', 'warning');
await loadAll();
this.render();
// Обновляем группы и правила — там могут быть ссылки на удалённый объект
Groups.render();
Rules.render();
}
}
};
@ -225,7 +251,12 @@ const Groups = {
const rows = [];
for (const [key, grp] of Object.entries(State.groups)) {
if (q && !key.toLowerCase().includes(q) && !(grp.name || '').toLowerCase().includes(q)) continue;
const items = (grp.items || []).map(i => `<span class="cell-tag cell-tag-group">${escHtml(i.hostname)}</span>`).join(' ');
const items = (grp.items || []).map(i => {
const exists = !!State.objects[i.hostname];
const cls = exists ? 'cell-tag-group' : 'cell-tag-missing';
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
return `<span class="cell-tag ${cls}" title="${exists ? '' : 'Объект удалён'}">${icon}${escHtml(i.hostname)}</span>`;
}).join(' ');
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${escHtml(grp.name)}</td>
@ -296,9 +327,10 @@ const Groups = {
const sel = document.getElementById('grp-selected-list');
const selItems = this._selectedItems.map((item, idx) => {
const obj = State.objects[item.hostname];
const badge = obj ? (obj.type === 'network' ? `<span class="badge-network item-badge">net</span>` : `<span class="badge-host item-badge">host</span>`) : '';
return `<div class="picker-item picker-item-selected">
<span class="item-label">${escHtml(item.hostname)}</span>
const badge = obj ? (obj.type === 'network' ? `<span class="badge-network item-badge">net</span>` : `<span class="badge-host item-badge">host</span>`) : `<span class="item-badge badge bg-danger">удалён</span>`;
const cls = obj ? '' : 'picker-item-missing';
return `<div class="picker-item picker-item-selected ${cls}">
<span class="item-label">${obj ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(item.hostname)}</span>
${badge}
<span class="item-action" onclick="Groups.removeItem(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
@ -349,12 +381,12 @@ const Groups = {
},
async delete(key) {
if (!confirm(`Удалить группу "${key}"?`)) return;
const r = await api('DELETE', `/api/groups/${encodeURIComponent(key)}`);
if (r.ok) {
showToast('Группа удалена', 'warning');
await loadAll();
this.render();
Rules.render();
}
}
};
@ -455,12 +487,13 @@ const Services = {
},
async delete(key) {
if (!confirm(`Удалить сервис "${key}"?`)) return;
const r = await api('DELETE', `/api/services/${encodeURIComponent(key)}`);
if (r.ok) {
showToast('Сервис удалён', 'warning');
await loadAll();
this.render();
SvcGroups.render();
Rules.render();
}
}
};
@ -482,8 +515,11 @@ const SvcGroups = {
for (const [key, sg] of Object.entries(State.service_groups)) {
const items = (sg.items || []).map(svcKey => {
const svc = State.services[svcKey];
const label = svc ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey;
return `<span class="cell-tag cell-tag-sgr">${escHtml(label)}</span>`;
const exists = !!svc;
const label = exists ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey;
const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing';
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
return `<span class="cell-tag ${cls}" title="${exists ? '' : 'Сервис удалён'}">${icon}${escHtml(label)}</span>`;
}).join(' ');
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
@ -546,9 +582,12 @@ const SvcGroups = {
const sel = document.getElementById('sg-selected-list');
const selItems = this._selectedItems.map((svcKey, idx) => {
const svc = State.services[svcKey];
const label = svc ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey;
return `<div class="picker-item picker-item-selected">
<span class="item-label">${escHtml(label)}</span>
const exists = !!svc;
const label = exists ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey;
const cls = exists ? '' : 'picker-item-missing';
return `<div class="picker-item picker-item-selected ${cls}">
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(label)}</span>
${exists ? '' : '<span class="item-badge badge bg-danger">удалён</span>'}
<span class="item-action" onclick="SvcGroups.removeItem(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
});
@ -596,12 +635,12 @@ const SvcGroups = {
},
async delete(key) {
if (!confirm(`Удалить группу сервисов "${key}"?`)) return;
const r = await api('DELETE', `/api/service_groups/${encodeURIComponent(key)}`);
if (r.ok) {
showToast('Группа сервисов удалена', 'warning');
await loadAll();
this.render();
Rules.render();
}
}
};
@ -621,6 +660,15 @@ const Rules = {
this.spanModal = new bootstrap.Modal(document.getElementById('modal-span'));
},
// ─── Пересчёт order по позиции ────────────────────────────────────────────
_recalcOrders(rules) {
// Назначаем order кратно 10 по позиции в массиве
return rules.map((rule, idx) => ({
...rule,
order: (idx + 1) * 10
}));
},
// ─── Рендер таблицы правил ─────────────────────────────────────────────────
render() {
const tbody = document.getElementById('rules-tbody');
@ -673,15 +721,33 @@ const Rules = {
this._initDragDrop();
},
// ─── Рендер ссылок (src/dst) с проверкой существования ───────────────────
_renderRefList(list) {
return (list || []).map(item => {
const rt = item.ref_type || 'group';
const rk = item.ref_key || '';
let exists = true;
let cls = 'cell-tag-group';
let label = rk;
if (rt === 'server') { cls = 'cell-tag-server'; const o = State.objects[rk]; label = rk + (o ? ` (${o.ip})` : ''); }
else if (rt === 'net') { cls = 'cell-tag-net'; const o = State.objects[rk]; label = rk + (o ? ` (${o.ip}/${o.prefix})` : ''); }
return `<span class="cell-tag ${cls}" title="${escHtml(rt)}">${escHtml(label)}</span>`;
if (rt === 'server') {
exists = !!State.objects[rk];
cls = exists ? 'cell-tag-server' : 'cell-tag-missing';
const o = State.objects[rk];
label = rk + (o ? ` (${o.ip})` : '');
} else if (rt === 'net') {
exists = !!State.objects[rk];
cls = exists ? 'cell-tag-net' : 'cell-tag-missing';
const o = State.objects[rk];
label = rk + (o ? ` (${o.ip}/${o.prefix})` : '');
} else {
exists = !!State.groups[rk];
cls = exists ? 'cell-tag-group' : 'cell-tag-missing';
}
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
const title = exists ? rt : 'Объект/группа удалены';
return `<span class="cell-tag ${cls}" title="${escHtml(title)}">${icon}${escHtml(label)}</span>`;
}).join('');
},
@ -689,16 +755,22 @@ const Rules = {
const parts = [];
(svcList || []).forEach(key => {
const svc = State.services[key];
const label = svc ? `${key} (${svc.proto}:${svc.dport})` : key;
parts.push(`<span class="cell-tag cell-tag-svc">${escHtml(label)}</span>`);
const exists = !!svc;
const label = exists ? `${key} (${svc.proto}:${svc.dport})` : key;
const cls = exists ? 'cell-tag-svc' : 'cell-tag-missing';
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
parts.push(`<span class="cell-tag ${cls}" title="${exists ? '' : 'Сервис удалён'}">${icon}${escHtml(label)}</span>`);
});
(sgList || []).forEach(key => {
parts.push(`<span class="cell-tag cell-tag-sgr">[${escHtml(key)}]</span>`);
const exists = !!State.service_groups[key];
const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing';
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
parts.push(`<span class="cell-tag ${cls}" title="${exists ? '' : 'Группа сервисов удалена'}">${icon}[${escHtml(key)}]</span>`);
});
return parts.join('');
},
// ─── Drag & Drop ───────────────────────────────────────────────────────────
// ─── Drag & Drop с пересчётом order ───────────────────────────────────────
_initDragDrop() {
const tbody = document.getElementById('rules-tbody');
let dragIdx = null;
@ -728,13 +800,20 @@ const Rules = {
const [moved] = newRules.splice(dragIdx, 1);
newRules.splice(targetIdx, 0, moved);
// Сохраняем новый порядок
// Пересчитываем order для всех правил
const recalculated = this._recalcOrders(newRules);
// Сохраняем через reorder + обновление каждого правила
// Используем bulk-сохранение: сначала reorder, потом обновляем order
const r = await api('POST', '/api/rules/reorder', { order: newRules.map((_, i) => i) });
// Обновляем через полную перезагрузку
await loadAll();
// Применяем новый порядок локально
State.rules = newRules;
this.render();
if (r.ok) {
// Обновляем order для каждого правила
for (let i = 0; i < recalculated.length; i++) {
await api('PUT', `/api/rules/${i}`, recalculated[i]);
}
await loadAll();
this.render();
}
dragIdx = null;
});
});
@ -767,6 +846,9 @@ const Rules = {
(rule.service_group_list || []).forEach(k => this._svcSelected.push({ ref_type: 'sg', ref_key: k }));
} else {
document.getElementById('form-rule').reset();
// Предлагаем следующий order
const maxOrder = State.rules.reduce((m, r) => Math.max(m, r.order || 0), 0);
document.getElementById('rule-order').value = maxOrder + 10;
}
document.getElementById('rule-src-search').value = '';
@ -841,7 +923,7 @@ const Rules = {
if (q && !key.toLowerCase().includes(q) && !(sg.name || '').toLowerCase().includes(q)) continue;
items.push(`<div class="picker-item" onclick="Rules.addSvc('sg','${escHtml(key)}')">
<span class="item-label">[${escHtml(key)}]</span>
<span class="item-badge badge bg-purple" style="background:#6f42c1;color:white">group</span>
<span class="item-badge badge" style="background:#6f42c1;color:white">group</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
@ -852,9 +934,13 @@ const Rules = {
const selItems = this._svcSelected.map((item, idx) => {
const isSg = item.ref_type === 'sg';
const label = isSg ? `[${item.ref_key}]` : item.ref_key;
const badge = isSg ? `<span class="item-badge badge" style="background:#6f42c1;color:white;font-size:10px">group</span>` : `<span class="item-badge badge bg-secondary">${escHtml((State.services[item.ref_key] || {}).proto || '')}</span>`;
return `<div class="picker-item picker-item-selected">
<span class="item-label">${escHtml(label)}</span>
const exists = isSg ? !!State.service_groups[item.ref_key] : !!State.services[item.ref_key];
const cls = exists ? '' : 'picker-item-missing';
const badge = isSg
? `<span class="item-badge badge" style="background:#6f42c1;color:white;font-size:10px">group</span>`
: `<span class="item-badge badge bg-secondary">${escHtml((State.services[item.ref_key] || {}).proto || '')}</span>`;
return `<div class="picker-item picker-item-selected ${cls}">
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(label)}</span>
${badge}
<span class="item-action" onclick="Rules.removeSvc(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
@ -911,11 +997,26 @@ const Rules = {
const rk = item.ref_key;
let badge = '';
let label = rk;
if (rt === 'group') { badge = `<span class="item-badge badge" style="background:#0c5460;color:white;font-size:10px">group</span>`; }
else if (rt === 'server') { badge = `<span class="item-badge badge-host">host</span>`; const o = State.objects[rk]; if (o) label += ` (${o.ip})`; }
else if (rt === 'net') { badge = `<span class="item-badge badge-network">net</span>`; const o = State.objects[rk]; if (o) label += ` (${o.ip}/${o.prefix})`; }
return `<div class="picker-item picker-item-selected">
<span class="item-label">${escHtml(label)}</span>
let exists = true;
if (rt === 'group') {
exists = !!State.groups[rk];
badge = `<span class="item-badge badge" style="background:#0c5460;color:white;font-size:10px">group</span>`;
} else if (rt === 'server') {
exists = !!State.objects[rk];
badge = `<span class="item-badge badge-host">host</span>`;
const o = State.objects[rk];
if (o) label += ` (${o.ip})`;
} else if (rt === 'net') {
exists = !!State.objects[rk];
badge = `<span class="item-badge badge-network">net</span>`;
const o = State.objects[rk];
if (o) label += ` (${o.ip}/${o.prefix})`;
}
const cls = exists ? '' : 'picker-item-missing';
return `<div class="picker-item picker-item-selected ${cls}">
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(label)}</span>
${badge}
<span class="item-action" onclick="Rules.removeRef('${side}',${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
@ -1000,7 +1101,8 @@ const Rules = {
document.getElementById('span-affinity').value = affinityStr(rule.affinity);
} else {
document.getElementById('span-name').value = '';
document.getElementById('span-order').value = '';
const maxOrder = State.rules.reduce((m, r) => Math.max(m, r.order || 0), 0);
document.getElementById('span-order').value = maxOrder + 10;
document.getElementById('span-affinity').value = '';
}
this.spanModal.show();
@ -1033,8 +1135,6 @@ const Rules = {
},
async delete(idx) {
const rule = State.rules[idx];
if (!confirm(`Удалить "${rule.name}"?`)) return;
const r = await api('DELETE', `/api/rules/${idx}`);
if (r.ok) {
showToast('Удалено', 'warning');
@ -1053,11 +1153,7 @@ document.addEventListener('DOMContentLoaded', async () => {
Rules.init();
await loadAll();
Objects.render();
Groups.render();
Services.render();
SvcGroups.render();
Rules.render();
renderAll();
// Перерисовка при переключении вкладок
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => {

View File

@ -184,6 +184,22 @@ tr.rule-span td {
color: #432874;
}
.cell-tag-missing {
background: #f8d7da;
color: #842029;
border: 1px solid #f5c2c7;
}
/* ─── Удалённые элементы в пикере ────────────────────────────────────────── */
.picker-item-missing {
background: #fff3cd !important;
border: 1px solid #ffc107;
}
.picker-item-missing:hover {
background: #ffe69c !important;
}
/* ─── Модальные окна ──────────────────────────────────────────────────────── */
.modal-header {
padding: 10px 16px;

View File

@ -13,13 +13,17 @@
<!-- Navbar -->
<nav class="navbar navbar-dark bg-dark px-3 py-2">
<span class="navbar-brand fw-bold"><i class="bi bi-shield-lock-fill me-2 text-info"></i>Firewall Rules Builder</span>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-warning" onclick="App.importSettings()">
<i class="bi bi-upload me-1"></i>Импорт fw_settings.py
</button>
<div class="d-flex gap-2 align-items-center">
<label class="btn btn-sm btn-outline-warning mb-0" title="Загрузить fw_settings.py">
<i class="bi bi-upload me-1"></i>Загрузить fw_settings.py
<input type="file" id="upload-input" accept=".py" style="display:none" onchange="App.uploadFile(this)">
</label>
<a class="btn btn-sm btn-outline-success" href="/api/export" download="fw_settings.py">
<i class="bi bi-download me-1"></i>Экспорт fw_settings.py
</a>
<button class="btn btn-sm btn-outline-danger" onclick="App.resetAll()">
<i class="bi bi-trash3 me-1"></i>Сбросить всё
</button>
</div>
</nav>