// Proxmox RouterOS Frontend Application Logic let state = { interfaces: [], vlans: [], systemStatus: null, vpnStatus: null, routes: [], activeTab: 'dashboard', fwSubTab: 'filter', routesSubTab: 'routes', ifaceFilter: 'all', currentModalIface: null, isPolling: true, expandedParents: new Set(['eth0']), pppoeData: null, wgData: null, ipsecData: null, l2tpData: null, pptpData: null, fwSummary: null, filterRules: [], natRules: [], mangleRules: [], addressLists: [], connections: [], policyRules: [], routingTables: [] }; const API_BASE = '/api'; // Initialize on page load document.addEventListener('DOMContentLoaded', () => { const hash = window.location.hash.replace('#', '') || 'dashboard'; navigate(hash); fetchData(); // Live polling every 2.5 seconds setInterval(() => { if (state.isPolling) { fetchData(false); } }, 2500); }); // Navigation Handler function navigate(tabName) { state.activeTab = tabName; window.location.hash = tabName; // Update nav links styling document.querySelectorAll('.nav-link').forEach(link => { link.className = 'nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white'; }); const activeNav = document.getElementById(`nav-${tabName}`); if (activeNav) { activeNav.className = 'nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group bg-brand-500/10 text-brand-400 border border-brand-500/20'; } // Update page headers const titles = { dashboard: { title: 'Dashboard Overview', sub: 'Live router performance and network telemetry' }, interfaces: { title: 'Network Interfaces', sub: 'Hierarchical view of physical interfaces and nested 802.1Q VLANs' }, vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' }, firewall: { title: 'MikroTik Firewall', sub: 'Filter rules, NAT, Mangle, Address Lists & Active Connections tracking' }, routes: { title: 'Routing & Policy', sub: 'Routing tables, static routes (DAS/DAC), and policy routing rules' }, pppoe: { title: 'PPPoE Client WAN', sub: 'Point-to-Point Protocol over Ethernet WAN access' }, wireguard: { title: 'WireGuard Kernel VPN', sub: 'Modern ultra-fast cryptographic VPN tunnel manager' }, ipsec: { title: 'IPSec (IKEv2) strongSwan', sub: 'Enterprise Site-to-Site & Roadwarrior IPSec controller' }, l2tp: { title: 'L2TP VPN Client', sub: 'Layer 2 Tunneling Protocol via xl2tpd' }, pptp: { title: 'PPTP VPN Client', sub: 'Point-to-Point Tunneling Protocol with MPPE-128 encryption' }, persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' } }; const header = titles[tabName] || titles.dashboard; const pTitle = document.getElementById('page-title'); if (pTitle) pTitle.innerText = header.title; const pSub = document.getElementById('page-subtitle'); if (pSub) pSub.innerText = header.sub; // Toggle sections dynamically for all section elements document.querySelectorAll('main section[id^="section-"]').forEach(el => { if (el.id === `section-${tabName}`) { el.classList.remove('hidden'); } else { el.classList.add('hidden'); } }); if (tabName === 'firewall') loadFirewall(); if (tabName === 'routes') loadRoutes(); if (tabName === 'persistence') loadDebianConfig(); if (tabName === 'pppoe') loadPPPoE(); if (tabName === 'wireguard') loadWireGuard(); if (tabName === 'ipsec') loadIPSec(); if (tabName === 'l2tp') loadL2TP(); if (tabName === 'pptp') loadPPTP(); } // Fetch all core system, network and VPN telemetry async function fetchData(isManual = false) { const spinner = document.getElementById('refresh-spinner'); if (isManual && spinner) spinner.classList.add('animate-spin'); try { 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}/firewall/summary`) ]); if (statusRes.ok) state.systemStatus = await statusRes.json(); if (ifacesRes.ok) { const ifacesData = await ifacesRes.json(); state.interfaces = Array.isArray(ifacesData) ? ifacesData : []; } if (vlansRes.ok) { const vlansData = await vlansRes.json(); 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(); 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); if (state.activeTab === 'l2tp') loadL2TP(false); if (state.activeTab === 'pptp') loadPPTP(false); } catch (err) { console.error('Failed to fetch router state:', err); } finally { if (isManual && spinner) { setTimeout(() => spinner.classList.remove('animate-spin'), 300); } } } // Update Top KPI and Sidebar Telemetry function updateSystemMetrics() { const s = state.systemStatus; const v = state.vpnStatus; if (!s) return; // Sidebar document.getElementById('side-hostname').innerText = s.hostname || 'debian-lxc'; document.getElementById('side-uptime').innerText = s.uptime_human || '0m'; document.getElementById('side-gateway-ip').innerText = s.default_gateway ? `GW: ${s.default_gateway}` : 'GW: Isolated'; if (s.cpu_load && s.cpu_load.length > 0) { document.getElementById('side-cpu-val').innerText = s.cpu_load[0]; const cpuPct = Math.min(100, Math.round(parseFloat(s.cpu_load[0]) * 50)); document.getElementById('side-cpu-bar').style.width = `${Math.max(5, cpuPct)}%`; } document.getElementById('side-ram-val').innerText = `${Math.round(s.mem_usage_pct || 0)}%`; document.getElementById('side-ram-bar').style.width = `${Math.max(5, Math.round(s.mem_usage_pct || 0))}%`; // Dashboard Stats document.getElementById('stat-ifaces-up').innerText = s.up_ifaces || 0; document.getElementById('stat-ifaces-total').innerText = `${s.total_ifaces || 0} Total`; document.getElementById('stat-ifaces-down').innerText = `${s.down_ifaces || 0} DOWN`; document.getElementById('stat-vlans-count').innerText = s.vlan_ifaces || 0; // VPN Status Indicators if (v) { const totalActive = v.total_active_tunnels || 0; const statVpnActive = document.getElementById('stat-vpn-active'); if (statVpnActive) statVpnActive.innerText = totalActive; const tunnelsDot = document.getElementById('tunnels-dot'); const tunnelsLabel = document.getElementById('tunnels-label'); if (tunnelsDot && tunnelsLabel) { if (totalActive > 0) { tunnelsDot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse'; tunnelsLabel.innerText = `${totalActive} Active VPN Tunnel${totalActive > 1 ? 's' : ''}`; } else { tunnelsDot.className = 'w-2 h-2 rounded-full bg-slate-500'; tunnelsLabel.innerText = '0 Active VPN Tunnels'; } } setDotBadge('badge-pppoe-status', (v.active_pppoe || 0) > 0 || v.pppoe_connected); setDotBadge('badge-wg-status', (v.active_wireguard || 0) > 0 || v.wireguard_active); setDotBadge('badge-ipsec-status', (v.active_ipsec || 0) > 0 || v.ipsec_active); setDotBadge('badge-l2tp-status', (v.active_l2tp || 0) > 0 || v.l2tp_connected); setDotBadge('badge-pptp-status', (v.active_pptp || 0) > 0 || v.pptp_connected); } // Total Live Bandwidth Rate let totalRx = 0; let totalTx = 0; (state.interfaces || []).forEach(i => { if (i.rate) { totalRx += i.rate.rx_bytes_sec || 0; totalTx += i.rate.tx_bytes_sec || 0; } }); document.getElementById('stat-traffic-rx').innerText = formatTraffic(totalRx); document.getElementById('stat-traffic-tx').innerText = formatTraffic(totalTx); } function setDotBadge(id, active) { const el = document.getElementById(id); if (!el) return; if (active) { el.className = 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-400/80'; } else { el.className = 'w-2 h-2 rounded-full bg-slate-600'; } } 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 function renderDashboard() { const matrix = document.getElementById('port-matrix'); matrix.innerHTML = ''; (state.interfaces || []).forEach(iface => { const isUp = iface.state === 'UP'; const isVlan = iface.is_vlan; const portCard = document.createElement('div'); portCard.className = `p-3 rounded-xl border transition-all cursor-pointer select-none group ${ isUp ? (isVlan ? 'bg-cyan-950/20 border-cyan-800/60 hover:border-cyan-500' : 'bg-slate-800/80 border-slate-700/80 hover:border-brand-500') : 'bg-slate-900/40 border-slate-800/60 opacity-60 hover:opacity-100 hover:border-slate-700' }`; portCard.onclick = () => openIfaceModal(iface.name); let statusPillClass = isUp ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'; if (isVlan) statusPillClass = isUp ? 'bg-cyan-400 shadow-sm shadow-cyan-400/50' : 'bg-rose-500'; portCard.innerHTML = `
${iface.name}
${iface.ipv4 && iface.ipv4.length > 0 ? iface.ipv4[0] : (iface.mac || 'No IP')}
${isVlan ? `VLAN ${iface.vlan_id}` : iface.type} ${iface.rate?.rx_human || '0 B/s'}
`; matrix.appendChild(portCard); }); const ifaceTbody = document.getElementById('quick-interfaces-table'); ifaceTbody.innerHTML = ''; (state.interfaces || []).slice(0, 6).forEach(i => { const isUp = i.state === 'UP'; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition cursor-pointer'; tr.onclick = () => openIfaceModal(i.name); tr.innerHTML = ` ${i.name} ${i.role || 'LAN'} ${i.ipv4 && i.ipv4.length > 0 ? i.ipv4[0] : '-'} ${i.state} `; ifaceTbody.appendChild(tr); }); const vlanTbody = document.getElementById('quick-vlans-table'); vlanTbody.innerHTML = ''; const vlans = state.vlans || []; if (vlans.length === 0) { vlanTbody.innerHTML = `No VLANs configured. Click "+ Add" above to create one.`; } else { vlans.slice(0, 6).forEach(v => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition cursor-pointer'; tr.onclick = () => openIfaceModal(v.name); tr.innerHTML = ` VLAN ${v.vlan_id} ${v.name} ${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : 'Unnumbered'} ${v.parent_name || 'eth0'} `; vlanTbody.appendChild(tr); }); } } // Expand / Collapse Handlers function toggleExpand(parentName) { if (state.expandedParents.has(parentName)) { state.expandedParents.delete(parentName); } else { state.expandedParents.add(parentName); } renderInterfacesList(); } function expandAllIfaces(expand) { if (expand) { (state.interfaces || []).forEach(i => { if (!i.is_vlan) state.expandedParents.add(i.name); }); } else { state.expandedParents.clear(); } renderInterfacesList(); } // Filter and Render Hierarchical Interfaces List function filterIfaces(filter) { state.ifaceFilter = filter; document.querySelectorAll('.filter-btn').forEach(btn => { btn.className = 'filter-btn px-3 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800 hover:text-white'; }); const activeBtn = document.getElementById(`filter-${filter}`); if (activeBtn) { activeBtn.className = 'filter-btn px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-800 text-white border border-slate-700'; } renderInterfacesList(); } function renderInterfacesList() { const tbody = document.getElementById('interfaces-tree-tbody'); const emptyState = document.getElementById('interfaces-empty-state'); const summary = document.getElementById('ifaces-list-summary'); const query = (document.getElementById('iface-search')?.value || '').toLowerCase(); tbody.innerHTML = ''; const allIfaces = state.interfaces || []; const allVlans = state.vlans || []; const parentIfaces = allIfaces.filter(i => !i.is_vlan); const filteredParents = parentIfaces.filter(parent => { const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.')); if (query) { const matchParent = parent.name.toLowerCase().includes(query) || (parent.role || '').toLowerCase().includes(query); const matchVlan = childVlans.some(v => v.name.toLowerCase().includes(query) || String(v.vlan_id).includes(query) || (v.description || '').toLowerCase().includes(query)); if (!matchParent && !matchVlan) return false; } if (state.ifaceFilter === 'has_vlans') return childVlans.length > 0; if (state.ifaceFilter === 'physical') return parent.type !== 'loopback'; if (state.ifaceFilter === 'up') return parent.state === 'UP'; return true; }); if (summary) { summary.innerText = `${parentIfaces.length} Physical/Parent Links • ${allVlans.length} Active VLANs`; } if (filteredParents.length === 0) { emptyState?.classList.remove('hidden'); return; } emptyState?.classList.add('hidden'); filteredParents.forEach(parent => { const isUp = parent.state === 'UP'; const childVlans = allVlans.filter(v => v.parent_name === parent.name || v.name.startsWith(parent.name + '.')); const isExpanded = state.expandedParents.has(parent.name); const parentRow = document.createElement('tr'); parentRow.className = `hover:bg-slate-800/50 transition border-b border-slate-800/80 ${isExpanded ? 'bg-slate-900/80' : ''}`; parentRow.innerHTML = `
${parent.type === 'loopback' ? 'LO' : 'NIC'}
${parent.name} ${childVlans.length > 0 ? `${childVlans.length} VLAN${childVlans.length > 1 ? 's' : ''}` : `0 VLANs` }
${parent.type}
${parent.role || 'LAN'} ${parent.description || '-'}
${parent.ipv4 && parent.ipv4.length > 0 ? `
${parent.ipv4[0]}
` : 'Unassigned' }
${parent.mac || 'N/A'}
MTU ${parent.mtu}
↓ ${parent.rate?.rx_human || '0 B/s'} ↑ ${parent.rate?.tx_human || '0 B/s'}
`; tbody.appendChild(parentRow); if (isExpanded) { const drawerRow = document.createElement('tr'); drawerRow.className = 'bg-slate-950/80 border-b border-slate-800/90'; const drawerCell = document.createElement('td'); drawerCell.colSpan = 8; drawerCell.className = 'p-0'; drawerCell.innerHTML = `
VLAN Sub-interfaces on ${parent.name} ${childVlans.length} Attached
${childVlans.length === 0 ? `

No 802.1Q tagged VLANs have been created under ${parent.name} yet.

` : `
${childVlans.map(v => { const isVlanUp = v.state === 'UP'; return ` `; }).join('')}
VLAN ID Interface Device Subnet Gateway Role / Alias MTU Throughput Status Actions
VLAN ${v.vlan_id} └─ ${v.name} ${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : 'Unassigned'} ${v.role || 'VLAN'} ${v.description || '-'} ${v.mtu} ↓ ${v.rate?.rx_human || '0 B/s'} ↑ ${v.rate?.tx_human || '0 B/s'}
`}
`; drawerRow.appendChild(drawerCell); tbody.appendChild(drawerRow); } }); } // Render VLANs Dedicated View function renderVlansList() { const tbody = document.getElementById('vlans-table-body'); const emptyState = document.getElementById('vlans-empty-state'); const countBadge = document.getElementById('vlans-table-count'); const query = (document.getElementById('vlan-search')?.value || '').toLowerCase(); tbody.innerHTML = ''; const allVlans = state.vlans || []; countBadge.innerText = allVlans.length; const filtered = allVlans.filter(v => { if (!query) return true; return v.name.toLowerCase().includes(query) || String(v.vlan_id).includes(query) || (v.description || '').toLowerCase().includes(query) || (v.ipv4 && v.ipv4.some(ip => ip.includes(query))); }); if (allVlans.length === 0) { emptyState.classList.remove('hidden'); return; } emptyState.classList.add('hidden'); filtered.forEach(v => { const isUp = v.state === 'UP'; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs'; tr.innerHTML = ` VLAN ${v.vlan_id} ${v.name} ${v.parent_name || 'eth0'} ${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : 'Unassigned'} ${v.description || v.role || '-'} ${v.mtu} `; tbody.appendChild(tr); }); } // Toggle interface UP / DOWN async function toggleInterfaceState(name, newState) { try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: newState }) }); const data = await res.json(); if (res.ok) { showToast('Interface Updated', `Interface ${name} is now ${newState.toUpperCase()}`, 'success'); fetchData(false); } else { showToast('Error', data.message || 'Failed to toggle interface state', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } // ---------------- MikroTik Flag & Utilities ---------------- function renderMikroTikFlag(running, disabled) { if (running) { return `R`; } if (disabled) { return `X`; } return `-`; } function copyToClipboard(text, label = 'Text') { navigator.clipboard.writeText(text).then(() => { showToast('Copied', `${label} copied to clipboard`, 'success'); }).catch(() => { prompt('Copy value:', text); }); } // ======================== PPPoE Multi-Instance ======================== async function loadPPPoE(populateInputs = true) { try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients`); const list = await res.json(); state.pppoeList = Array.isArray(list) ? list : []; const badge = document.getElementById('pppoe-count-badge'); if (badge) badge.innerText = `${state.pppoeList.length} Interface${state.pppoeList.length !== 1 ? 's' : ''}`; const tbody = document.getElementById('pppoe-table-body'); const empty = document.getElementById('pppoe-empty'); if (!tbody) return; tbody.innerHTML = ''; if (state.pppoeList.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); state.pppoeList.forEach(inst => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(inst.running, inst.disabled)} ${inst.name} ${inst.interface} ${inst.username || '-'} ${inst.assigned_ip || '-'} ${inst.ppp_device || '-'} ${inst.default_route ? 'Yes' : 'No'} ${inst.running ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load PPPoE:', err); } } function openAddPPPoEModal() { document.getElementById('pppoe-modal-title').innerText = 'Add PPPoE Client Interface'; document.getElementById('pppoe-modal-edit-orig').value = ''; document.getElementById('pppoe-modal-name').value = `pppoe-out${(state.pppoeList || []).length + 1}`; document.getElementById('pppoe-modal-name').readOnly = false; document.getElementById('pppoe-modal-user').value = ''; document.getElementById('pppoe-modal-pass').value = ''; document.getElementById('pppoe-modal-service').value = ''; document.getElementById('pppoe-modal-mtu').value = '1492'; document.getElementById('pppoe-modal-defroute').checked = true; document.getElementById('pppoe-modal-reconnect').checked = true; document.getElementById('pppoe-modal-disabled').checked = false; // Populate interfaces const sel = document.getElementById('pppoe-modal-iface'); sel.innerHTML = ''; (state.interfaces || []).forEach(i => { if (i.name === 'lo') return; const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.role || i.type})`; sel.appendChild(opt); }); document.getElementById('pppoe-modal').classList.remove('hidden'); } function openEditPPPoEModal(name) { const inst = (state.pppoeList || []).find(i => i.name === name); if (!inst) return; document.getElementById('pppoe-modal-title').innerText = `Edit PPPoE Client: ${name}`; document.getElementById('pppoe-modal-edit-orig').value = name; document.getElementById('pppoe-modal-name').value = inst.name; document.getElementById('pppoe-modal-name').readOnly = true; document.getElementById('pppoe-modal-user').value = inst.username || ''; document.getElementById('pppoe-modal-pass').value = inst.password || ''; document.getElementById('pppoe-modal-service').value = inst.service_name || ''; document.getElementById('pppoe-modal-mtu').value = inst.mtu || 1492; document.getElementById('pppoe-modal-defroute').checked = inst.default_route ?? true; document.getElementById('pppoe-modal-reconnect').checked = inst.auto_reconnect ?? true; document.getElementById('pppoe-modal-disabled').checked = !!inst.disabled; const sel = document.getElementById('pppoe-modal-iface'); sel.innerHTML = ''; (state.interfaces || []).forEach(i => { if (i.name === 'lo') return; const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.role || i.type})`; if (i.name === inst.interface) opt.selected = true; sel.appendChild(opt); }); document.getElementById('pppoe-modal').classList.remove('hidden'); } function closePPPoEModal() { document.getElementById('pppoe-modal').classList.add('hidden'); } async function submitSavePPPoE(e) { e.preventDefault(); const name = document.getElementById('pppoe-modal-name').value.trim(); const iface = document.getElementById('pppoe-modal-iface').value; const user = document.getElementById('pppoe-modal-user').value.trim(); const pass = document.getElementById('pppoe-modal-pass').value; const service = document.getElementById('pppoe-modal-service').value.trim(); const mtu = parseInt(document.getElementById('pppoe-modal-mtu').value, 10) || 1492; const defRoute = document.getElementById('pppoe-modal-defroute').checked; const reconnect = document.getElementById('pppoe-modal-reconnect').checked; const disabled = document.getElementById('pppoe-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, interface: iface, username: user, password: pass, service_name: service, mtu, default_route: defRoute, auto_reconnect: reconnect, disabled }) }); if (res.ok) { showToast('PPPoE Saved', `Configuration for ${name} saved`, 'success'); closePPPoEModal(); loadPPPoE(false); fetchData(false); } else { const err = await res.json(); showToast('Error', err.message || 'Failed to save PPPoE', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } } async function connectPPPoEInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' }); if (res.ok) { showToast('Connecting', `Dialing ${name}...`, 'info'); setTimeout(() => loadPPPoE(false), 2000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function disconnectPPPoEInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' }); if (res.ok) { showToast('Disconnected', `${name} session terminated`, 'info'); setTimeout(() => loadPPPoE(false), 1000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function togglePPPoEState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `${name} is now ${enable ? 'enabled' : 'disabled'}`, 'info'); loadPPPoE(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deletePPPoEInstance(name) { if (!confirm(`Are you sure you want to delete PPPoE client ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/pppoe/clients/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `PPPoE client ${name} removed`, 'success'); loadPPPoE(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== WireGuard Multi-Interface & Multi-Peer ======================== state.wgSubTab = 'ifaces'; function switchWGSubTab(tab) { state.wgSubTab = tab; const ifacesPanel = document.getElementById('wg-subtab-ifaces'); const peersPanel = document.getElementById('wg-subtab-peers'); const ifacesBtn = document.getElementById('wg-subtab-btn-ifaces'); const peersBtn = document.getElementById('wg-subtab-btn-peers'); if (tab === 'ifaces') { ifacesPanel?.classList.remove('hidden'); peersPanel?.classList.add('hidden'); ifacesBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-rose-500 text-white transition flex items-center gap-2'; peersBtn.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'; } else { ifacesPanel?.classList.add('hidden'); peersPanel?.classList.remove('hidden'); ifacesBtn.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'; peersBtn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-rose-500 text-white transition flex items-center gap-2'; renderWGPeers(); } } async function loadWireGuard(populateInputs = true) { try { const [ifacesRes, peersRes] = await Promise.all([ fetch(`${API_BASE}/vpn/wireguard/interfaces`), fetch(`${API_BASE}/vpn/wireguard/peers`) ]); const ifaces = ifacesRes.ok ? await ifacesRes.json() : []; const peers = peersRes.ok ? await peersRes.json() : []; state.wgInterfaces = Array.isArray(ifaces) ? ifaces : []; state.wgPeers = Array.isArray(peers) ? peers : []; // Badges const ifacesCountBadge = document.getElementById('wg-ifaces-count'); if (ifacesCountBadge) ifacesCountBadge.innerText = `${state.wgInterfaces.length} Interfaces`; const peersCountBadge = document.getElementById('wg-peers-count'); if (peersCountBadge) peersCountBadge.innerText = `${state.wgPeers.length} Peers`; const subTabIfaces = document.getElementById('wg-subtab-badge-ifaces'); if (subTabIfaces) subTabIfaces.innerText = state.wgInterfaces.length; const subTabPeers = document.getElementById('wg-subtab-badge-peers'); if (subTabPeers) subTabPeers.innerText = state.wgPeers.length; // Render Interfaces const ifacesTbody = document.getElementById('wg-ifaces-table-body'); const ifacesEmpty = document.getElementById('wg-ifaces-empty'); if (ifacesTbody) { ifacesTbody.innerHTML = ''; if (state.wgInterfaces.length === 0) { if (ifacesEmpty) ifacesEmpty.classList.remove('hidden'); } else { if (ifacesEmpty) ifacesEmpty.classList.add('hidden'); state.wgInterfaces.forEach(iface => { const peersOnIface = state.wgPeers.filter(p => p.interface === iface.name).length; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(iface.running, iface.disabled)} ${iface.name} ${iface.listen_port} ${iface.address || '-'} ${(iface.public_key || '').substring(0, 10)}... 📋 ${iface.mtu || 1420} ${peersOnIface} peer${peersOnIface !== 1 ? 's' : ''} ${iface.running ? ` ` : ` `} `; ifacesTbody.appendChild(tr); }); } } // Populate filter dropdown in peers tab const filterSel = document.getElementById('wg-peers-filter-iface'); if (filterSel) { const currentVal = filterSel.value; filterSel.innerHTML = ''; state.wgInterfaces.forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = i.name; if (i.name === currentVal) opt.selected = true; filterSel.appendChild(opt); }); } renderWGPeers(); } catch (err) { console.error('Failed to load WireGuard:', err); } } function renderWGPeers() { const filterSel = document.getElementById('wg-peers-filter-iface'); const selectedIface = filterSel ? filterSel.value : ''; const tbody = document.getElementById('wg-peers-table-body'); const empty = document.getElementById('wg-peers-empty'); if (!tbody) return; tbody.innerHTML = ''; const filtered = selectedIface ? state.wgPeers.filter(p => p.interface === selectedIface) : state.wgPeers; if (filtered.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); filtered.forEach(p => { const isOnline = p.latest_handshake && !p.latest_handshake.includes('Never'); const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(isOnline, p.disabled)} ${p.interface} ${p.name || 'Unnamed'} ${p.public_key.substring(0, 10)}... 📋 ${(p.allowed_ips || []).join(', ')} ${p.endpoint_address ? (p.endpoint_address + (p.endpoint_port ? ':' + p.endpoint_port : '')) : '-'} ${p.latest_handshake || 'Never'} ↓ ${p.transfer_rx_human || '0 B'} ↑ ${p.transfer_tx_human || '0 B'} `; tbody.appendChild(tr); }); } } function openAddWGInterfaceModal() { document.getElementById('wg-iface-modal-title').innerText = 'Add WireGuard Interface'; document.getElementById('wg-iface-modal-edit-orig').value = ''; document.getElementById('wg-iface-modal-name').value = `wg${(state.wgInterfaces || []).length}`; document.getElementById('wg-iface-modal-name').readOnly = false; document.getElementById('wg-iface-modal-port').value = 51820 + (state.wgInterfaces || []).length; document.getElementById('wg-iface-modal-addr').value = `10.${10 + (state.wgInterfaces || []).length}.0.1/24`; document.getElementById('wg-iface-modal-mtu').value = '1420'; document.getElementById('wg-iface-modal-pubkey').value = ''; document.getElementById('wg-iface-modal-privkey').value = ''; document.getElementById('wg-iface-modal-disabled').checked = false; generateWGInterfaceKeys(); document.getElementById('wg-iface-modal').classList.remove('hidden'); } function openEditWGInterfaceModal(name) { const iface = (state.wgInterfaces || []).find(i => i.name === name); if (!iface) return; document.getElementById('wg-iface-modal-title').innerText = `Edit WireGuard Interface: ${name}`; document.getElementById('wg-iface-modal-edit-orig').value = name; document.getElementById('wg-iface-modal-name').value = iface.name; document.getElementById('wg-iface-modal-name').readOnly = true; document.getElementById('wg-iface-modal-port').value = iface.listen_port || 51820; document.getElementById('wg-iface-modal-addr').value = iface.address || ''; document.getElementById('wg-iface-modal-mtu').value = iface.mtu || 1420; document.getElementById('wg-iface-modal-pubkey').value = iface.public_key || ''; document.getElementById('wg-iface-modal-privkey').value = iface.private_key || ''; document.getElementById('wg-iface-modal-disabled').checked = !!iface.disabled; document.getElementById('wg-iface-modal').classList.remove('hidden'); } function closeAddWGInterfaceModal() { document.getElementById('wg-iface-modal').classList.add('hidden'); } async function generateWGInterfaceKeys() { try { const res = await fetch(`${API_BASE}/vpn/wireguard/genkeys`, { method: 'POST' }); const keys = await res.json(); if (res.ok) { document.getElementById('wg-iface-modal-privkey').value = keys.private_key; document.getElementById('wg-iface-modal-pubkey').value = keys.public_key; } } catch (err) { showToast('Error', 'Failed to generate keypair: ' + err.message, 'error'); } } async function submitSaveWGInterface(e) { e.preventDefault(); const name = document.getElementById('wg-iface-modal-name').value.trim(); const port = parseInt(document.getElementById('wg-iface-modal-port').value, 10) || 51820; const addr = document.getElementById('wg-iface-modal-addr').value.trim(); const mtu = parseInt(document.getElementById('wg-iface-modal-mtu').value, 10) || 1420; const pub = document.getElementById('wg-iface-modal-pubkey').value.trim(); const priv = document.getElementById('wg-iface-modal-privkey').value.trim(); const disabled = document.getElementById('wg-iface-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, listen_port: port, address: addr, mtu, public_key: pub, private_key: priv, disabled }) }); if (res.ok) { showToast('WireGuard Saved', `Interface ${name} configured`, 'success'); closeAddWGInterfaceModal(); loadWireGuard(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function startWGInterface(name) { try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}/start`, { method: 'POST' }); if (res.ok) { showToast('Interface UP', `Started WireGuard interface ${name}`, 'success'); loadWireGuard(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function stopWGInterface(name) { try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}/stop`, { method: 'POST' }); if (res.ok) { showToast('Interface DOWN', `Stopped WireGuard interface ${name}`, 'info'); loadWireGuard(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleWGInterfaceState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info'); loadWireGuard(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteWGInterface(name) { if (!confirm(`Are you sure you want to delete WireGuard interface ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/wireguard/interfaces/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `Interface ${name} removed`, 'success'); loadWireGuard(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } function openAddWGPeerModal() { const sel = document.getElementById('wg-peer-modal-iface'); sel.innerHTML = ''; (state.wgInterfaces || []).forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.address || 'No IP'})`; sel.appendChild(opt); }); if ((state.wgInterfaces || []).length === 0) { showToast('No Interfaces', 'Please add a WireGuard interface first before adding peers', 'warning'); openAddWGInterfaceModal(); return; } document.getElementById('wg-peer-modal-name').value = ''; document.getElementById('wg-peer-modal-pubkey').value = ''; document.getElementById('wg-peer-modal-allowedips').value = '10.10.0.2/32'; document.getElementById('wg-peer-modal-endpoint').value = ''; document.getElementById('wg-peer-modal-keepalive').value = '25'; document.getElementById('wg-peer-modal-preshared').value = ''; document.getElementById('wg-peer-modal-disabled').checked = false; document.getElementById('wg-peer-modal').classList.remove('hidden'); } function closeAddWGPeerModal() { document.getElementById('wg-peer-modal').classList.add('hidden'); } async function submitSaveWGPeer(e) { e.preventDefault(); const iface = document.getElementById('wg-peer-modal-iface').value; const name = document.getElementById('wg-peer-modal-name').value.trim(); const pub = document.getElementById('wg-peer-modal-pubkey').value.trim(); const allowed = document.getElementById('wg-peer-modal-allowedips').value.split(',').map(s => s.trim()); const ep = document.getElementById('wg-peer-modal-endpoint').value.trim(); const keepalive = parseInt(document.getElementById('wg-peer-modal-keepalive').value, 10) || 25; const psk = document.getElementById('wg-peer-modal-preshared').value.trim(); const disabled = document.getElementById('wg-peer-modal-disabled').checked; let epAddr = ep; let epPort = 0; if (ep.includes(':')) { const parts = ep.split(':'); epAddr = parts[0]; epPort = parseInt(parts[1], 10) || 0; } try { const res = await fetch(`${API_BASE}/vpn/wireguard/peers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface, name, public_key: pub, allowed_ips: allowed, endpoint_address: epAddr, endpoint_port: epPort, persistent_keepalive: keepalive, preshared_key: psk, disabled }) }); if (res.ok) { showToast('Peer Saved', `Peer ${name} added to ${iface}`, 'success'); closeAddWGPeerModal(); loadWireGuard(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteWGPeer(iface, pubkey) { if (!confirm(`Are you sure you want to remove peer from ${iface}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/wireguard/peers/${encodeURIComponent(iface)}/${encodeURIComponent(pubkey)}`, { method: 'DELETE' }); if (res.ok) { showToast('Peer Deleted', 'WireGuard peer removed', 'success'); loadWireGuard(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== IPSec Multi-Tunnel ======================== async function loadIPSec(populateInputs = true) { try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels`); const list = await res.json(); state.ipsecTunnels = Array.isArray(list) ? list : []; const activeCount = state.ipsecTunnels.filter(t => t.running || t.status === 'ESTABLISHED').length; const badge = document.getElementById('ipsec-sas-badge'); if (badge) badge.innerText = `${activeCount} Active SAs`; const tbody = document.getElementById('ipsec-table-body'); const empty = document.getElementById('ipsec-empty'); if (!tbody) return; tbody.innerHTML = ''; if (state.ipsecTunnels.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); state.ipsecTunnels.forEach(c => { const isEstablished = c.running || c.status === 'ESTABLISHED'; const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(isEstablished, c.disabled)} ${c.name} ${c.local_subnet || '-'} ${c.remote_addrs || '-'} ${c.remote_subnet || '-'} ${c.status || (isEstablished ? 'ESTABLISHED' : 'DISCONNECTED')} ${isEstablished ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load IPSec:', err); } } function openAddIPSecModal() { document.getElementById('ipsec-modal-title').innerText = 'Configure IPSec IKEv2 Tunnel'; document.getElementById('ipsec-modal-name').value = `ipsec-${(state.ipsecTunnels || []).length + 1}`; document.getElementById('ipsec-modal-name').readOnly = false; document.getElementById('ipsec-modal-localts').value = '192.168.10.0/24'; document.getElementById('ipsec-modal-remotets').value = '192.168.20.0/24'; document.getElementById('ipsec-modal-remotegw').value = ''; document.getElementById('ipsec-modal-psk').value = ''; document.getElementById('ipsec-modal-ikeprop').value = 'aes256-sha256-modp2048,default'; document.getElementById('ipsec-modal-espprop').value = 'aes256-sha256-modp2048,default'; document.getElementById('ipsec-modal-autostart').checked = true; document.getElementById('ipsec-modal-disabled').checked = false; document.getElementById('ipsec-modal').classList.remove('hidden'); } function openEditIPSecModal(name) { const conn = (state.ipsecTunnels || []).find(c => c.name === name); if (!conn) return; document.getElementById('ipsec-modal-title').innerText = `Edit IPSec Tunnel: ${name}`; document.getElementById('ipsec-modal-name').value = conn.name; document.getElementById('ipsec-modal-name').readOnly = true; document.getElementById('ipsec-modal-localts').value = conn.local_subnet || ''; document.getElementById('ipsec-modal-remotets').value = conn.remote_subnet || ''; document.getElementById('ipsec-modal-remotegw').value = conn.remote_addrs || ''; document.getElementById('ipsec-modal-psk').value = conn.preshared_key || ''; document.getElementById('ipsec-modal-ikeprop').value = conn.ike_proposals || 'aes256-sha256-modp2048,default'; document.getElementById('ipsec-modal-espprop').value = conn.esp_proposals || 'aes256-sha256-modp2048,default'; document.getElementById('ipsec-modal-autostart').checked = conn.auto_start ?? true; document.getElementById('ipsec-modal-disabled').checked = !!conn.disabled; document.getElementById('ipsec-modal').classList.remove('hidden'); } function closeAddIPSecModal() { document.getElementById('ipsec-modal').classList.add('hidden'); } async function submitSaveIPSec(e) { e.preventDefault(); const name = document.getElementById('ipsec-modal-name').value.trim(); const localts = document.getElementById('ipsec-modal-localts').value.trim(); const remotets = document.getElementById('ipsec-modal-remotets').value.trim(); const remotegw = document.getElementById('ipsec-modal-remotegw').value.trim(); const psk = document.getElementById('ipsec-modal-psk').value; const ike = document.getElementById('ipsec-modal-ikeprop').value.trim(); const esp = document.getElementById('ipsec-modal-espprop').value.trim(); const autostart = document.getElementById('ipsec-modal-autostart').checked; const disabled = document.getElementById('ipsec-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, local_addrs: '%any', remote_addrs: remotegw, local_subnet: localts, remote_subnet: remotets, preshared_key: psk, ike_proposals: ike, esp_proposals: esp, auto_start: autostart, disabled }) }); if (res.ok) { showToast('IPSec Saved', `Tunnel ${name} configured`, 'success'); closeAddIPSecModal(); loadIPSec(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function startIPSecTunnel(name) { try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/start`, { method: 'POST' }); if (res.ok) { showToast('Initiating Tunnel', `Attempting SA handshake for ${name}...`, 'info'); setTimeout(() => loadIPSec(false), 2000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function stopIPSecTunnel(name) { try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/stop`, { method: 'POST' }); if (res.ok) { showToast('Terminated', `IPSec SA for ${name} closed`, 'info'); loadIPSec(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleIPSecState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `IPSec tunnel ${name} updated`, 'info'); loadIPSec(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteIPSecConn(name) { if (!confirm(`Are you sure you want to delete IPSec tunnel ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/ipsec/tunnels/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `IPSec connection ${name} removed`, 'success'); loadIPSec(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== L2TP Multi-Instance ======================== async function loadL2TP(populateInputs = true) { try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients`); const list = await res.json(); state.l2tpList = Array.isArray(list) ? list : []; const badge = document.getElementById('l2tp-count-badge'); if (badge) badge.innerText = `${state.l2tpList.length} Client${state.l2tpList.length !== 1 ? 's' : ''}`; const tbody = document.getElementById('l2tp-table-body'); const empty = document.getElementById('l2tp-empty'); if (!tbody) return; tbody.innerHTML = ''; if (state.l2tpList.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); state.l2tpList.forEach(inst => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(inst.running, inst.disabled)} ${inst.name} ${inst.connect_to} ${inst.username || '-'} ${inst.assigned_ip || '-'} ${inst.ppp_device || '-'} ${inst.default_route ? 'Yes' : 'No'} ${inst.running ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load L2TP:', err); } } function openAddL2TPModal() { document.getElementById('l2tp-modal-title').innerText = 'Add L2TP Client Interface'; document.getElementById('l2tp-modal-name').value = `l2tp-out${(state.l2tpList || []).length + 1}`; document.getElementById('l2tp-modal-name').readOnly = false; document.getElementById('l2tp-modal-server').value = ''; document.getElementById('l2tp-modal-user').value = ''; document.getElementById('l2tp-modal-pass').value = ''; document.getElementById('l2tp-modal-ipsec').value = ''; document.getElementById('l2tp-modal-defroute').checked = true; document.getElementById('l2tp-modal-disabled').checked = false; document.getElementById('l2tp-modal').classList.remove('hidden'); } function openEditL2TPModal(name) { const inst = (state.l2tpList || []).find(i => i.name === name); if (!inst) return; document.getElementById('l2tp-modal-title').innerText = `Edit L2TP Client: ${name}`; document.getElementById('l2tp-modal-name').value = inst.name; document.getElementById('l2tp-modal-name').readOnly = true; document.getElementById('l2tp-modal-server').value = inst.connect_to || ''; document.getElementById('l2tp-modal-user').value = inst.username || ''; document.getElementById('l2tp-modal-pass').value = inst.password || ''; document.getElementById('l2tp-modal-ipsec').value = inst.ipsec_secret || ''; document.getElementById('l2tp-modal-defroute').checked = inst.default_route ?? true; document.getElementById('l2tp-modal-disabled').checked = !!inst.disabled; document.getElementById('l2tp-modal').classList.remove('hidden'); } function closeL2TPModal() { document.getElementById('l2tp-modal').classList.add('hidden'); } async function submitSaveL2TP(e) { e.preventDefault(); const name = document.getElementById('l2tp-modal-name').value.trim(); const server = document.getElementById('l2tp-modal-server').value.trim(); const user = document.getElementById('l2tp-modal-user').value.trim(); const pass = document.getElementById('l2tp-modal-pass').value; const ipsec = document.getElementById('l2tp-modal-ipsec').value; const defRoute = document.getElementById('l2tp-modal-defroute').checked; const disabled = document.getElementById('l2tp-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, connect_to: server, username: user, password: pass, ipsec_secret: ipsec, default_route: defRoute, disabled }) }); if (res.ok) { showToast('L2TP Saved', `Client ${name} configuration saved`, 'success'); closeL2TPModal(); loadL2TP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function connectL2TPInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' }); if (res.ok) { showToast('Connecting', `Dialing L2TP ${name}...`, 'info'); setTimeout(() => loadL2TP(false), 2000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function disconnectL2TPInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' }); if (res.ok) { showToast('Disconnected', `L2TP tunnel ${name} terminated`, 'info'); loadL2TP(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function toggleL2TPState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info'); loadL2TP(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deleteL2TPInstance(name) { if (!confirm(`Are you sure you want to delete L2TP client ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/l2tp/clients/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `L2TP client ${name} removed`, 'success'); loadL2TP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== PPTP Multi-Instance ======================== async function loadPPTP(populateInputs = true) { try { const res = await fetch(`${API_BASE}/vpn/pptp/clients`); const list = await res.json(); state.pptpList = Array.isArray(list) ? list : []; const badge = document.getElementById('pptp-count-badge'); if (badge) badge.innerText = `${state.pptpList.length} Client${state.pptpList.length !== 1 ? 's' : ''}`; const tbody = document.getElementById('pptp-table-body'); const empty = document.getElementById('pptp-empty'); if (!tbody) return; tbody.innerHTML = ''; if (state.pptpList.length === 0) { if (empty) empty.classList.remove('hidden'); } else { if (empty) empty.classList.add('hidden'); state.pptpList.forEach(inst => { const tr = document.createElement('tr'); tr.className = 'hover:bg-slate-800/40 transition text-xs'; tr.innerHTML = ` ${renderMikroTikFlag(inst.running, inst.disabled)} ${inst.name} ${inst.connect_to} ${inst.username || '-'} ${inst.assigned_ip || '-'} ${inst.ppp_device || '-'} ${inst.require_mppe ? 'MPPE-128' : 'None'} ${inst.running ? ` ` : ` `} `; tbody.appendChild(tr); }); } } catch (err) { console.error('Failed to load PPTP:', err); } } function openAddPPTPModal() { document.getElementById('pptp-modal-title').innerText = 'Add PPTP Client Interface'; document.getElementById('pptp-modal-name').value = `pptp-out${(state.pptpList || []).length + 1}`; document.getElementById('pptp-modal-name').readOnly = false; document.getElementById('pptp-modal-server').value = ''; document.getElementById('pptp-modal-user').value = ''; document.getElementById('pptp-modal-pass').value = ''; document.getElementById('pptp-modal-mppe').checked = true; document.getElementById('pptp-modal-defroute').checked = true; document.getElementById('pptp-modal-disabled').checked = false; document.getElementById('pptp-modal').classList.remove('hidden'); } function openEditPPTPModal(name) { const inst = (state.pptpList || []).find(i => i.name === name); if (!inst) return; document.getElementById('pptp-modal-title').innerText = `Edit PPTP Client: ${name}`; document.getElementById('pptp-modal-name').value = inst.name; document.getElementById('pptp-modal-name').readOnly = true; document.getElementById('pptp-modal-server').value = inst.connect_to || ''; document.getElementById('pptp-modal-user').value = inst.username || ''; document.getElementById('pptp-modal-pass').value = inst.password || ''; document.getElementById('pptp-modal-mppe').checked = inst.require_mppe ?? true; document.getElementById('pptp-modal-defroute').checked = inst.default_route ?? true; document.getElementById('pptp-modal-disabled').checked = !!inst.disabled; document.getElementById('pptp-modal').classList.remove('hidden'); } function closePPTPModal() { document.getElementById('pptp-modal').classList.add('hidden'); } async function submitSavePPTP(e) { e.preventDefault(); const name = document.getElementById('pptp-modal-name').value.trim(); const server = document.getElementById('pptp-modal-server').value.trim(); const user = document.getElementById('pptp-modal-user').value.trim(); const pass = document.getElementById('pptp-modal-pass').value; const mppe = document.getElementById('pptp-modal-mppe').checked; const defRoute = document.getElementById('pptp-modal-defroute').checked; const disabled = document.getElementById('pptp-modal-disabled').checked; try { const res = await fetch(`${API_BASE}/vpn/pptp/clients`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, connect_to: server, username: user, password: pass, require_mppe: mppe, default_route: defRoute, auto_reconnect: true, disabled }) }); if (res.ok) { showToast('PPTP Saved', `Client ${name} configuration saved`, 'success'); closePPTPModal(); loadPPTP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function connectPPTPInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/connect`, { method: 'POST' }); if (res.ok) { showToast('Connecting', `Dialing PPTP ${name}...`, 'info'); setTimeout(() => loadPPTP(false), 2000); } } catch (err) { showToast('Error', err.message, 'error'); } } async function disconnectPPTPInstance(name) { try { const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/disconnect`, { method: 'POST' }); if (res.ok) { showToast('Disconnected', `PPTP tunnel ${name} terminated`, 'info'); loadPPTP(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function togglePPTPState(name, enable) { try { const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enable }) }); if (res.ok) { showToast(enable ? 'Enabled' : 'Disabled', `${name} state updated`, 'info'); loadPPTP(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function deletePPTPInstance(name) { if (!confirm(`Are you sure you want to delete PPTP client ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vpn/pptp/clients/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('Deleted', `PPTP client ${name} removed`, 'success'); loadPPTP(false); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ---------------- Modal & Helper Functions ---------------- function openVlanModal(defaultParent = '') { const select = document.getElementById('vlan-parent'); select.innerHTML = ''; const allIfaces = state.interfaces || []; const eligible = allIfaces.filter(i => !i.is_vlan && i.name !== 'lo'); if (eligible.length === 0) { allIfaces.forEach(i => eligible.push(i)); } eligible.forEach(i => { const opt = document.createElement('option'); opt.value = i.name; opt.innerText = `${i.name} (${i.role || 'Interface'} - ${i.state})`; if (i.name === defaultParent) opt.selected = true; select.appendChild(opt); }); document.getElementById('vlan-id').value = ''; document.getElementById('vlan-name').value = ''; document.getElementById('vlan-ip').value = ''; document.getElementById('vlan-desc').value = ''; autoSuggestVlanName(); document.getElementById('vlan-modal').classList.remove('hidden'); } function closeVlanModal() { document.getElementById('vlan-modal').classList.add('hidden'); } function autoSuggestVlanName() { const parent = document.getElementById('vlan-parent').value; const id = document.getElementById('vlan-id').value; const nameInput = document.getElementById('vlan-name'); if (parent && id) nameInput.placeholder = `${parent}.${id}`; } function setSubnetPreset(cidr) { const ipInput = document.getElementById('vlan-ip'); const id = document.getElementById('vlan-id').value || '10'; ipInput.value = `192.168.${id}.1${cidr}`; } async function submitCreateVlan(e) { e.preventDefault(); const btn = document.getElementById('vlan-submit-btn'); btn.disabled = true; btn.innerText = 'Creating...'; const parent = document.getElementById('vlan-parent').value; const id = parseInt(document.getElementById('vlan-id').value, 10); const name = document.getElementById('vlan-name').value.trim(); const ip = document.getElementById('vlan-ip').value.trim(); const role = document.getElementById('vlan-role').value; const desc = document.getElementById('vlan-desc').value.trim(); try { const res = await fetch(`${API_BASE}/vlans`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parent_name: parent, vlan_id: id, name: name, ip_address: ip, role: role, description: desc, mtu: 1500, auto_up: true }) }); const data = await res.json(); if (res.ok) { showToast('VLAN Created', `Created ${data.name} (VLAN ${data.vlan_id})`, 'success'); state.expandedParents.add(parent); closeVlanModal(); await fetchData(true); if (state.activeTab !== 'interfaces') navigate('interfaces'); } else { showToast('Creation Failed', data.message || 'Failed to create VLAN', 'error'); } } catch (err) { showToast('Error', err.message, 'error'); } finally { btn.disabled = false; btn.innerText = 'Create VLAN'; } } async function confirmDeleteVlan(name) { if (!confirm(`Delete VLAN interface ${name}?`)) return; try { const res = await fetch(`${API_BASE}/vlans/${encodeURIComponent(name)}`, { method: 'DELETE' }); if (res.ok) { showToast('VLAN Deleted', `Interface ${name} removed`, 'success'); closeIfaceModal(); fetchData(true); } } catch (err) { showToast('Error', err.message, 'error'); } } function openIfaceModal(name) { const iface = (state.interfaces || []).find(i => i.name === name); if (!iface) return; state.currentModalIface = iface.name; document.getElementById('modal-iface-name').innerText = iface.name; document.getElementById('modal-iface-badge').innerText = iface.is_vlan ? `VLAN ${iface.vlan_id}` : iface.type; document.getElementById('modal-iface-mtu').value = iface.mtu; document.getElementById('modal-iface-role').value = iface.role || 'LAN'; document.getElementById('modal-iface-desc').value = iface.description || ''; const delBtn = document.getElementById('modal-delete-vlan-btn'); if (iface.is_vlan) { delBtn.classList.remove('hidden'); } else { delBtn.classList.add('hidden'); } renderModalIPs(iface); document.getElementById('iface-modal').classList.remove('hidden'); } function renderModalIPs(iface) { const container = document.getElementById('modal-ip-list'); container.innerHTML = ''; const addrs = [...(iface.ipv4 || []), ...(iface.ipv6 || [])]; if (addrs.length === 0) { container.innerHTML = `No IP addresses currently bound`; return; } addrs.forEach(addr => { const row = document.createElement('div'); row.className = 'flex items-center justify-between p-2 rounded bg-slate-900 border border-slate-800'; row.innerHTML = ` ${addr} `; container.appendChild(row); }); } function closeIfaceModal() { document.getElementById('iface-modal').classList.add('hidden'); state.currentModalIface = null; } async function setIfaceState(name, newState) { await toggleInterfaceState(name, newState); const updated = (state.interfaces || []).find(i => i.name === name); if (updated) openIfaceModal(name); } async function applyMTU() { const mtu = parseInt(document.getElementById('modal-iface-mtu').value, 10); try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/mtu`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mtu }) }); if (res.ok) { showToast('MTU Applied', `Set MTU to ${mtu} on ${state.currentModalIface}`, 'success'); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } async function addInterfaceIP() { const ip = document.getElementById('modal-new-ip').value.trim(); if (!ip) return; try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/ip`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip }) }); if (res.ok) { showToast('IP Added', `Bound ${ip} to ${state.currentModalIface}`, 'success'); document.getElementById('modal-new-ip').value = ''; await fetchData(false); const iface = (state.interfaces || []).find(i => i.name === state.currentModalIface); if (iface) renderModalIPs(iface); } } catch (err) { showToast('Error', err.message, 'error'); } } async function removeInterfaceIP(ip) { if (!confirm(`Remove IP ${ip}?`)) return; try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/ip`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip }) }); if (res.ok) { showToast('IP Removed', `Removed ${ip}`, 'success'); await fetchData(false); const iface = (state.interfaces || []).find(i => i.name === state.currentModalIface); if (iface) renderModalIPs(iface); } } catch (err) { showToast('Error', err.message, 'error'); } } async function saveInterfaceMetadata() { const role = document.getElementById('modal-iface-role').value; const desc = document.getElementById('modal-iface-desc').value.trim(); try { const res = await fetch(`${API_BASE}/interfaces/${encodeURIComponent(state.currentModalIface)}/metadata`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role, description: desc }) }); if (res.ok) { showToast('Saved', 'Metadata updated', 'success'); closeIfaceModal(); fetchData(false); } } catch (err) { showToast('Error', err.message, 'error'); } } // ======================== MIKROTIK FIREWALL (/ip firewall) ======================== state.fwSubTab = 'filter'; state.routesSubTab = 'routes'; function renderRuleFlag(disabled, hasComment) { let flags = ''; if (disabled) flags += 'X'; if (hasComment) flags += 'C'; return flags || '-'; } 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 `${action}`; } 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 = '+ Add Filter Rule'; else if (tab === 'nat') addBtn.innerHTML = '+ Add NAT Rule'; else if (tab === 'mangle') addBtn.innerHTML = '+ Add Mangle Rule'; else if (tab === 'addrlist') addBtn.innerHTML = '+ Add Address List'; else if (tab === 'connections') addBtn.innerHTML = 'Refresh Flows'; } 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 const bFilter = document.getElementById('fw-badge-filter'); if (bFilter) bFilter.innerText = (state.filterRules || []).length; const bNat = document.getElementById('fw-badge-nat'); if (bNat) bNat.innerText = (state.natRules || []).length; const bMangle = document.getElementById('fw-badge-mangle'); if (bMangle) bMangle.innerText = (state.mangleRules || []).length; const bAddr = document.getElementById('fw-badge-addrlist'); if (bAddr) bAddr.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 = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${r.chain} ${r.src_address || r.src_address_list ? (r.src_address || `list:${r.src_address_list}`) : '0.0.0.0/0'} ${r.dst_address || r.dst_address_list ? (r.dst_address || `list:${r.dst_address_list}`) : '0.0.0.0/0'} ${r.protocol || 'all'} ${ports || '-'} ${ifaces || '-'} ${renderActionBadge(r.action)} ${(r.packets || 0).toLocaleString()}p / ${formatTraffic(r.bytes || 0).replace('/s', '')} ${r.comment || '-'} `; tbody.appendChild(tr); }); } function populateInterfaceSelect(selectId, currentVal = '') { const sel = document.getElementById(selectId); if (!sel) return; sel.innerHTML = ''; (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 = ''; 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 = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${r.chain} ${r.src_address || '0.0.0.0/0'} ${r.dst_address || '0.0.0.0/0'} ${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''} ${ifaces || '-'} ${renderActionBadge(r.action)} ${toTarget || '-'} ${(r.packets || 0).toLocaleString()}p / ${formatTraffic(r.bytes || 0).replace('/s', '')} ${r.comment || '-'} `; 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 = ` ${r.order || idx + 1} ${renderRuleFlag(r.disabled, !!r.comment)} ${r.chain} ${r.src_address || '0.0.0.0/0'} ${r.dst_address || '0.0.0.0/0'} ${renderActionBadge(r.action)} ${markVal || '-'} ${r.passthrough ? 'yes' : 'no'} ${(r.packets || 0).toLocaleString()}p / ${formatTraffic(r.bytes || 0).replace('/s', '')} ${r.comment || '-'} `; 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 = ''; 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 = ` ${idx + 1} ${e.disabled ? 'X' : '-'} ${e.name} ${e.address} ${e.comment || '-'} `; 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 = ` ${c.protocol} ${c.src_address}:${c.src_port} ${c.dst_address}:${c.dst_port} ${c.reply_src_address ? c.reply_src_address + ':' + c.reply_src_port : '-'} ${c.reply_dst_address ? c.reply_dst_address + ':' + c.reply_dst_port : '-'} ${c.tcp_state} ${c.connection_mark || '-'} ${c.timeout ? c.timeout + 's' : '-'} `; 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(); const bRoutes = document.getElementById('routes-badge-routes'); if (bRoutes) bRoutes.innerText = (state.routes || []).length; const bRules = document.getElementById('routes-badge-rules'); if (bRules) bRules.innerText = (state.policyRules || []).length; const bTables = document.getElementById('routes-badge-tables'); if (bTables) bTables.innerText = (state.routingTables || []).length; const bRoutesCount = document.getElementById('badge-routes-count'); if (bRoutesCount) bRoutesCount.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'); 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 = ''; (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 = ` ${flags || '-'} ${r.dst_address} ${r.gateway || 'Direct link'} ${r.distance || 1} ${r.routing_table || 'main'} ${r.interface || '-'} ${r.comment || '-'} ${r.static ? ` ` : `Kernel`} `; 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`, { 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'); } } 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 = ` ${pr.priority} ${pr.src_address || 'all'} ${pr.dst_address || 'all'} ${pr.routing_mark || '-'} ${pr.table} ${pr.action} ${pr.priority < 32766 && pr.priority > 0 ? ` ` : `Default`} `; 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) { 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 = ` ${t.table_id} ${t.name} ${t.comment || '-'} `; 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'); } } async function loadDebianConfig() { const view = document.getElementById('debian-config-view'); view.innerText = 'Generating live configuration...'; try { const res = await fetch(`${API_BASE}/debian/config`); const data = await res.json(); view.innerText = data.content || '# Empty config'; } catch (err) { view.innerText = 'Failed to generate Debian network config.'; } } async function saveDebianConfig() { try { const res = await fetch(`${API_BASE}/debian/apply`, { method: 'POST' }); const data = await res.json(); if (res.ok) { showToast('Configuration Saved', data.message || 'Saved to /etc/network/interfaces.d/vlans.cfg', 'success'); loadDebianConfig(); } } catch (err) { showToast('Error', err.message, 'error'); } } function showToast(title, message, type = 'info') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); const borderColors = { success: 'border-emerald-500/40 bg-slate-900/95 text-emerald-400', error: 'border-rose-500/40 bg-slate-900/95 text-rose-400', info: 'border-cyan-500/40 bg-slate-900/95 text-cyan-400' }; toast.className = `pointer-events-auto border rounded-xl p-4 shadow-2xl backdrop-blur-md flex items-start space-x-3 w-80 transform transition-all duration-300 translate-y-2 opacity-0 ${borderColors[type] || borderColors.info}`; toast.innerHTML = `
${title}
${message}
`; container.appendChild(toast); requestAnimationFrame(() => toast.classList.remove('translate-y-2', 'opacity-0')); setTimeout(() => { toast.classList.add('opacity-0', 'translate-y-2'); setTimeout(() => toast.remove(), 300); }, 4000); } function formatTraffic(bytesPerSec) { if (bytesPerSec < 1024) return `${Math.round(bytesPerSec)} B/s`; const kb = bytesPerSec / 1024; if (kb < 1024) return `${kb.toFixed(1)} KB/s`; const mb = kb / 1024; return `${mb.toFixed(2)} MB/s`; } function toggleSidebar() { const sb = document.getElementById('sidebar'); sb.classList.toggle('-translate-x-full'); }