|
|
@@ -78,6 +78,7 @@ function navigate(tabName) {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
+ if (tabName === 'firewall') loadFirewall();
|
|
|
if (tabName === 'routes') loadRoutes();
|
|
|
if (tabName === 'persistence') loadDebianConfig();
|
|
|
if (tabName === 'pppoe') loadPPPoE();
|
|
|
@@ -93,11 +94,12 @@ async function fetchData(isManual = false) {
|
|
|
if (isManual && spinner) spinner.classList.add('animate-spin');
|
|
|
|
|
|
try {
|
|
|
- const [statusRes, ifacesRes, vlansRes, vpnRes] = await Promise.all([
|
|
|
+ const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes] = await Promise.all([
|
|
|
fetch(`${API_BASE}/system/status`),
|
|
|
fetch(`${API_BASE}/interfaces`),
|
|
|
fetch(`${API_BASE}/vlans`),
|
|
|
- fetch(`${API_BASE}/vpn/status`)
|
|
|
+ fetch(`${API_BASE}/vpn/status`),
|
|
|
+ fetch(`${API_BASE}/firewall/summary`)
|
|
|
]);
|
|
|
|
|
|
if (statusRes.ok) state.systemStatus = await statusRes.json();
|
|
|
@@ -110,6 +112,7 @@ async function fetchData(isManual = false) {
|
|
|
state.vlans = Array.isArray(vlansData) ? vlansData : [];
|
|
|
}
|
|
|
if (vpnRes.ok) state.vpnStatus = await vpnRes.json();
|
|
|
+ if (fwRes && fwRes.ok) state.fwSummary = await fwRes.json();
|
|
|
|
|
|
updateSystemMetrics();
|
|
|
updateBadges();
|
|
|
@@ -117,6 +120,8 @@ async function fetchData(isManual = false) {
|
|
|
if (state.activeTab === 'dashboard') renderDashboard();
|
|
|
if (state.activeTab === 'interfaces') renderInterfacesList();
|
|
|
if (state.activeTab === 'vlans') renderVlansList();
|
|
|
+ if (state.activeTab === 'firewall') loadFirewall(false);
|
|
|
+ if (state.activeTab === 'routes') loadRoutes(false);
|
|
|
if (state.activeTab === 'pppoe') loadPPPoE(false);
|
|
|
if (state.activeTab === 'wireguard') loadWireGuard(false);
|
|
|
if (state.activeTab === 'ipsec') loadIPSec(false);
|
|
|
@@ -210,6 +215,11 @@ function setDotBadge(id, active) {
|
|
|
function updateBadges() {
|
|
|
document.getElementById('badge-ifaces-count').innerText = (state.interfaces || []).length;
|
|
|
document.getElementById('badge-vlans-count').innerText = (state.vlans || []).length;
|
|
|
+ if (state.fwSummary) {
|
|
|
+ const totalFwRules = (state.fwSummary.filter_rules_count || 0) + (state.fwSummary.nat_rules_count || 0) + (state.fwSummary.mangle_rules_count || 0);
|
|
|
+ const badgeFw = document.getElementById('badge-firewall-count');
|
|
|
+ if (badgeFw) badgeFw.innerText = totalFwRules;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// Render Dashboard View
|
|
|
@@ -2022,33 +2032,1307 @@ async function saveInterfaceMetadata() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// ---------------- Routes & Debian Config ----------------
|
|
|
+// ======================== MIKROTIK FIREWALL (/ip firewall) ========================
|
|
|
+state.fwSubTab = 'filter';
|
|
|
+state.routesSubTab = 'routes';
|
|
|
+
|
|
|
+function renderRuleFlag(disabled, hasComment) {
|
|
|
+ let flags = '';
|
|
|
+ if (disabled) flags += '<span class="text-rose-400 font-bold" title="Disabled">X</span>';
|
|
|
+ if (hasComment) flags += '<span class="text-amber-400 font-bold ml-1" title="Has Comment">C</span>';
|
|
|
+ return flags || '<span class="text-slate-600">-</span>';
|
|
|
+}
|
|
|
+
|
|
|
+function renderActionBadge(action) {
|
|
|
+ const act = (action || '').toLowerCase();
|
|
|
+ let color = 'bg-slate-800 text-slate-300';
|
|
|
+ if (act === 'accept') color = 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
|
|
+ else if (act === 'drop' || act === 'reject') color = 'bg-rose-500/20 text-rose-300 border border-rose-500/30';
|
|
|
+ else if (act === 'masquerade' || act === 'src-nat' || act === 'dst-nat') color = 'bg-amber-500/20 text-amber-300 border border-amber-500/30';
|
|
|
+ else if (act.startsWith('mark-')) color = 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
|
|
|
+ else if (act === 'log') color = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30';
|
|
|
+
|
|
|
+ return `<span class="px-2 py-0.5 rounded text-[10px] font-mono font-semibold ${color}">${action}</span>`;
|
|
|
+}
|
|
|
+
|
|
|
+function switchFWSubTab(tab) {
|
|
|
+ state.fwSubTab = tab;
|
|
|
+ const tabs = ['filter', 'nat', 'mangle', 'addrlist', 'connections'];
|
|
|
+ tabs.forEach(t => {
|
|
|
+ const panel = document.getElementById(`fw-subtab-${t}`);
|
|
|
+ const btn = document.getElementById(`fw-subtab-btn-${t}`);
|
|
|
+ if (panel) {
|
|
|
+ if (t === tab) panel.classList.remove('hidden');
|
|
|
+ else panel.classList.add('hidden');
|
|
|
+ }
|
|
|
+ if (btn) {
|
|
|
+ if (t === tab) {
|
|
|
+ btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-emerald-500 text-white transition flex items-center gap-2 whitespace-nowrap';
|
|
|
+ } else {
|
|
|
+ btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const addBtn = document.getElementById('fw-primary-add-btn');
|
|
|
+ if (addBtn) {
|
|
|
+ if (tab === 'filter') addBtn.innerHTML = '<span>+ Add Filter Rule</span>';
|
|
|
+ else if (tab === 'nat') addBtn.innerHTML = '<span>+ Add NAT Rule</span>';
|
|
|
+ else if (tab === 'mangle') addBtn.innerHTML = '<span>+ Add Mangle Rule</span>';
|
|
|
+ else if (tab === 'addrlist') addBtn.innerHTML = '<span>+ Add Address List</span>';
|
|
|
+ else if (tab === 'connections') addBtn.innerHTML = '<span>Refresh Flows</span>';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (tab === 'connections') loadConnections();
|
|
|
+}
|
|
|
+
|
|
|
+function openCurrentFWModal() {
|
|
|
+ if (state.fwSubTab === 'filter') openAddFilterModal();
|
|
|
+ else if (state.fwSubTab === 'nat') openAddNATModal();
|
|
|
+ else if (state.fwSubTab === 'mangle') openAddMangleModal();
|
|
|
+ else if (state.fwSubTab === 'addrlist') openAddAddrListModal();
|
|
|
+ else if (state.fwSubTab === 'connections') loadConnections();
|
|
|
+}
|
|
|
+
|
|
|
+async function loadFirewall(populateDropdowns = true) {
|
|
|
+ try {
|
|
|
+ const [summaryRes, filterRes, natRes, mangleRes, addrlistRes] = await Promise.all([
|
|
|
+ fetch(`${API_BASE}/firewall/summary`),
|
|
|
+ fetch(`${API_BASE}/firewall/filter`),
|
|
|
+ fetch(`${API_BASE}/firewall/nat`),
|
|
|
+ fetch(`${API_BASE}/firewall/mangle`),
|
|
|
+ fetch(`${API_BASE}/firewall/address-lists`)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (summaryRes.ok) state.fwSummary = await summaryRes.json();
|
|
|
+ if (filterRes.ok) state.filterRules = await filterRes.json();
|
|
|
+ if (natRes.ok) state.natRules = await natRes.json();
|
|
|
+ if (mangleRes.ok) state.mangleRules = await mangleRes.json();
|
|
|
+ if (addrlistRes.ok) state.addressLists = await addrlistRes.json();
|
|
|
+
|
|
|
+ // Badges
|
|
|
+ document.getElementById('fw-badge-filter').innerText = (state.filterRules || []).length;
|
|
|
+ document.getElementById('fw-badge-nat').innerText = (state.natRules || []).length;
|
|
|
+ document.getElementById('fw-badge-mangle').innerText = (state.mangleRules || []).length;
|
|
|
+ document.getElementById('fw-badge-addrlist').innerText = (state.addressLists || []).length;
|
|
|
+
|
|
|
+ renderFilterRules();
|
|
|
+ renderNATRules();
|
|
|
+ renderMangleRules();
|
|
|
+ renderAddressLists();
|
|
|
+
|
|
|
+ if (state.fwSubTab === 'connections') {
|
|
|
+ loadConnections();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to load firewall:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Filter Rules -----------------
|
|
|
+function renderFilterRules() {
|
|
|
+ const chainFilter = document.getElementById('filter-filter-chain')?.value || '';
|
|
|
+ const tbody = document.getElementById('filter-table-body');
|
|
|
+ const empty = document.getElementById('filter-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const rules = (state.filterRules || []).filter(r => !chainFilter || r.chain === chainFilter);
|
|
|
+ document.getElementById('filter-count-badge').innerText = `${rules.length} Rules`;
|
|
|
+
|
|
|
+ if (rules.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ rules.forEach((r, idx) => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
|
|
|
+ const ports = [r.src_port ? 's:' + r.src_port : '', r.dst_port ? 'd:' + r.dst_port : ''].filter(Boolean).join(' ');
|
|
|
+ const ifaces = [r.in_interface ? 'in:' + r.in_interface : '', r.out_interface ? 'out:' + r.out_interface : ''].filter(Boolean).join(' ');
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white">${r.chain}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300">${r.src_address || r.src_address_list ? (r.src_address || `list:${r.src_address_list}`) : '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300">${r.dst_address || r.dst_address_list ? (r.dst_address || `list:${r.dst_address_list}`) : '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-amber-400">${ports || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
|
|
|
+ <td class="py-2.5 px-3 text-[11px] text-slate-400">
|
|
|
+ <span class="text-slate-300">${(r.packets || 0).toLocaleString()}p</span> /
|
|
|
+ <span class="text-slate-500">${formatTraffic(r.bytes || 0).replace('/s', '')}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${r.comment || ''}">${r.comment || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ <button onclick="moveFilterRule('${r.id}', 'up')" class="text-xs text-slate-400 hover:text-white px-1">▲</button>
|
|
|
+ <button onclick="moveFilterRule('${r.id}', 'down')" class="text-xs text-slate-400 hover:text-white px-1">▼</button>
|
|
|
+ <button onclick="toggleFilterRuleState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditFilterModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteFilterRule('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function populateInterfaceSelect(selectId, currentVal = '') {
|
|
|
+ const sel = document.getElementById(selectId);
|
|
|
+ if (!sel) return;
|
|
|
+ sel.innerHTML = '<option value="">(Any Interface)</option>';
|
|
|
+ (state.interfaces || []).forEach(i => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = i.name;
|
|
|
+ opt.innerText = `${i.name} (${i.role || i.type})`;
|
|
|
+ if (i.name === currentVal) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function populateAddressListSelect(selectId, currentVal = '') {
|
|
|
+ const sel = document.getElementById(selectId);
|
|
|
+ if (!sel) return;
|
|
|
+ sel.innerHTML = '<option value="">(None)</option>';
|
|
|
+ const set = new Set((state.addressLists || []).map(a => a.name));
|
|
|
+ set.forEach(name => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = name;
|
|
|
+ opt.innerText = name;
|
|
|
+ if (name === currentVal) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddFilterModal() {
|
|
|
+ document.getElementById('filter-modal-title').innerText = 'Add Firewall Filter Rule';
|
|
|
+ document.getElementById('filter-modal-id').value = '';
|
|
|
+ document.getElementById('filter-modal-chain').value = 'forward';
|
|
|
+ document.getElementById('filter-modal-action').value = 'accept';
|
|
|
+ document.getElementById('filter-modal-proto').value = 'all';
|
|
|
+ document.getElementById('filter-modal-src').value = '';
|
|
|
+ document.getElementById('filter-modal-dst').value = '';
|
|
|
+ document.getElementById('filter-modal-sport').value = '';
|
|
|
+ document.getElementById('filter-modal-dport').value = '';
|
|
|
+ document.getElementById('filter-modal-connmark').value = '';
|
|
|
+ document.getElementById('filter-modal-comment').value = '';
|
|
|
+ document.getElementById('filter-modal-disabled').checked = false;
|
|
|
+
|
|
|
+ document.getElementById('filter-modal-cs-established').checked = false;
|
|
|
+ document.getElementById('filter-modal-cs-related').checked = false;
|
|
|
+ document.getElementById('filter-modal-cs-new').checked = false;
|
|
|
+ document.getElementById('filter-modal-cs-invalid').checked = false;
|
|
|
+
|
|
|
+ populateInterfaceSelect('filter-modal-iniface');
|
|
|
+ populateInterfaceSelect('filter-modal-outiface');
|
|
|
+ populateAddressListSelect('filter-modal-srclist');
|
|
|
+ populateAddressListSelect('filter-modal-dstlist');
|
|
|
+ toggleFilterActionFields();
|
|
|
+
|
|
|
+ document.getElementById('filter-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditFilterModal(id) {
|
|
|
+ const r = (state.filterRules || []).find(rule => rule.id === id);
|
|
|
+ if (!r) return;
|
|
|
+
|
|
|
+ document.getElementById('filter-modal-title').innerText = `Edit Filter Rule (#${r.order})`;
|
|
|
+ document.getElementById('filter-modal-id').value = r.id;
|
|
|
+ document.getElementById('filter-modal-chain').value = r.chain || 'forward';
|
|
|
+ document.getElementById('filter-modal-action').value = r.action || 'accept';
|
|
|
+ document.getElementById('filter-modal-proto').value = r.protocol || 'all';
|
|
|
+ document.getElementById('filter-modal-src').value = r.src_address || '';
|
|
|
+ document.getElementById('filter-modal-dst').value = r.dst_address || '';
|
|
|
+ document.getElementById('filter-modal-sport').value = r.src_port || '';
|
|
|
+ document.getElementById('filter-modal-dport').value = r.dst_port || '';
|
|
|
+ document.getElementById('filter-modal-connmark').value = r.connection_mark || '';
|
|
|
+ document.getElementById('filter-modal-comment').value = r.comment || '';
|
|
|
+ document.getElementById('filter-modal-disabled').checked = !!r.disabled;
|
|
|
+
|
|
|
+ const states = r.connection_state || [];
|
|
|
+ document.getElementById('filter-modal-cs-established').checked = states.includes('established');
|
|
|
+ document.getElementById('filter-modal-cs-related').checked = states.includes('related');
|
|
|
+ document.getElementById('filter-modal-cs-new').checked = states.includes('new');
|
|
|
+ document.getElementById('filter-modal-cs-invalid').checked = states.includes('invalid');
|
|
|
+
|
|
|
+ populateInterfaceSelect('filter-modal-iniface', r.in_interface);
|
|
|
+ populateInterfaceSelect('filter-modal-outiface', r.out_interface);
|
|
|
+ populateAddressListSelect('filter-modal-srclist', r.src_address_list);
|
|
|
+ populateAddressListSelect('filter-modal-dstlist', r.dst_address_list);
|
|
|
+ toggleFilterActionFields();
|
|
|
+
|
|
|
+ document.getElementById('filter-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeFilterModal() {
|
|
|
+ document.getElementById('filter-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function toggleFilterActionFields() {
|
|
|
+ const act = document.getElementById('filter-modal-action').value;
|
|
|
+ const rejectBox = document.getElementById('filter-action-reject-box');
|
|
|
+ if (rejectBox) {
|
|
|
+ if (act === 'reject') rejectBox.classList.remove('hidden');
|
|
|
+ else rejectBox.classList.add('hidden');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveFilterRule(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('filter-modal-id').value;
|
|
|
+ const chain = document.getElementById('filter-modal-chain').value;
|
|
|
+ const action = document.getElementById('filter-modal-action').value;
|
|
|
+ const proto = document.getElementById('filter-modal-proto').value;
|
|
|
+ const src = document.getElementById('filter-modal-src').value.trim();
|
|
|
+ const dst = document.getElementById('filter-modal-dst').value.trim();
|
|
|
+ const sport = document.getElementById('filter-modal-sport').value.trim();
|
|
|
+ const dport = document.getElementById('filter-modal-dport').value.trim();
|
|
|
+ const inIface = document.getElementById('filter-modal-iniface').value;
|
|
|
+ const outIface = document.getElementById('filter-modal-outiface').value;
|
|
|
+ const connmark = document.getElementById('filter-modal-connmark').value.trim();
|
|
|
+ const srclist = document.getElementById('filter-modal-srclist').value;
|
|
|
+ const dstlist = document.getElementById('filter-modal-dstlist').value;
|
|
|
+ const comment = document.getElementById('filter-modal-comment').value.trim();
|
|
|
+ const disabled = document.getElementById('filter-modal-disabled').checked;
|
|
|
+ const rejectWith = document.getElementById('filter-modal-rejectwith')?.value || '';
|
|
|
+
|
|
|
+ const states = [];
|
|
|
+ if (document.getElementById('filter-modal-cs-established').checked) states.push('established');
|
|
|
+ if (document.getElementById('filter-modal-cs-related').checked) states.push('related');
|
|
|
+ if (document.getElementById('filter-modal-cs-new').checked) states.push('new');
|
|
|
+ if (document.getElementById('filter-modal-cs-invalid').checked) states.push('invalid');
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/filter`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ id,
|
|
|
+ chain,
|
|
|
+ action,
|
|
|
+ reject_with: rejectWith,
|
|
|
+ protocol: proto,
|
|
|
+ src_address: src,
|
|
|
+ dst_address: dst,
|
|
|
+ src_port: sport,
|
|
|
+ dst_port: dport,
|
|
|
+ in_interface: inIface,
|
|
|
+ out_interface: outIface,
|
|
|
+ connection_state: states,
|
|
|
+ connection_mark: connmark,
|
|
|
+ src_address_list: srclist,
|
|
|
+ dst_address_list: dstlist,
|
|
|
+ comment,
|
|
|
+ disabled
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Filter Rule Saved', 'Rule applied successfully to firewall', 'success');
|
|
|
+ closeFilterModal();
|
|
|
+ loadFirewall(false);
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function moveFilterRule(id, dir) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/move`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ direction: dir })
|
|
|
+ });
|
|
|
+ if (res.ok) loadFirewall(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleFilterRuleState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) loadFirewall(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteFilterRule(id) {
|
|
|
+ if (!confirm('Are you sure you want to delete this filter rule?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', 'Filter rule removed', 'success');
|
|
|
+ loadFirewall(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- NAT Rules -----------------
|
|
|
+function renderNATRules() {
|
|
|
+ const chainFilter = document.getElementById('nat-filter-chain')?.value || '';
|
|
|
+ const tbody = document.getElementById('nat-table-body');
|
|
|
+ const empty = document.getElementById('nat-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const rules = (state.natRules || []).filter(r => !chainFilter || r.chain === chainFilter);
|
|
|
+ document.getElementById('nat-count-badge').innerText = `${rules.length} Rules`;
|
|
|
+
|
|
|
+ if (rules.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ rules.forEach((r, idx) => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
|
|
|
+ const toTarget = [r.to_addresses, r.to_ports].filter(Boolean).join(':');
|
|
|
+ const ifaces = [r.out_interface ? 'out:' + r.out_interface : '', r.in_interface ? 'in:' + r.in_interface : ''].filter(Boolean).join(' ');
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-amber-400">${r.chain}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300">${r.src_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300">${r.dst_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''}</td>
|
|
|
+ <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono font-bold text-amber-300">${toTarget || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-[11px] text-slate-400">
|
|
|
+ <span class="text-slate-300">${(r.packets || 0).toLocaleString()}p</span> /
|
|
|
+ <span class="text-slate-500">${formatTraffic(r.bytes || 0).replace('/s', '')}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${r.comment || ''}">${r.comment || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ <button onclick="moveNATRule('${r.id}', 'up')" class="text-xs text-slate-400 hover:text-white px-1">▲</button>
|
|
|
+ <button onclick="moveNATRule('${r.id}', 'down')" class="text-xs text-slate-400 hover:text-white px-1">▼</button>
|
|
|
+ <button onclick="toggleNATRuleState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditNATModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteNATRule('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddNATModal() {
|
|
|
+ document.getElementById('nat-modal-title').innerText = 'Add NAT Rule';
|
|
|
+ document.getElementById('nat-modal-id').value = '';
|
|
|
+ document.getElementById('nat-modal-chain').value = 'srcnat';
|
|
|
+ document.getElementById('nat-modal-action').value = 'masquerade';
|
|
|
+ document.getElementById('nat-modal-toaddr').value = '';
|
|
|
+ document.getElementById('nat-modal-toport').value = '';
|
|
|
+ document.getElementById('nat-modal-proto').value = 'all';
|
|
|
+ document.getElementById('nat-modal-src').value = '';
|
|
|
+ document.getElementById('nat-modal-dst').value = '';
|
|
|
+ document.getElementById('nat-modal-dport').value = '';
|
|
|
+ document.getElementById('nat-modal-comment').value = '';
|
|
|
+ document.getElementById('nat-modal-disabled').checked = false;
|
|
|
+
|
|
|
+ populateInterfaceSelect('nat-modal-iniface');
|
|
|
+ populateInterfaceSelect('nat-modal-outiface');
|
|
|
+ toggleNATActionFields();
|
|
|
+
|
|
|
+ document.getElementById('nat-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditNATModal(id) {
|
|
|
+ const r = (state.natRules || []).find(rule => rule.id === id);
|
|
|
+ if (!r) return;
|
|
|
+
|
|
|
+ document.getElementById('nat-modal-title').innerText = `Edit NAT Rule (#${r.order})`;
|
|
|
+ document.getElementById('nat-modal-id').value = r.id;
|
|
|
+ document.getElementById('nat-modal-chain').value = r.chain || 'srcnat';
|
|
|
+ document.getElementById('nat-modal-action').value = r.action || 'masquerade';
|
|
|
+ document.getElementById('nat-modal-toaddr').value = r.to_addresses || '';
|
|
|
+ document.getElementById('nat-modal-toport').value = r.to_ports || '';
|
|
|
+ document.getElementById('nat-modal-proto').value = r.protocol || 'all';
|
|
|
+ document.getElementById('nat-modal-src').value = r.src_address || '';
|
|
|
+ document.getElementById('nat-modal-dst').value = r.dst_address || '';
|
|
|
+ document.getElementById('nat-modal-dport').value = r.dst_port || '';
|
|
|
+ document.getElementById('nat-modal-comment').value = r.comment || '';
|
|
|
+ document.getElementById('nat-modal-disabled').checked = !!r.disabled;
|
|
|
+
|
|
|
+ populateInterfaceSelect('nat-modal-iniface', r.in_interface);
|
|
|
+ populateInterfaceSelect('nat-modal-outiface', r.out_interface);
|
|
|
+ toggleNATActionFields();
|
|
|
+
|
|
|
+ document.getElementById('nat-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeNATModal() {
|
|
|
+ document.getElementById('nat-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function toggleNATChainFields() {
|
|
|
+ const ch = document.getElementById('nat-modal-chain').value;
|
|
|
+ const actSel = document.getElementById('nat-modal-action');
|
|
|
+ if (ch === 'srcnat' && actSel.value === 'dst-nat') {
|
|
|
+ actSel.value = 'masquerade';
|
|
|
+ } else if (ch === 'dstnat' && actSel.value === 'masquerade') {
|
|
|
+ actSel.value = 'dst-nat';
|
|
|
+ }
|
|
|
+ toggleNATActionFields();
|
|
|
+}
|
|
|
+
|
|
|
+function toggleNATActionFields() {
|
|
|
+ const act = document.getElementById('nat-modal-action').value;
|
|
|
+ const toBox = document.getElementById('nat-action-to-box');
|
|
|
+ if (toBox) {
|
|
|
+ if (act === 'masquerade' || act === 'accept') toBox.classList.add('hidden');
|
|
|
+ else toBox.classList.remove('hidden');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveNATRule(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('nat-modal-id').value;
|
|
|
+ const chain = document.getElementById('nat-modal-chain').value;
|
|
|
+ const action = document.getElementById('nat-modal-action').value;
|
|
|
+ const toaddr = document.getElementById('nat-modal-toaddr').value.trim();
|
|
|
+ const toport = document.getElementById('nat-modal-toport').value.trim();
|
|
|
+ const proto = document.getElementById('nat-modal-proto').value;
|
|
|
+ const src = document.getElementById('nat-modal-src').value.trim();
|
|
|
+ const dst = document.getElementById('nat-modal-dst').value.trim();
|
|
|
+ const dport = document.getElementById('nat-modal-dport').value.trim();
|
|
|
+ const inIface = document.getElementById('nat-modal-iniface').value;
|
|
|
+ const outIface = document.getElementById('nat-modal-outiface').value;
|
|
|
+ const comment = document.getElementById('nat-modal-comment').value.trim();
|
|
|
+ const disabled = document.getElementById('nat-modal-disabled').checked;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/nat`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ id,
|
|
|
+ chain,
|
|
|
+ action,
|
|
|
+ to_addresses: toaddr,
|
|
|
+ to_ports: toport,
|
|
|
+ protocol: proto,
|
|
|
+ src_address: src,
|
|
|
+ dst_address: dst,
|
|
|
+ dst_port: dport,
|
|
|
+ in_interface: inIface,
|
|
|
+ out_interface: outIface,
|
|
|
+ comment,
|
|
|
+ disabled
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('NAT Rule Saved', 'NAT mapping updated', 'success');
|
|
|
+ closeNATModal();
|
|
|
+ loadFirewall(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function moveNATRule(id, dir) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/move`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ direction: dir })
|
|
|
+ });
|
|
|
+ if (res.ok) loadFirewall(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleNATRuleState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) loadFirewall(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteNATRule(id) {
|
|
|
+ if (!confirm('Are you sure you want to delete this NAT rule?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', 'NAT rule removed', 'success');
|
|
|
+ loadFirewall(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Mangle Rules -----------------
|
|
|
+function renderMangleRules() {
|
|
|
+ const chainFilter = document.getElementById('mangle-filter-chain')?.value || '';
|
|
|
+ const tbody = document.getElementById('mangle-table-body');
|
|
|
+ const empty = document.getElementById('mangle-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const rules = (state.mangleRules || []).filter(r => !chainFilter || r.chain === chainFilter);
|
|
|
+ document.getElementById('mangle-count-badge').innerText = `${rules.length} Rules`;
|
|
|
+
|
|
|
+ if (rules.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ rules.forEach((r, idx) => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
|
|
|
+ const markVal = r.new_connection_mark || r.new_routing_mark || r.new_packet_mark || r.new_mss || (r.new_dscp ? 'DSCP:' + r.new_dscp : '');
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-purple-400">${r.chain}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300">${r.src_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300">${r.dst_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono font-bold text-cyan-300">${markVal || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center">${r.passthrough ? '<span class="text-emerald-400">yes</span>' : '<span class="text-slate-500">no</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-[11px] text-slate-400">
|
|
|
+ <span class="text-slate-300">${(r.packets || 0).toLocaleString()}p</span> /
|
|
|
+ <span class="text-slate-500">${formatTraffic(r.bytes || 0).replace('/s', '')}</span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${r.comment || ''}">${r.comment || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
|
|
|
+ <button onclick="moveMangleRule('${r.id}', 'up')" class="text-xs text-slate-400 hover:text-white px-1">▲</button>
|
|
|
+ <button onclick="moveMangleRule('${r.id}', 'down')" class="text-xs text-slate-400 hover:text-white px-1">▼</button>
|
|
|
+ <button onclick="toggleMangleRuleState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditMangleModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteMangleRule('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddMangleModal() {
|
|
|
+ document.getElementById('mangle-modal-title').innerText = 'Add Mangle Rule';
|
|
|
+ document.getElementById('mangle-modal-id').value = '';
|
|
|
+ document.getElementById('mangle-modal-chain').value = 'prerouting';
|
|
|
+ document.getElementById('mangle-modal-action').value = 'mark-connection';
|
|
|
+ document.getElementById('mangle-modal-newmark').value = '';
|
|
|
+ document.getElementById('mangle-modal-passthrough').checked = true;
|
|
|
+ document.getElementById('mangle-modal-src').value = '';
|
|
|
+ document.getElementById('mangle-modal-dst').value = '';
|
|
|
+ document.getElementById('mangle-modal-connmark').value = '';
|
|
|
+ document.getElementById('mangle-modal-comment').value = '';
|
|
|
+ document.getElementById('mangle-modal-disabled').checked = false;
|
|
|
+
|
|
|
+ populateInterfaceSelect('mangle-modal-iniface');
|
|
|
+ document.getElementById('mangle-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditMangleModal(id) {
|
|
|
+ const r = (state.mangleRules || []).find(rule => rule.id === id);
|
|
|
+ if (!r) return;
|
|
|
+
|
|
|
+ document.getElementById('mangle-modal-title').innerText = `Edit Mangle Rule (#${r.order})`;
|
|
|
+ document.getElementById('mangle-modal-id').value = r.id;
|
|
|
+ document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting';
|
|
|
+ document.getElementById('mangle-modal-action').value = r.action || 'mark-connection';
|
|
|
+ document.getElementById('mangle-modal-newmark').value = r.new_connection_mark || r.new_routing_mark || r.new_packet_mark || r.new_mss || '';
|
|
|
+ document.getElementById('mangle-modal-passthrough').checked = r.passthrough ?? true;
|
|
|
+ document.getElementById('mangle-modal-src').value = r.src_address || '';
|
|
|
+ document.getElementById('mangle-modal-dst').value = r.dst_address || '';
|
|
|
+ document.getElementById('mangle-modal-connmark').value = r.connection_mark || '';
|
|
|
+ document.getElementById('mangle-modal-comment').value = r.comment || '';
|
|
|
+ document.getElementById('mangle-modal-disabled').checked = !!r.disabled;
|
|
|
+
|
|
|
+ populateInterfaceSelect('mangle-modal-iniface', r.in_interface);
|
|
|
+ document.getElementById('mangle-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeMangleModal() {
|
|
|
+ document.getElementById('mangle-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function toggleMangleActionFields() {
|
|
|
+ // Handled smoothly by single dynamic input
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveMangleRule(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('mangle-modal-id').value;
|
|
|
+ const chain = document.getElementById('mangle-modal-chain').value;
|
|
|
+ const action = document.getElementById('mangle-modal-action').value;
|
|
|
+ const newmark = document.getElementById('mangle-modal-newmark').value.trim();
|
|
|
+ const passthrough = document.getElementById('mangle-modal-passthrough').checked;
|
|
|
+ const src = document.getElementById('mangle-modal-src').value.trim();
|
|
|
+ const dst = document.getElementById('mangle-modal-dst').value.trim();
|
|
|
+ const inIface = document.getElementById('mangle-modal-iniface').value;
|
|
|
+ const connmark = document.getElementById('mangle-modal-connmark').value.trim();
|
|
|
+ const comment = document.getElementById('mangle-modal-comment').value.trim();
|
|
|
+ const disabled = document.getElementById('mangle-modal-disabled').checked;
|
|
|
+
|
|
|
+ const payload = {
|
|
|
+ id,
|
|
|
+ chain,
|
|
|
+ action,
|
|
|
+ passthrough,
|
|
|
+ src_address: src,
|
|
|
+ dst_address: dst,
|
|
|
+ in_interface: inIface,
|
|
|
+ connection_mark: connmark,
|
|
|
+ comment,
|
|
|
+ disabled
|
|
|
+ };
|
|
|
+
|
|
|
+ if (action === 'mark-connection') payload.new_connection_mark = newmark;
|
|
|
+ else if (action === 'mark-routing') payload.new_routing_mark = newmark;
|
|
|
+ else if (action === 'mark-packet') payload.new_packet_mark = newmark;
|
|
|
+ else if (action === 'change-mss') payload.new_mss = newmark || 'clamp-to-pmtu';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/mangle`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify(payload)
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Mangle Rule Saved', 'Marking rule applied', 'success');
|
|
|
+ closeMangleModal();
|
|
|
+ loadFirewall(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function moveMangleRule(id, dir) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/move`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ direction: dir })
|
|
|
+ });
|
|
|
+ if (res.ok) loadFirewall(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleMangleRuleState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) loadFirewall(false);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteMangleRule(id) {
|
|
|
+ if (!confirm('Are you sure you want to delete this mangle rule?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', 'Mangle rule removed', 'success');
|
|
|
+ loadFirewall(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Address Lists -----------------
|
|
|
+function renderAddressLists() {
|
|
|
+ const filterList = document.getElementById('addrlist-filter-name')?.value || '';
|
|
|
+ const tbody = document.getElementById('addrlist-table-body');
|
|
|
+ const empty = document.getElementById('addrlist-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const entries = (state.addressLists || []).filter(e => !filterList || e.name === filterList);
|
|
|
+ document.getElementById('addrlist-count-badge').innerText = `${entries.length} Entries`;
|
|
|
+
|
|
|
+ // Update filter select
|
|
|
+ const sel = document.getElementById('addrlist-filter-name');
|
|
|
+ if (sel) {
|
|
|
+ const cur = sel.value;
|
|
|
+ sel.innerHTML = '<option value="">All Lists</option>';
|
|
|
+ const set = new Set((state.addressLists || []).map(a => a.name));
|
|
|
+ set.forEach(n => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = n;
|
|
|
+ opt.innerText = n;
|
|
|
+ if (n === cur) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (entries.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ entries.forEach((e, idx) => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${e.disabled ? 'opacity-50' : ''}`;
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${idx + 1}</td>
|
|
|
+ <td class="py-2.5 px-3 text-center">${e.disabled ? '<span class="text-rose-400 font-bold">X</span>' : '<span class="text-slate-600">-</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white font-mono">${e.name}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono font-bold text-emerald-400">${e.address}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic">${e.comment || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right font-sans">
|
|
|
+ <button onclick="deleteAddrList('${e.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddAddrListModal() {
|
|
|
+ document.getElementById('addrlist-modal-name').value = '';
|
|
|
+ document.getElementById('addrlist-modal-addr').value = '';
|
|
|
+ document.getElementById('addrlist-modal-comment').value = '';
|
|
|
+ document.getElementById('addrlist-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeAddrListModal() {
|
|
|
+ document.getElementById('addrlist-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveAddrList(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const name = document.getElementById('addrlist-modal-name').value.trim();
|
|
|
+ const addr = document.getElementById('addrlist-modal-addr').value.trim();
|
|
|
+ const comment = document.getElementById('addrlist-modal-comment').value.trim();
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/address-lists`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ name, address: addr, comment, disabled: false })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Address List Item Added', `Added ${addr} to ${name}`, 'success');
|
|
|
+ closeAddrListModal();
|
|
|
+ loadFirewall(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteAddrList(id) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/address-lists/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', 'Address list item removed', 'info');
|
|
|
+ loadFirewall(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Active Connections Tracking -----------------
|
|
|
+async function loadConnections() {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/connections`);
|
|
|
+ const data = await res.json();
|
|
|
+ state.connections = Array.isArray(data) ? data : [];
|
|
|
+ document.getElementById('fw-badge-connections').innerText = state.connections.length;
|
|
|
+ document.getElementById('conns-count-badge').innerText = `${state.connections.length} Flows`;
|
|
|
+ renderConnections();
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to load connections:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderConnections() {
|
|
|
+ const search = (document.getElementById('conns-search-input')?.value || '').toLowerCase();
|
|
|
+ const protoFilter = document.getElementById('conns-filter-proto')?.value || '';
|
|
|
+ const tbody = document.getElementById('conns-table-body');
|
|
|
+ const empty = document.getElementById('conns-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const conns = (state.connections || []).filter(c => {
|
|
|
+ if (protoFilter && c.protocol !== protoFilter) return false;
|
|
|
+ if (search) {
|
|
|
+ const line = `${c.protocol} ${c.src_address} ${c.dst_address} ${c.src_port} ${c.dst_port} ${c.tcp_state} ${c.connection_mark}`.toLowerCase();
|
|
|
+ if (!line.includes(search)) return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+
|
|
|
+ if (conns.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ conns.slice(0, 150).forEach(c => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition text-xs';
|
|
|
+ let stateColor = 'text-slate-400';
|
|
|
+ if (c.tcp_state === 'ESTABLISHED') stateColor = 'text-emerald-400 font-bold';
|
|
|
+ else if (c.tcp_state === 'TIME_WAIT' || c.tcp_state === 'SYN_SENT') stateColor = 'text-amber-400';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 font-bold text-cyan-400">${c.protocol}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-white">${c.src_address}:${c.src_port}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-white">${c.dst_address}:${c.dst_port}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-slate-400">${c.reply_src_address ? c.reply_src_address + ':' + c.reply_src_port : '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-slate-400">${c.reply_dst_address ? c.reply_dst_address + ':' + c.reply_dst_port : '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono ${stateColor}">${c.tcp_state}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-purple-300">${c.connection_mark || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-500">${c.timeout ? c.timeout + 's' : '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right font-sans">
|
|
|
+ <button onclick="killConnection('${c.protocol}', '${c.src_address}', ${c.src_port}, '${c.dst_address}', ${c.dst_port})" class="text-xs text-rose-400 hover:text-rose-300">Kill</button>
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function killConnection(protocol, src, sport, dst, dport) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/connections/kill`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ protocol, src, sport, dst, dport })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Terminated', `Killed flow ${src}:${sport} -> ${dst}:${dport}`, 'info');
|
|
|
+ loadConnections();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function resetFirewallCounters() {
|
|
|
+ if (!confirm('Reset all firewall packet and byte counters to zero?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/firewall/reset-counters`, { method: 'POST' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Counters Reset', 'All rules counters reset to 0', 'success');
|
|
|
+ loadFirewall(false);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ======================== MIKROTIK ROUTING & POLICY (/ip route) ========================
|
|
|
+function switchRoutesSubTab(tab) {
|
|
|
+ state.routesSubTab = tab;
|
|
|
+ const tabs = ['routes', 'rules', 'tables'];
|
|
|
+ tabs.forEach(t => {
|
|
|
+ const panel = document.getElementById(`routes-subtab-${t}`);
|
|
|
+ const btn = document.getElementById(`routes-subtab-btn-${t}`);
|
|
|
+ if (panel) {
|
|
|
+ if (t === tab) panel.classList.remove('hidden');
|
|
|
+ else panel.classList.add('hidden');
|
|
|
+ }
|
|
|
+ if (btn) {
|
|
|
+ if (t === tab) {
|
|
|
+ btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-cyan-500 text-white transition flex items-center gap-2';
|
|
|
+ } else {
|
|
|
+ btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
async function loadRoutes() {
|
|
|
+ try {
|
|
|
+ const [routesRes, rulesRes, tablesRes] = await Promise.all([
|
|
|
+ fetch(`${API_BASE}/routes`),
|
|
|
+ fetch(`${API_BASE}/routing/rules`),
|
|
|
+ fetch(`${API_BASE}/routing/tables`)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (routesRes.ok) state.routes = await routesRes.json();
|
|
|
+ if (rulesRes.ok) state.policyRules = await rulesRes.json();
|
|
|
+ if (tablesRes.ok) state.routingTables = await tablesRes.json();
|
|
|
+
|
|
|
+ document.getElementById('routes-badge-routes').innerText = (state.routes || []).length;
|
|
|
+ document.getElementById('routes-badge-rules').innerText = (state.policyRules || []).length;
|
|
|
+ document.getElementById('routes-badge-tables').innerText = (state.routingTables || []).length;
|
|
|
+ document.getElementById('badge-routes-count').innerText = (state.routes || []).length;
|
|
|
+
|
|
|
+ renderRoutesList();
|
|
|
+ renderPolicyRules();
|
|
|
+ renderRoutingTables();
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to load routing:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderRoutesList() {
|
|
|
+ const tableFilter = document.getElementById('routes-filter-table')?.value || '';
|
|
|
const tbody = document.getElementById('routes-table-body');
|
|
|
- tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-slate-500 font-sans">Loading routes...</td></tr>`;
|
|
|
+ const empty = document.getElementById('routes-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ // Update table filter select
|
|
|
+ const sel = document.getElementById('routes-filter-table');
|
|
|
+ if (sel) {
|
|
|
+ const cur = sel.value;
|
|
|
+ sel.innerHTML = '<option value="">All Tables</option>';
|
|
|
+ (state.routingTables || []).forEach(t => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = t.name;
|
|
|
+ opt.innerText = t.name;
|
|
|
+ if (t.name === cur) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const routes = (state.routes || []).filter(r => !tableFilter || r.routing_table === tableFilter);
|
|
|
+ document.getElementById('routes-count-badge').innerText = `${routes.length} Routes`;
|
|
|
+
|
|
|
+ if (routes.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ routes.forEach(r => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
|
|
|
+
|
|
|
+ // MikroTik Flags string: D=Dynamic, A=Active, C=Connect, S=Static, X=Disabled
|
|
|
+ let flags = '';
|
|
|
+ if (r.dynamic) flags += 'D';
|
|
|
+ if (r.active) flags += 'A';
|
|
|
+ if (r.connect) flags += 'C';
|
|
|
+ if (r.static) flags += 'S';
|
|
|
+ if (r.disabled) flags += 'X';
|
|
|
+
|
|
|
+ const isDefault = r.dst_address === 'default' || r.dst_address === '0.0.0.0/0';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-center">
|
|
|
+ <span class="px-1.5 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-800 ${r.active ? 'text-cyan-400 border border-cyan-800/40' : 'text-slate-500'}">
|
|
|
+ ${flags || '-'}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td class="py-2.5 px-3 font-bold ${isDefault ? 'text-cyan-300' : 'text-white'}">${r.dst_address}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-300 font-mono">${r.gateway || '<span class="text-slate-600 font-sans">Direct link</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 font-mono">${r.distance || 1}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono font-semibold text-purple-300">${r.routing_table || 'main'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-emerald-400 font-mono">${r.interface || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[140px]">${r.comment || '-'}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right space-x-2 font-sans whitespace-nowrap">
|
|
|
+ ${r.static ? `
|
|
|
+ <button onclick="toggleRouteState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
|
|
|
+ <button onclick="openEditRouteModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
|
|
|
+ <button onclick="deleteRoute('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ ` : `<span class="text-slate-600 text-[10px]">Kernel</span>`}
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddRouteModal() {
|
|
|
+ document.getElementById('route-modal-title').innerText = 'Add Static Route';
|
|
|
+ document.getElementById('route-modal-id').value = '';
|
|
|
+ document.getElementById('route-modal-dst').value = '0.0.0.0/0';
|
|
|
+ document.getElementById('route-modal-gw').value = '';
|
|
|
+ document.getElementById('route-modal-distance').value = '1';
|
|
|
+ document.getElementById('route-modal-comment').value = '';
|
|
|
+ document.getElementById('route-modal-disabled').checked = false;
|
|
|
+
|
|
|
+ populateInterfaceSelect('route-modal-iface');
|
|
|
+
|
|
|
+ // Populate tables
|
|
|
+ const sel = document.getElementById('route-modal-table');
|
|
|
+ sel.innerHTML = '';
|
|
|
+ (state.routingTables || [{ name: 'main' }]).forEach(t => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = t.name;
|
|
|
+ opt.innerText = t.name;
|
|
|
+ if (t.name === 'main') opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('route-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function openEditRouteModal(id) {
|
|
|
+ const r = (state.routes || []).find(route => route.id === id);
|
|
|
+ if (!r) return;
|
|
|
+
|
|
|
+ document.getElementById('route-modal-title').innerText = `Edit Route: ${r.dst_address}`;
|
|
|
+ document.getElementById('route-modal-id').value = r.id;
|
|
|
+ document.getElementById('route-modal-dst').value = r.dst_address;
|
|
|
+ document.getElementById('route-modal-gw').value = r.gateway || '';
|
|
|
+ document.getElementById('route-modal-distance').value = r.distance || 1;
|
|
|
+ document.getElementById('route-modal-comment').value = r.comment || '';
|
|
|
+ document.getElementById('route-modal-disabled').checked = !!r.disabled;
|
|
|
+
|
|
|
+ populateInterfaceSelect('route-modal-iface', r.interface);
|
|
|
+
|
|
|
+ const sel = document.getElementById('route-modal-table');
|
|
|
+ sel.innerHTML = '';
|
|
|
+ (state.routingTables || [{ name: 'main' }]).forEach(t => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = t.name;
|
|
|
+ opt.innerText = t.name;
|
|
|
+ if (t.name === r.routing_table) opt.selected = true;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('route-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeRouteModal() {
|
|
|
+ document.getElementById('route-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveRoute(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const id = document.getElementById('route-modal-id').value;
|
|
|
+ const dst = document.getElementById('route-modal-dst').value.trim();
|
|
|
+ const gw = document.getElementById('route-modal-gw').value.trim();
|
|
|
+ const table = document.getElementById('route-modal-table').value;
|
|
|
+ const distance = parseInt(document.getElementById('route-modal-distance').value, 10) || 1;
|
|
|
+ const iface = document.getElementById('route-modal-iface').value;
|
|
|
+ const comment = document.getElementById('route-modal-comment').value.trim();
|
|
|
+ const disabled = document.getElementById('route-modal-disabled').checked;
|
|
|
+
|
|
|
try {
|
|
|
- const res = await fetch(`${API_BASE}/routes`);
|
|
|
- const routes = await res.json();
|
|
|
- tbody.innerHTML = '';
|
|
|
- if (!routes || routes.length === 0) {
|
|
|
- tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-slate-500 font-sans">No active routes found.</td></tr>`;
|
|
|
- return;
|
|
|
+ const res = await fetch(`${API_BASE}/routes`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ id,
|
|
|
+ dst_address: dst,
|
|
|
+ gateway: gw,
|
|
|
+ routing_table: table,
|
|
|
+ distance,
|
|
|
+ interface: iface,
|
|
|
+ comment,
|
|
|
+ disabled
|
|
|
+ })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Route Saved', `Route to ${dst} installed`, 'success');
|
|
|
+ closeRouteModal();
|
|
|
+ loadRoutes();
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
}
|
|
|
- routes.forEach(r => {
|
|
|
- const isDefault = r.destination === 'default' || r.destination === '0.0.0.0/0';
|
|
|
- const tr = document.createElement('tr');
|
|
|
- tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs';
|
|
|
- tr.innerHTML = `
|
|
|
- <td class="py-3 px-4 font-bold ${isDefault ? 'text-brand-400' : 'text-white'}">${r.destination}</td>
|
|
|
- <td class="py-3 px-4 text-slate-300">${r.gateway || '<span class="text-slate-600 font-sans">On-link</span>'}</td>
|
|
|
- <td class="py-3 px-4 text-white font-medium">${r.interface || '-'}</td>
|
|
|
- <td class="py-3 px-4 text-slate-400 font-sans">${r.protocol}</td>
|
|
|
- <td class="py-3 px-4 text-right text-slate-400 font-sans">${r.scope}</td>
|
|
|
- `;
|
|
|
- tbody.appendChild(tr);
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleRouteState(id, disabled) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/routes/${encodeURIComponent(id)}/state`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ disabled })
|
|
|
+ });
|
|
|
+ if (res.ok) loadRoutes();
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteRoute(id) {
|
|
|
+ if (!confirm('Are you sure you want to delete this route?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/routes/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Deleted', 'Route removed', 'success');
|
|
|
+ loadRoutes();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Policy Rules (/ip route rule) -----------------
|
|
|
+function renderPolicyRules() {
|
|
|
+ const tbody = document.getElementById('rules-table-body');
|
|
|
+ const empty = document.getElementById('rules-empty');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ const rules = state.policyRules || [];
|
|
|
+ document.getElementById('rules-count-badge').innerText = `${rules.length} Rules`;
|
|
|
+
|
|
|
+ if (rules.length === 0) {
|
|
|
+ empty?.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ empty?.classList.add('hidden');
|
|
|
+
|
|
|
+ rules.forEach(pr => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition text-xs';
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 font-mono text-cyan-400 font-bold">${pr.priority}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-white">${pr.src_address || '<span class="text-slate-600">all</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-white">${pr.dst_address || '<span class="text-slate-600">all</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono font-bold text-purple-300">${pr.routing_mark || '<span class="text-slate-600">-</span>'}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono font-bold text-amber-300">${pr.table}</td>
|
|
|
+ <td class="py-2.5 px-3 font-mono text-slate-300">${pr.action}</td>
|
|
|
+ <td class="py-2.5 px-3 text-right font-sans">
|
|
|
+ ${pr.priority < 32766 && pr.priority > 0 ? `
|
|
|
+ <button onclick="deletePolicyRule('${pr.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
|
|
|
+ ` : `<span class="text-slate-600 text-[10px]">Default</span>`}
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddPolicyRuleModal() {
|
|
|
+ document.getElementById('prule-modal-pref').value = (state.policyRules || []).length * 10 + 1000;
|
|
|
+ document.getElementById('prule-modal-mark').value = '';
|
|
|
+ document.getElementById('prule-modal-src').value = '';
|
|
|
+ document.getElementById('prule-modal-dst').value = '';
|
|
|
+
|
|
|
+ const sel = document.getElementById('prule-modal-table');
|
|
|
+ sel.innerHTML = '';
|
|
|
+ (state.routingTables || []).forEach(t => {
|
|
|
+ const opt = document.createElement('option');
|
|
|
+ opt.value = t.name;
|
|
|
+ opt.innerText = t.name;
|
|
|
+ sel.appendChild(opt);
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('prule-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closePolicyRuleModal() {
|
|
|
+ document.getElementById('prule-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSavePolicyRule(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const pref = parseInt(document.getElementById('prule-modal-pref').value, 10) || 1000;
|
|
|
+ const mark = document.getElementById('prule-modal-mark').value.trim();
|
|
|
+ const src = document.getElementById('prule-modal-src').value.trim();
|
|
|
+ const dst = document.getElementById('prule-modal-dst').value.trim();
|
|
|
+ const table = document.getElementById('prule-modal-table').value;
|
|
|
+ const action = document.getElementById('prule-modal-action').value;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/routing/rules`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ priority: pref, routing_mark: mark, src_address: src, dst_address: dst, table, action })
|
|
|
});
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Rule Added', `Policy rule installed for table ${table}`, 'success');
|
|
|
+ closePolicyRuleModal();
|
|
|
+ loadRoutes();
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
} catch (err) {
|
|
|
- tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-rose-400 font-sans">Failed to load routes.</td></tr>`;
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deletePolicyRule(id) {
|
|
|
+ if (!confirm('Are you sure you want to remove this policy rule?')) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/routing/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Rule Removed', 'Policy rule deleted', 'success');
|
|
|
+ loadRoutes();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ----------------- Routing Tables -----------------
|
|
|
+function renderRoutingTables() {
|
|
|
+ const tbody = document.getElementById('tables-table-body');
|
|
|
+ if (!tbody) return;
|
|
|
+ tbody.innerHTML = '';
|
|
|
+
|
|
|
+ (state.routingTables || []).forEach(t => {
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition text-xs font-mono';
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-2.5 px-3 text-cyan-400 font-bold">${t.table_id}</td>
|
|
|
+ <td class="py-2.5 px-3 font-bold text-white">${t.name}</td>
|
|
|
+ <td class="py-2.5 px-3 text-slate-400 italic">${t.comment || '-'}</td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddTableModal() {
|
|
|
+ document.getElementById('table-modal-name').value = '';
|
|
|
+ document.getElementById('table-modal-id').value = '';
|
|
|
+ document.getElementById('table-modal-comment').value = '';
|
|
|
+ document.getElementById('table-modal').classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeTableModal() {
|
|
|
+ document.getElementById('table-modal').classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSaveTable(e) {
|
|
|
+ e.preventDefault();
|
|
|
+ const name = document.getElementById('table-modal-name').value.trim();
|
|
|
+ const id = parseInt(document.getElementById('table-modal-id').value, 10) || 0;
|
|
|
+ const comment = document.getElementById('table-modal-comment').value.trim();
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/routing/tables`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ name, table_id: id, comment })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Table Created', `Routing table ${name} registered`, 'success');
|
|
|
+ closeTableModal();
|
|
|
+ loadRoutes();
|
|
|
+ } else {
|
|
|
+ const err = await res.json();
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
}
|
|
|
}
|
|
|
|