Files
alpine-router/public/firewall.js
2026-04-13 12:40:49 +03:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── 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();