331 lines
13 KiB
JavaScript
331 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
|
|
const state = {
|
|
rules: [], // current rule list (order matters)
|
|
interfaces: [], // available interface names for autocomplete
|
|
editIdx: -1, // index in state.rules being edited (-1 = new)
|
|
};
|
|
|
|
// ── API helpers ──────────────────────────────────────────────────────────────
|
|
|
|
async function api(method, path, body) {
|
|
const res = await fetch(path, {
|
|
method,
|
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const json = await res.json();
|
|
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
|
return json.data;
|
|
}
|
|
const get = p => api('GET', p);
|
|
const post = (p, b) => api('POST', p, b);
|
|
|
|
// ── Load ─────────────────────────────────────────────────────────────────────
|
|
|
|
async function loadAll() {
|
|
try {
|
|
const data = await get('/api/firewall');
|
|
state.rules = data.rules || [];
|
|
state.interfaces = data.interfaces || [];
|
|
document.getElementById('vlanIsolation').checked = !!data.vlan_isolation;
|
|
|
|
const notInstalled = document.getElementById('notInstalledBanner');
|
|
if (!data.installed) {
|
|
notInstalled.classList.remove('hidden');
|
|
} else {
|
|
notInstalled.classList.add('hidden');
|
|
}
|
|
|
|
// Populate datalist for interface autocomplete.
|
|
const dl = document.getElementById('ifaceList');
|
|
dl.innerHTML = state.interfaces.map(n => `<option value="${n}">`).join('');
|
|
|
|
renderRules();
|
|
} catch (e) {
|
|
showToast('Ошибка загрузки: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Render ───────────────────────────────────────────────────────────────────
|
|
|
|
const ACTION_LABELS = { accept: 'Разрешить', drop: 'Запретить', reject: 'Отклонить' };
|
|
const ACTION_CLASS = { accept: 'fw-accept', drop: 'fw-drop', reject: 'fw-reject' };
|
|
const PROTO_LABELS = { all: 'Любой', tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
|
|
|
|
function addrPort(addr, port) {
|
|
if (!addr && !port) return '<span class="none">любой</span>';
|
|
const a = addr || '<span class="none">*</span>';
|
|
const p = port ? `:<b>${port}</b>` : '';
|
|
return a + p;
|
|
}
|
|
|
|
function renderRules() {
|
|
const tbody = document.getElementById('rulesTbody');
|
|
const emptyRow = document.getElementById('emptyRow');
|
|
|
|
// Remove all rows except the empty placeholder.
|
|
[...tbody.querySelectorAll('.fw-row')].forEach(r => r.remove());
|
|
|
|
if (state.rules.length === 0) {
|
|
emptyRow.classList.remove('hidden');
|
|
return;
|
|
}
|
|
emptyRow.classList.add('hidden');
|
|
|
|
state.rules.forEach((rule, idx) => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'fw-row' + (rule.enabled ? '' : ' fw-row-disabled');
|
|
tr.draggable = true;
|
|
tr.dataset.ruleId = rule.id || String(idx);
|
|
|
|
const inIface = rule.in_iface || '<span class="none">—</span>';
|
|
const outIface = rule.out_iface || '<span class="none">—</span>';
|
|
|
|
tr.innerHTML = `
|
|
<td class="col-drag"><span class="drag-handle" title="Перетащить">⠿</span></td>
|
|
<td class="col-num">${idx + 1}</td>
|
|
<td class="col-en">
|
|
<label class="mini-toggle" title="${rule.enabled ? 'Отключить' : 'Включить'}">
|
|
<input type="checkbox" data-idx="${idx}" class="rule-toggle" ${rule.enabled ? 'checked' : ''}>
|
|
<span class="mini-slider"></span>
|
|
</label>
|
|
</td>
|
|
<td class="col-action"><span class="action-badge ${ACTION_CLASS[rule.action] || 'fw-drop'}">${ACTION_LABELS[rule.action] || rule.action}</span></td>
|
|
<td class="col-proto">${PROTO_LABELS[rule.protocol] || rule.protocol || 'Любой'}</td>
|
|
<td class="col-iface">${inIface}</td>
|
|
<td class="col-iface">${outIface}</td>
|
|
<td class="col-addr">${addrPort(rule.src_addr, rule.src_port)}</td>
|
|
<td class="col-addr">${addrPort(rule.dst_addr, rule.dst_port)}</td>
|
|
<td class="col-comment">${rule.comment ? `<span class="fw-comment">${esc(rule.comment)}</span>` : '<span class="none">—</span>'}</td>
|
|
<td class="col-btns">
|
|
<button class="btn btn-ghost btn-xs rule-edit" data-idx="${idx}" title="Редактировать">✎</button>
|
|
<button class="btn btn-danger btn-xs rule-del" data-idx="${idx}" title="Удалить">✕</button>
|
|
</td>
|
|
`;
|
|
|
|
addDragHandlers(tr);
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function esc(s) {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ── Drag-to-reorder ───────────────────────────────────────────────────────────
|
|
|
|
let dragSrc = null;
|
|
|
|
function addDragHandlers(row) {
|
|
row.addEventListener('dragstart', e => {
|
|
dragSrc = row;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
setTimeout(() => row.classList.add('dragging'), 0);
|
|
});
|
|
row.addEventListener('dragend', () => {
|
|
row.classList.remove('dragging');
|
|
document.querySelectorAll('.fw-row').forEach(r => r.classList.remove('drag-over'));
|
|
commitDragOrder();
|
|
dragSrc = null;
|
|
});
|
|
row.addEventListener('dragover', e => {
|
|
e.preventDefault();
|
|
if (!dragSrc || dragSrc === row) return;
|
|
document.querySelectorAll('.fw-row').forEach(r => r.classList.remove('drag-over'));
|
|
row.classList.add('drag-over');
|
|
});
|
|
row.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
if (!dragSrc || dragSrc === row) return;
|
|
const tbody = row.parentNode;
|
|
const rows = [...tbody.querySelectorAll('.fw-row')];
|
|
const si = rows.indexOf(dragSrc);
|
|
const ti = rows.indexOf(row);
|
|
if (si < ti) {
|
|
tbody.insertBefore(dragSrc, row.nextSibling);
|
|
} else {
|
|
tbody.insertBefore(dragSrc, row);
|
|
}
|
|
});
|
|
}
|
|
|
|
function commitDragOrder() {
|
|
const rows = [...document.querySelectorAll('.fw-row')];
|
|
const byId = {};
|
|
state.rules.forEach(r => { byId[r.id] = r; });
|
|
|
|
const newRules = [];
|
|
rows.forEach((row, i) => {
|
|
const id = row.dataset.ruleId;
|
|
const r = byId[id] || state.rules[parseInt(id)];
|
|
if (r) newRules.push(r);
|
|
});
|
|
state.rules = newRules;
|
|
renderRules();
|
|
}
|
|
|
|
// ── Modal ─────────────────────────────────────────────────────────────────────
|
|
|
|
function openModal(idx) {
|
|
state.editIdx = idx;
|
|
const isNew = idx === -1;
|
|
document.getElementById('ruleModalTitle').textContent = isNew ? 'Добавить правило' : 'Редактировать правило';
|
|
|
|
const rule = isNew ? { enabled: true, action: 'accept', protocol: 'all' } : state.rules[idx];
|
|
|
|
document.getElementById('rEnabled').checked = rule.enabled !== false;
|
|
document.getElementById('rComment').value = rule.comment || '';
|
|
document.getElementById('rSrcAddr').value = rule.src_addr || '';
|
|
document.getElementById('rSrcPort').value = rule.src_port || '';
|
|
document.getElementById('rDstAddr').value = rule.dst_addr || '';
|
|
document.getElementById('rDstPort').value = rule.dst_port || '';
|
|
document.getElementById('rInIface').value = rule.in_iface || '';
|
|
document.getElementById('rOutIface').value = rule.out_iface || '';
|
|
|
|
setSegmented('actionSwitch', rule.action || 'accept');
|
|
setSegmented('protoSwitch', rule.protocol || 'all');
|
|
updatePortFields(rule.protocol || 'all');
|
|
|
|
document.getElementById('ruleModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('ruleModal').classList.add('hidden');
|
|
document.getElementById('ruleForm').reset();
|
|
}
|
|
|
|
function setSegmented(id, val) {
|
|
document.querySelectorAll(`#${id} .seg-btn`).forEach(b => {
|
|
b.classList.toggle('active', b.dataset.val === val);
|
|
});
|
|
}
|
|
|
|
function getSegmented(id) {
|
|
return document.querySelector(`#${id} .seg-btn.active`)?.dataset.val ?? '';
|
|
}
|
|
|
|
function updatePortFields(proto) {
|
|
const show = proto === 'tcp' || proto === 'udp';
|
|
document.getElementById('portFields').classList.toggle('hidden', !show);
|
|
}
|
|
|
|
function saveRule() {
|
|
const rule = {
|
|
id: state.editIdx === -1 ? genId() : (state.rules[state.editIdx]?.id || genId()),
|
|
enabled: document.getElementById('rEnabled').checked,
|
|
action: getSegmented('actionSwitch'),
|
|
protocol: getSegmented('protoSwitch'),
|
|
src_addr: document.getElementById('rSrcAddr').value.trim(),
|
|
src_port: document.getElementById('rSrcPort').value.trim(),
|
|
dst_addr: document.getElementById('rDstAddr').value.trim(),
|
|
dst_port: document.getElementById('rDstPort').value.trim(),
|
|
in_iface: document.getElementById('rInIface').value.trim(),
|
|
out_iface: document.getElementById('rOutIface').value.trim(),
|
|
comment: document.getElementById('rComment').value.trim(),
|
|
};
|
|
|
|
if (!rule.action) { showToast('Выберите действие', 'error'); return; }
|
|
|
|
if (state.editIdx === -1) {
|
|
state.rules.push(rule);
|
|
} else {
|
|
state.rules[state.editIdx] = rule;
|
|
}
|
|
|
|
closeModal();
|
|
renderRules();
|
|
}
|
|
|
|
function genId() {
|
|
return Math.random().toString(36).slice(2, 10);
|
|
}
|
|
|
|
// ── Save & Apply ──────────────────────────────────────────────────────────────
|
|
|
|
async function saveAndApply() {
|
|
const btn = document.getElementById('applyBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Применяю...';
|
|
|
|
try {
|
|
await post('/api/firewall', {
|
|
rules: state.rules,
|
|
vlan_isolation: document.getElementById('vlanIsolation').checked,
|
|
});
|
|
await post('/api/firewall/apply');
|
|
showToast('Правила файрвола применены', 'success');
|
|
} catch (e) {
|
|
showToast('Ошибка: ' + e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Сохранить и применить';
|
|
}
|
|
}
|
|
|
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
|
|
let toastTimer;
|
|
function showToast(msg, type = 'info') {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.className = `toast ${type}`;
|
|
t.classList.remove('hidden');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
|
}
|
|
|
|
// ── Event wiring ──────────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
|
document.getElementById('applyBtn').addEventListener('click', saveAndApply);
|
|
document.getElementById('addRuleBtn').addEventListener('click', () => openModal(-1));
|
|
|
|
// Table events (delegated)
|
|
document.getElementById('rulesTbody').addEventListener('click', e => {
|
|
const editBtn = e.target.closest('.rule-edit');
|
|
const delBtn = e.target.closest('.rule-del');
|
|
if (editBtn) { openModal(parseInt(editBtn.dataset.idx)); return; }
|
|
if (delBtn) {
|
|
const idx = parseInt(delBtn.dataset.idx);
|
|
if (confirm(`Удалить правило ${idx + 1}?`)) {
|
|
state.rules.splice(idx, 1);
|
|
renderRules();
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
document.getElementById('rulesTbody').addEventListener('change', e => {
|
|
const toggle = e.target.closest('.rule-toggle');
|
|
if (toggle) {
|
|
const idx = parseInt(toggle.dataset.idx);
|
|
state.rules[idx].enabled = toggle.checked;
|
|
renderRules();
|
|
}
|
|
});
|
|
|
|
// Modal
|
|
document.getElementById('closeRuleModal').addEventListener('click', closeModal);
|
|
document.getElementById('cancelRuleBtn').addEventListener('click', closeModal);
|
|
document.getElementById('ruleModalBackdrop').addEventListener('click', closeModal);
|
|
document.getElementById('saveRuleBtn').addEventListener('click', saveRule);
|
|
document.getElementById('ruleForm').addEventListener('submit', e => { e.preventDefault(); saveRule(); });
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
|
|
|
// Segmented switchers
|
|
document.getElementById('actionSwitch').addEventListener('click', e => {
|
|
const b = e.target.closest('.seg-btn');
|
|
if (b) setSegmented('actionSwitch', b.dataset.val);
|
|
});
|
|
document.getElementById('protoSwitch').addEventListener('click', e => {
|
|
const b = e.target.closest('.seg-btn');
|
|
if (b) { setSegmented('protoSwitch', b.dataset.val); updatePortFields(b.dataset.val); }
|
|
});
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
loadAll();
|