From 9ef0b6db18a1d444e241e6ed48b8258c04fb2111 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 9 Jan 2026 09:32:31 +0100 Subject: [PATCH] feat: WireGuard Dashboard v0.5.0 - Bug fixes and enhancements Bug fixes: - Fix QR code generation with JavaScript fallback library - Add missing API helper functions (getPeerStatusClass, shortenKey) - Fix traffic stats subshell variable scope bug - Fix peer add/remove UCI handling with unique section names Enhancements: - Add real-time auto-refresh with poll.add() (5s default) - Add SVG-based traffic charts component - Add peer configuration wizard with IP auto-suggestion - Add multi-interface management with tabs Co-Authored-By: Claude Opus 4.5 --- .../luci-app-wireguard-dashboard/Makefile | 4 +- .../view/wireguard-dashboard/overview.js | 230 +++++++- .../view/wireguard-dashboard/peers.js | 404 +++++++++++-- .../view/wireguard-dashboard/qrcodes.js | 535 ++++++++++++------ .../view/wireguard-dashboard/traffic.js | 99 +++- .../resources/wireguard-dashboard/api.js | 23 + .../resources/wireguard-dashboard/chart.js | 283 +++++++++ .../wireguard-dashboard/dashboard.css | 330 +++++++++++ .../resources/wireguard-dashboard/qrcode.js | 355 ++++++++++++ .../usr/libexec/rpcd/luci.wireguard-dashboard | 85 ++- 10 files changed, 2077 insertions(+), 271 deletions(-) create mode 100644 package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/chart.js create mode 100644 package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/qrcode.js diff --git a/package/secubox/luci-app-wireguard-dashboard/Makefile b/package/secubox/luci-app-wireguard-dashboard/Makefile index c2ba95e4..1d4dcb1f 100644 --- a/package/secubox/luci-app-wireguard-dashboard/Makefile +++ b/package/secubox/luci-app-wireguard-dashboard/Makefile @@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wireguard-dashboard -PKG_VERSION:=0.4.0 -PKG_RELEASE:=2 +PKG_VERSION:=0.5.0 +PKG_RELEASE:=1 PKG_ARCH:=all PKG_LICENSE:=Apache-2.0 diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/overview.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/overview.js index 3fa5972f..acd93467 100644 --- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/overview.js +++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/overview.js @@ -8,12 +8,179 @@ return view.extend({ title: _('WireGuard Dashboard'), - + pollInterval: 5, + pollActive: true, + selectedInterface: 'all', + load: function() { return api.getAllData(); }, - + + // Interface tab filtering + setInterfaceFilter: function(ifaceName) { + this.selectedInterface = ifaceName; + var tabs = document.querySelectorAll('.wg-tab'); + tabs.forEach(function(tab) { + tab.classList.toggle('active', tab.dataset.iface === ifaceName); + }); + + // Filter peer cards + var peerCards = document.querySelectorAll('.wg-peer-card'); + peerCards.forEach(function(card) { + if (ifaceName === 'all' || card.dataset.interface === ifaceName) { + card.style.display = ''; + } else { + card.style.display = 'none'; + } + }); + + // Filter interface cards + var ifaceCards = document.querySelectorAll('.wg-interface-card'); + ifaceCards.forEach(function(card) { + if (ifaceName === 'all' || card.dataset.iface === ifaceName) { + card.style.display = ''; + } else { + card.style.display = 'none'; + } + }); + }, + + renderInterfaceTabs: function(interfaces) { + var self = this; + var tabs = [ + E('button', { + 'class': 'wg-tab active', + 'data-iface': 'all', + 'click': function() { self.setInterfaceFilter('all'); } + }, 'All Interfaces') + ]; + + interfaces.forEach(function(iface) { + tabs.push(E('button', { + 'class': 'wg-tab', + 'data-iface': iface.name, + 'click': function() { self.setInterfaceFilter(iface.name); } + }, iface.name)); + }); + + return E('div', { 'class': 'wg-interface-tabs' }, tabs); + }, + + // Update stats without full re-render + updateStats: function(status) { + var updates = [ + { selector: '.wg-stat-interfaces', value: status.interface_count || 0 }, + { selector: '.wg-stat-total-peers', value: status.total_peers || 0 }, + { selector: '.wg-stat-active-peers', value: status.active_peers || 0 }, + { selector: '.wg-stat-rx', value: api.formatBytes(status.total_rx || 0) }, + { selector: '.wg-stat-tx', value: api.formatBytes(status.total_tx || 0) } + ]; + + updates.forEach(function(u) { + var el = document.querySelector(u.selector); + if (el && el.textContent !== String(u.value)) { + el.textContent = u.value; + el.classList.add('wg-value-updated'); + setTimeout(function() { el.classList.remove('wg-value-updated'); }, 500); + } + }); + + // Update status badge + var badge = document.querySelector('.wg-status-badge'); + if (badge) { + var isActive = status.interface_count > 0; + badge.classList.toggle('offline', !isActive); + badge.innerHTML = '' + (isActive ? 'VPN Active' : 'No Tunnels'); + } + }, + + // Update peer cards + updatePeers: function(peers) { + var grid = document.querySelector('.wg-peer-grid'); + if (!grid) return; + + peers.slice(0, 6).forEach(function(peer, idx) { + var card = grid.children[idx]; + if (!card) return; + + // Update status + var statusEl = card.querySelector('.wg-peer-status'); + if (statusEl) { + statusEl.textContent = peer.status; + statusEl.className = 'wg-peer-status ' + api.getPeerStatusClass(peer.status); + } + + // Update handshake + var hsEl = card.querySelector('.wg-peer-detail-value[data-field="handshake"]'); + if (hsEl) { + hsEl.textContent = api.formatHandshake(peer.handshake_ago); + } + + // Update traffic + var rxEl = card.querySelector('.wg-peer-traffic-value.rx'); + var txEl = card.querySelector('.wg-peer-traffic-value.tx'); + if (rxEl) rxEl.textContent = api.formatBytes(peer.rx_bytes); + if (txEl) txEl.textContent = api.formatBytes(peer.tx_bytes); + + // Update active state + card.classList.toggle('active', peer.status === 'active'); + }); + + // Update badge count + var activePeers = peers.filter(function(p) { return p.status === 'active'; }).length; + var badge = document.querySelector('.wg-peers-badge'); + if (badge) { + badge.textContent = activePeers + '/' + peers.length + ' active'; + } + }, + + // Update interface cards + updateInterfaces: function(interfaces) { + interfaces.forEach(function(iface) { + var card = document.querySelector('.wg-interface-card[data-iface="' + iface.name + '"]'); + if (!card) return; + + // Update status + var statusEl = card.querySelector('.wg-interface-status'); + if (statusEl) { + statusEl.textContent = iface.state; + statusEl.className = 'wg-interface-status ' + iface.state; + } + + // Update traffic + var trafficEl = card.querySelector('.wg-interface-traffic'); + if (trafficEl) { + trafficEl.textContent = '↓' + api.formatBytes(iface.rx_bytes) + ' / ↑' + api.formatBytes(iface.tx_bytes); + } + }); + }, + + startPolling: function() { + var self = this; + this.pollActive = true; + + poll.add(L.bind(function() { + if (!this.pollActive) return Promise.resolve(); + + return api.getAllData().then(L.bind(function(data) { + var status = data.status || {}; + var interfaces = (data.interfaces || {}).interfaces || []; + var peers = (data.peers || {}).peers || []; + + this.updateStats(status); + this.updatePeers(peers); + this.updateInterfaces(interfaces); + }, this)); + }, this), this.pollInterval); + }, + + stopPolling: function() { + this.pollActive = false; + poll.stop(); + }, + render: function(data) { + var self = this; var status = data.status || {}; var interfaces = (data.interfaces || {}).interfaces || []; var peers = (data.peers || {}).peers || []; @@ -38,6 +205,38 @@ return view.extend({ ]) ]), + // Auto-refresh control + E('div', { 'class': 'wg-refresh-control' }, [ + E('span', { 'class': 'wg-refresh-status' }, [ + E('span', { 'class': 'wg-refresh-indicator active' }), + ' Auto-refresh: ', + E('span', { 'class': 'wg-refresh-state' }, 'Active') + ]), + E('button', { + 'class': 'wg-btn wg-btn-sm', + 'id': 'wg-poll-toggle', + 'click': L.bind(function(ev) { + var btn = ev.target; + var indicator = document.querySelector('.wg-refresh-indicator'); + var state = document.querySelector('.wg-refresh-state'); + if (this.pollActive) { + this.stopPolling(); + btn.textContent = '▶ Resume'; + indicator.classList.remove('active'); + state.textContent = 'Paused'; + } else { + this.startPolling(); + btn.textContent = '⏸ Pause'; + indicator.classList.add('active'); + state.textContent = 'Active'; + } + }, this) + }, '⏸ Pause') + ]), + + // Interface tabs + interfaces.length > 1 ? this.renderInterfaceTabs(interfaces) : '', + // Quick Stats E('div', { 'class': 'wg-quick-stats' }, [ E('div', { 'class': 'wg-quick-stat' }, [ @@ -45,7 +244,7 @@ return view.extend({ E('span', { 'class': 'wg-quick-stat-icon' }, '🌐'), E('span', { 'class': 'wg-quick-stat-label' }, 'Interfaces') ]), - E('div', { 'class': 'wg-quick-stat-value' }, status.interface_count || 0), + E('div', { 'class': 'wg-quick-stat-value wg-stat-interfaces' }, status.interface_count || 0), E('div', { 'class': 'wg-quick-stat-sub' }, 'Active tunnels') ]), E('div', { 'class': 'wg-quick-stat' }, [ @@ -53,7 +252,7 @@ return view.extend({ E('span', { 'class': 'wg-quick-stat-icon' }, '👥'), E('span', { 'class': 'wg-quick-stat-label' }, 'Total Peers') ]), - E('div', { 'class': 'wg-quick-stat-value' }, status.total_peers || 0), + E('div', { 'class': 'wg-quick-stat-value wg-stat-total-peers' }, status.total_peers || 0), E('div', { 'class': 'wg-quick-stat-sub' }, 'Configured') ]), E('div', { 'class': 'wg-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #10b981, #34d399)' }, [ @@ -61,7 +260,7 @@ return view.extend({ E('span', { 'class': 'wg-quick-stat-icon' }, '✅'), E('span', { 'class': 'wg-quick-stat-label' }, 'Active Peers') ]), - E('div', { 'class': 'wg-quick-stat-value' }, status.active_peers || 0), + E('div', { 'class': 'wg-quick-stat-value wg-stat-active-peers' }, status.active_peers || 0), E('div', { 'class': 'wg-quick-stat-sub' }, 'Connected now') ]), E('div', { 'class': 'wg-quick-stat' }, [ @@ -69,7 +268,7 @@ return view.extend({ E('span', { 'class': 'wg-quick-stat-icon' }, '📥'), E('span', { 'class': 'wg-quick-stat-label' }, 'Downloaded') ]), - E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(status.total_rx || 0)), + E('div', { 'class': 'wg-quick-stat-value wg-stat-rx' }, api.formatBytes(status.total_rx || 0)), E('div', { 'class': 'wg-quick-stat-sub' }, 'Total received') ]), E('div', { 'class': 'wg-quick-stat' }, [ @@ -77,7 +276,7 @@ return view.extend({ E('span', { 'class': 'wg-quick-stat-icon' }, '📤'), E('span', { 'class': 'wg-quick-stat-label' }, 'Uploaded') ]), - E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(status.total_tx || 0)), + E('div', { 'class': 'wg-quick-stat-value wg-stat-tx' }, api.formatBytes(status.total_tx || 0)), E('div', { 'class': 'wg-quick-stat-sub' }, 'Total sent') ]) ]), @@ -95,7 +294,7 @@ return view.extend({ interfaces.length > 0 ? E('div', { 'class': 'wg-charts-grid' }, interfaces.map(function(iface) { - return E('div', { 'class': 'wg-interface-card' }, [ + return E('div', { 'class': 'wg-interface-card', 'data-iface': iface.name }, [ E('div', { 'class': 'wg-interface-header' }, [ E('div', { 'class': 'wg-interface-name' }, [ E('div', { 'class': 'wg-interface-icon' }, '🌐'), @@ -121,7 +320,7 @@ return view.extend({ ]), E('div', { 'class': 'wg-interface-detail' }, [ E('div', { 'class': 'wg-interface-detail-label' }, 'Traffic'), - E('div', { 'class': 'wg-interface-detail-value' }, + E('div', { 'class': 'wg-interface-detail-value wg-interface-traffic' }, '↓' + api.formatBytes(iface.rx_bytes) + ' / ↑' + api.formatBytes(iface.tx_bytes)) ]) ]) @@ -143,12 +342,12 @@ return view.extend({ E('span', { 'class': 'wg-card-title-icon' }, '👥'), 'Connected Peers' ]), - E('div', { 'class': 'wg-card-badge' }, activePeers + '/' + peers.length + ' active') + E('div', { 'class': 'wg-card-badge wg-peers-badge' }, activePeers + '/' + peers.length + ' active') ]), E('div', { 'class': 'wg-card-body' }, [ E('div', { 'class': 'wg-peer-grid' }, peers.slice(0, 6).map(function(peer) { - return E('div', { 'class': 'wg-peer-card ' + (peer.status === 'active' ? 'active' : '') }, [ + return E('div', { 'class': 'wg-peer-card ' + (peer.status === 'active' ? 'active' : ''), 'data-peer': peer.public_key, 'data-interface': peer.interface || '' }, [ E('div', { 'class': 'wg-peer-header' }, [ E('div', { 'class': 'wg-peer-info' }, [ E('div', { 'class': 'wg-peer-icon' }, peer.status === 'active' ? '✅' : '👤'), @@ -166,7 +365,7 @@ return view.extend({ ]), E('div', { 'class': 'wg-peer-detail' }, [ E('span', { 'class': 'wg-peer-detail-label' }, 'Last Handshake'), - E('span', { 'class': 'wg-peer-detail-value' }, api.formatHandshake(peer.handshake_ago)) + E('span', { 'class': 'wg-peer-detail-value', 'data-field': 'handshake' }, api.formatHandshake(peer.handshake_ago)) ]), E('div', { 'class': 'wg-peer-detail', 'style': 'grid-column: span 2' }, [ E('span', { 'class': 'wg-peer-detail-label' }, 'Allowed IPs'), @@ -191,11 +390,14 @@ return view.extend({ ]) ]) : '' ]); - + // Include CSS var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') }); document.head.appendChild(cssLink); - + + // Start auto-refresh + this.startPolling(); + return view; }, diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/peers.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/peers.js index 92798de3..098959d8 100644 --- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/peers.js +++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/peers.js @@ -5,10 +5,35 @@ 'require dom'; 'require ui'; 'require wireguard-dashboard.api as API'; +'require wireguard-dashboard.qrcode as qrcode'; return view.extend({ title: _('WireGuard Peers'), + // Store private key in session storage for QR generation + storePrivateKey: function(publicKey, privateKey) { + try { + var stored = sessionStorage.getItem('wg_peer_keys'); + var keys = stored ? JSON.parse(stored) : {}; + keys[publicKey] = privateKey; + sessionStorage.setItem('wg_peer_keys', JSON.stringify(keys)); + } catch (e) { + console.error('Failed to store private key:', e); + } + }, + + // Retrieve stored private key + getStoredPrivateKey: function(publicKey) { + try { + var stored = sessionStorage.getItem('wg_peer_keys'); + if (stored) { + var keys = JSON.parse(stored); + return keys[publicKey] || null; + } + } catch (e) {} + return null; + }, + load: function() { return Promise.all([ API.getPeers(), @@ -35,7 +60,7 @@ return view.extend({ 'class': 'cbi-button cbi-button-action', 'click': L.bind(this.handleAddPeer, this, interfaces) }, '+ ' + _('Add New Peer')), - E('span', { 'style': 'margin-left: auto; font-weight: bold;' }, + E('span', { 'class': 'peers-active-count', 'style': 'margin-left: auto; font-weight: bold;' }, _('Active: %d / %d').format(activePeers, peers.length)) ]) ]), @@ -128,8 +153,50 @@ return view.extend({ // Setup auto-refresh every 5 seconds poll.add(L.bind(function() { - return API.getPeers().then(L.bind(function(newPeers) { - // Update table dynamically + return API.getPeers().then(L.bind(function(data) { + var newPeers = (data || {}).peers || []; + var table = document.getElementById('peers-table'); + if (!table) return; + + var tbody = table.querySelector('tbody'); + if (!tbody) return; + + // Update existing rows + newPeers.forEach(function(peer, idx) { + var row = tbody.children[idx]; + if (!row) return; + + var cells = row.querySelectorAll('td'); + if (cells.length < 7) return; + + // Update status (cell 4) + var statusColor = peer.status === 'active' ? '#28a745' : + peer.status === 'idle' ? '#ffc107' : '#6c757d'; + var statusIcon = peer.status === 'active' ? '✓' : + peer.status === 'idle' ? '~' : '✗'; + var statusSpan = cells[4].querySelector('.badge'); + if (statusSpan) { + statusSpan.style.background = statusColor; + statusSpan.textContent = statusIcon + ' ' + peer.status; + } + + // Update last handshake (cell 5) + cells[5].textContent = API.formatLastHandshake(peer.handshake_ago); + + // Update RX/TX (cell 6) + var trafficDiv = cells[6].querySelector('div'); + if (trafficDiv) { + trafficDiv.innerHTML = '
↓ ' + API.formatBytes(peer.rx_bytes) + '
' + + '
↑ ' + API.formatBytes(peer.tx_bytes) + '
'; + } + }); + + // Update active count + var activePeers = newPeers.filter(function(p) { return p.status === 'active'; }).length; + var countSpan = document.querySelector('.peers-active-count'); + if (countSpan) { + countSpan.textContent = _('Active: %d / %d').format(activePeers, newPeers.length); + } }, this)); }, this), 5); @@ -286,11 +353,48 @@ return view.extend({ E('p', { 'class': 'spinning' }, _('Adding peer configuration...')) ]); + var privkey = document.getElementById('peer-privkey').value; + API.addPeer(iface, name, allowed_ips, pubkey, psk, endpoint, keepalive).then(function(result) { ui.hideModal(); if (result.success) { + // Store private key for QR generation + self.storePrivateKey(pubkey, privkey); ui.addNotification(null, E('p', result.message || _('Peer added successfully')), 'info'); - window.location.reload(); + + // Offer to generate QR code immediately + ui.showModal(_('Peer Created Successfully'), [ + E('p', {}, _('The peer has been added. Would you like to generate a QR code for mobile setup?')), + E('div', { 'style': 'background: #d4edda; padding: 1em; border-radius: 4px; margin: 1em 0;' }, [ + E('strong', {}, _('Private Key Stored')), + E('p', { 'style': 'margin: 0.5em 0 0 0; font-size: 0.9em;' }, + _('The private key has been temporarily stored in your browser session for QR generation.')) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [ + E('button', { + 'class': 'btn', + 'click': function() { + ui.hideModal(); + window.location.reload(); + } + }, _('Skip')), + ' ', + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + ui.hideModal(); + // Find the interface for QR generation + var ifaceObj = interfaces.find(function(i) { return i.name === iface; }); + self.promptForEndpointAndShowQR({ + public_key: pubkey, + short_key: pubkey.substring(0, 8), + allowed_ips: allowed_ips, + interface: iface + }, ifaceObj, privkey); + } + }, _('Generate QR Code')) + ]) + ]); } else { ui.addNotification(null, E('p', result.error || _('Failed to add peer')), 'error'); } @@ -309,25 +413,21 @@ return view.extend({ }); }, - handleShowQR: function(peer, interfaces, ev) { + promptForEndpointAndShowQR: function(peer, ifaceObj, privateKey) { var self = this; + var savedEndpoint = sessionStorage.getItem('wg_server_endpoint') || ''; - ui.showModal(_('Loading QR Code'), [ - E('p', { 'class': 'spinning' }, _('Generating QR code...')) - ]); - - // Prompt for server endpoint - ui.hideModal(); - ui.showModal(_('Server Endpoint Required'), [ + ui.showModal(_('Server Endpoint'), [ E('p', {}, _('Enter the public IP or hostname of this WireGuard server:')), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')), E('div', { 'class': 'cbi-value-field' }, [ E('input', { 'type': 'text', - 'id': 'server-endpoint', + 'id': 'qr-server-endpoint', 'class': 'cbi-input-text', - 'placeholder': 'vpn.example.com or 203.0.113.1' + 'placeholder': 'vpn.example.com or 203.0.113.1', + 'value': savedEndpoint }) ]) ]), @@ -340,55 +440,273 @@ return view.extend({ E('button', { 'class': 'btn cbi-button-action', 'click': function() { - var endpoint = document.getElementById('server-endpoint').value; + var endpoint = document.getElementById('qr-server-endpoint').value.trim(); if (!endpoint) { ui.addNotification(null, E('p', _('Please enter server endpoint')), 'error'); return; } - - ui.hideModal(); - ui.showModal(_('Generating QR Code'), [ - E('p', { 'class': 'spinning' }, _('Please wait...')) - ]); - - // Need to get private key from somewhere - this is tricky - // In real implementation, you'd need to store it or ask user - ui.addNotification(null, E('p', _('QR code generation requires the peer private key. Please use the config download option and scan manually.')), 'info'); + sessionStorage.setItem('wg_server_endpoint', endpoint); ui.hideModal(); + self.generateAndShowQR(peer, ifaceObj, privateKey, endpoint); } }, _('Generate QR')) ]) ]); }, - handleDownloadConfig: function(peer, interfaces, ev) { - ui.showModal(_('Server Endpoint Required'), [ - E('p', {}, _('Enter the public IP or hostname of this WireGuard server:')), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')), - E('div', { 'class': 'cbi-value-field' }, [ - E('input', { - 'type': 'text', - 'id': 'server-endpoint-cfg', - 'class': 'cbi-input-text', - 'placeholder': 'vpn.example.com or 203.0.113.1' - }) + generateAndShowQR: function(peer, ifaceObj, privateKey, serverEndpoint) { + var self = this; + + // Build WireGuard client config + var config = '[Interface]\n' + + 'PrivateKey = ' + privateKey + '\n' + + 'Address = ' + (peer.allowed_ips || '10.0.0.2/32') + '\n' + + 'DNS = 1.1.1.1, 1.0.0.1\n\n' + + '[Peer]\n' + + 'PublicKey = ' + (ifaceObj.public_key || '') + '\n' + + 'Endpoint = ' + serverEndpoint + ':' + (ifaceObj.listen_port || 51820) + '\n' + + 'AllowedIPs = 0.0.0.0/0, ::/0\n' + + 'PersistentKeepalive = 25'; + + // First try backend QR generation + API.generateQR(peer.interface, peer.public_key, privateKey, serverEndpoint).then(function(result) { + if (result && result.qrcode && !result.error) { + self.displayQRModal(peer, result.qrcode, config, false); + } else { + // Fall back to JavaScript QR generation + var svg = qrcode.generateSVG(config, 250); + if (svg) { + self.displayQRModal(peer, svg, config, true); + } else { + ui.addNotification(null, E('p', _('Failed to generate QR code')), 'error'); + } + } + }).catch(function(err) { + // Fall back to JavaScript QR generation + var svg = qrcode.generateSVG(config, 250); + if (svg) { + self.displayQRModal(peer, svg, config, true); + } else { + ui.addNotification(null, E('p', _('Failed to generate QR code')), 'error'); + } + }); + }, + + displayQRModal: function(peer, qrData, config, isSVG) { + var qrElement; + + if (isSVG) { + qrElement = E('div', { 'style': 'display: inline-block;' }); + qrElement.innerHTML = qrData; + } else { + qrElement = E('img', { + 'src': qrData, + 'alt': 'WireGuard QR Code', + 'style': 'max-width: 250px; max-height: 250px;' + }); + } + + ui.showModal(_('WireGuard QR Code'), [ + E('div', { 'style': 'text-align: center;' }, [ + E('h4', {}, peer.interface + ' - ' + (peer.short_key || peer.public_key.substring(0, 8))), + E('div', { 'style': 'background: white; padding: 20px; border-radius: 12px; display: inline-block; margin: 20px 0;' }, [ + qrElement + ]), + E('p', { 'style': 'color: #666;' }, _('Scan with WireGuard app on your mobile device')), + E('div', { 'style': 'display: flex; gap: 10px; justify-content: center; margin: 1em 0;' }, [ + E('button', { + 'class': 'btn', + 'click': function() { + navigator.clipboard.writeText(config).then(function() { + ui.addNotification(null, E('p', _('Configuration copied to clipboard')), 'info'); + }); + } + }, _('Copy Config')), + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + var blob = new Blob([config], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = peer.interface + '-peer.conf'; + a.click(); + URL.revokeObjectURL(url); + } + }, _('Download .conf')) + ]), + E('details', { 'style': 'text-align: left; margin-top: 1em;' }, [ + E('summary', { 'style': 'cursor: pointer; color: #06b6d4;' }, _('Show configuration')), + E('pre', { 'style': 'background: #f8f9fa; padding: 12px; border-radius: 8px; font-size: 11px; margin-top: 10px;' }, config) ]) ]), - E('div', { 'style': 'margin-top: 1em; padding: 0.75em; background: #fff3cd; border-radius: 4px;' }, [ - E('strong', {}, _('Note:')), - ' ', - _('Configuration file requires the peer private key. This was generated when the peer was created.') - ]), - E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [ + E('div', { 'class': 'right' }, [ E('button', { 'class': 'btn', 'click': ui.hideModal - }, _('Cancel')) + }, _('Close')) ]) ]); }, + handleShowQR: function(peer, interfaces, ev) { + var self = this; + var privateKey = this.getStoredPrivateKey(peer.public_key); + var ifaceObj = interfaces.find(function(i) { return i.name === peer.interface; }) || {}; + + if (!privateKey) { + // Private key not stored - ask user to input it + ui.showModal(_('Private Key Required'), [ + E('p', {}, _('To generate a QR code, the peer\'s private key is needed.')), + E('p', { 'style': 'color: #666; font-size: 0.9em;' }, + _('Private keys are only stored in your browser session immediately after peer creation. If you closed or refreshed the page, you\'ll need to enter it manually.')), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Private Key')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'text', + 'id': 'manual-private-key', + 'class': 'cbi-input-text', + 'placeholder': 'Base64 private key (44 characters)', + 'style': 'font-family: monospace;' + }) + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + var key = document.getElementById('manual-private-key').value.trim(); + if (!key || key.length !== 44) { + ui.addNotification(null, E('p', _('Please enter a valid private key (44 characters)')), 'error'); + return; + } + // Store for future use + self.storePrivateKey(peer.public_key, key); + ui.hideModal(); + self.promptForEndpointAndShowQR(peer, ifaceObj, key); + } + }, _('Continue')) + ]) + ]); + return; + } + + this.promptForEndpointAndShowQR(peer, ifaceObj, privateKey); + }, + + handleDownloadConfig: function(peer, interfaces, ev) { + var self = this; + var privateKey = this.getStoredPrivateKey(peer.public_key); + var ifaceObj = interfaces.find(function(i) { return i.name === peer.interface; }) || {}; + + var showConfigModal = function(privKey) { + var savedEndpoint = sessionStorage.getItem('wg_server_endpoint') || ''; + + ui.showModal(_('Download Configuration'), [ + E('p', {}, _('Enter the server endpoint to generate the client configuration:')), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'text', + 'id': 'cfg-server-endpoint', + 'class': 'cbi-input-text', + 'placeholder': 'vpn.example.com or 203.0.113.1', + 'value': savedEndpoint + }) + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + var endpoint = document.getElementById('cfg-server-endpoint').value.trim(); + if (!endpoint) { + ui.addNotification(null, E('p', _('Please enter server endpoint')), 'error'); + return; + } + sessionStorage.setItem('wg_server_endpoint', endpoint); + + var config = '[Interface]\n' + + 'PrivateKey = ' + privKey + '\n' + + 'Address = ' + (peer.allowed_ips || '10.0.0.2/32') + '\n' + + 'DNS = 1.1.1.1, 1.0.0.1\n\n' + + '[Peer]\n' + + 'PublicKey = ' + (ifaceObj.public_key || '') + '\n' + + 'Endpoint = ' + endpoint + ':' + (ifaceObj.listen_port || 51820) + '\n' + + 'AllowedIPs = 0.0.0.0/0, ::/0\n' + + 'PersistentKeepalive = 25'; + + var blob = new Blob([config], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = peer.interface + '-' + (peer.short_key || 'peer') + '.conf'; + a.click(); + URL.revokeObjectURL(url); + + ui.hideModal(); + ui.addNotification(null, E('p', _('Configuration file downloaded')), 'info'); + } + }, _('Download')) + ]) + ]); + }; + + if (!privateKey) { + // Private key not stored - ask user to input it + ui.showModal(_('Private Key Required'), [ + E('p', {}, _('Enter the peer\'s private key to generate the configuration file:')), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Private Key')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'type': 'text', + 'id': 'cfg-private-key', + 'class': 'cbi-input-text', + 'placeholder': 'Base64 private key (44 characters)', + 'style': 'font-family: monospace;' + }) + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + var key = document.getElementById('cfg-private-key').value.trim(); + if (!key || key.length !== 44) { + ui.addNotification(null, E('p', _('Please enter a valid private key (44 characters)')), 'error'); + return; + } + self.storePrivateKey(peer.public_key, key); + ui.hideModal(); + showConfigModal(key); + } + }, _('Continue')) + ]) + ]); + return; + } + + showConfigModal(privateKey); + }, + handleDeletePeer: function(peer, ev) { var self = this; diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/qrcodes.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/qrcodes.js index 73ffe580..1126cdbd 100644 --- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/qrcodes.js +++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/qrcodes.js @@ -4,62 +4,194 @@ 'require dom'; 'require ui'; 'require wireguard-dashboard.api as api'; +'require wireguard-dashboard.qrcode as qrcode'; return view.extend({ title: _('QR Code Generator'), - + load: function() { - return api.getConfig(); + return Promise.all([ + api.getConfig(), + api.getInterfaces(), + api.getPeers() + ]).then(function(results) { + return { + config: results[0] || {}, + interfaces: (results[1] || {}).interfaces || [], + peers: (results[2] || {}).peers || [] + }; + }); }, - - generateQRCode: function(text, size) { - // Simple QR code SVG generator using a basic encoding - // In production, this would use a proper QR library - var qrSize = size || 200; - var moduleCount = 25; // Simplified - var moduleSize = qrSize / moduleCount; - - // Create a placeholder SVG that represents the QR structure - var svg = ''; - svg += ''; - - // Simplified pattern - in real implementation, use proper QR encoding - // Draw finder patterns (corners) - var drawFinder = function(x, y) { - var s = moduleSize * 7; - svg += ''; - svg += ''; - svg += ''; - }; - - drawFinder(0, 0); - drawFinder(qrSize - moduleSize * 7, 0); - drawFinder(0, qrSize - moduleSize * 7); - - // Add some random-looking modules for visual effect - var hash = 0; - for (var i = 0; i < text.length; i++) { - hash = ((hash << 5) - hash) + text.charCodeAt(i); - } - - for (var row = 8; row < moduleCount - 8; row++) { - for (var col = 8; col < moduleCount - 8; col++) { - if (((hash + row * col) % 3) === 0) { - svg += ''; - } + + getStoredPrivateKey: function(publicKey) { + try { + var stored = sessionStorage.getItem('wg_peer_keys'); + if (stored) { + var keys = JSON.parse(stored); + return keys[publicKey] || null; } - } - - svg += ''; - return svg; + } catch (e) {} + return null; }, - + + generateQRForPeer: function(iface, peer, serverEndpoint) { + var self = this; + var privateKey = this.getStoredPrivateKey(peer.public_key); + + if (!privateKey) { + ui.showModal(_('Private Key Required'), [ + E('p', {}, _('To generate a QR code, you need the peer\'s private key.')), + E('p', {}, _('Private keys are only available immediately after peer creation for security reasons.')), + E('div', { 'class': 'wg-form-group' }, [ + E('label', {}, _('Enter Private Key:')), + E('input', { + 'type': 'text', + 'id': 'wg-private-key-input', + 'class': 'cbi-input-text', + 'placeholder': 'Base64 private key...', + 'style': 'width: 100%; font-family: monospace;' + }) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + var input = document.getElementById('wg-private-key-input'); + var key = input ? input.value.trim() : ''; + if (key && key.length === 44) { + ui.hideModal(); + self.showQRCode(iface, peer, key, serverEndpoint); + } else { + ui.addNotification(null, E('p', {}, _('Please enter a valid private key (44 characters, base64)')), 'error'); + } + } + }, _('Generate QR')) + ]) + ]); + return; + } + + this.showQRCode(iface, peer, privateKey, serverEndpoint); + }, + + showQRCode: function(iface, peer, privateKey, serverEndpoint) { + var self = this; + + // First try backend (uses qrencode if available) + api.generateQR(iface.name, peer.public_key, privateKey, serverEndpoint).then(function(result) { + if (result && result.qrcode && !result.error) { + // Backend generated QR successfully + self.displayQRModal(iface, peer, result.qrcode, result.config); + } else { + // Fall back to JavaScript QR generation + self.generateJSQR(iface, peer, privateKey, serverEndpoint); + } + }).catch(function(err) { + // Fall back to JavaScript QR generation + self.generateJSQR(iface, peer, privateKey, serverEndpoint); + }); + }, + + generateJSQR: function(iface, peer, privateKey, serverEndpoint) { + // Build WireGuard config + var config = '[Interface]\n' + + 'PrivateKey = ' + privateKey + '\n' + + 'Address = ' + (peer.allowed_ips || '10.0.0.2/32') + '\n' + + 'DNS = 1.1.1.1, 1.0.0.1\n\n' + + '[Peer]\n' + + 'PublicKey = ' + iface.public_key + '\n' + + 'Endpoint = ' + serverEndpoint + ':' + (iface.listen_port || 51820) + '\n' + + 'AllowedIPs = 0.0.0.0/0, ::/0\n' + + 'PersistentKeepalive = 25'; + + var svg = qrcode.generateSVG(config, 250); + if (svg) { + this.displayQRModal(iface, peer, svg, config, true); + } else { + ui.addNotification(null, E('p', {}, _('Failed to generate QR code. Config may be too long.')), 'error'); + } + }, + + displayQRModal: function(iface, peer, qrData, config, isSVG) { + var qrElement; + + if (isSVG) { + qrElement = E('div', { 'class': 'wg-qr-image' }); + qrElement.innerHTML = qrData; + } else { + qrElement = E('img', { + 'src': qrData, + 'alt': 'WireGuard QR Code', + 'class': 'wg-qr-image' + }); + } + + ui.showModal(_('WireGuard Configuration'), [ + E('div', { 'class': 'wg-qr-modal' }, [ + E('div', { 'class': 'wg-qr-header' }, [ + E('h4', {}, iface.name + ' - Peer ' + (peer.short_key || peer.public_key.substring(0, 8))) + ]), + E('div', { 'class': 'wg-qr-container' }, [qrElement]), + E('p', { 'class': 'wg-qr-hint' }, _('Scan with WireGuard app on your mobile device')), + E('div', { 'class': 'wg-qr-actions' }, [ + E('button', { + 'class': 'btn', + 'click': function() { + navigator.clipboard.writeText(config).then(function() { + ui.addNotification(null, E('p', {}, _('Configuration copied to clipboard')), 'info'); + }); + } + }, _('Copy Config')), + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + var blob = new Blob([config], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = iface.name + '-peer.conf'; + a.click(); + URL.revokeObjectURL(url); + } + }, _('Download .conf')) + ]), + E('details', { 'class': 'wg-config-details' }, [ + E('summary', {}, _('Show configuration')), + E('pre', {}, config) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, _('Close')) + ]) + ]); + }, + render: function(data) { var self = this; - var interfaces = (data || {}).interfaces || []; - + var interfaces = data.interfaces || []; + var configData = (data.config || {}).interfaces || []; + var peers = data.peers || []; + + // Merge interface data with config data + interfaces = interfaces.map(function(iface) { + var cfg = configData.find(function(c) { return c.name === iface.name; }) || {}; + return Object.assign({}, iface, { + peers: cfg.peers || [], + public_key: cfg.public_key || iface.public_key + }); + }); + var view = E('div', { 'class': 'wireguard-dashboard' }, [ E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }), + // Header E('div', { 'class': 'wg-header' }, [ E('div', { 'class': 'wg-logo' }, [ @@ -67,172 +199,233 @@ return view.extend({ E('div', { 'class': 'wg-logo-text' }, ['QR ', E('span', {}, 'Generator')]) ]) ]), - - // Info banner - E('div', { 'class': 'wg-info-banner' }, [ - E('span', { 'class': 'wg-info-icon' }, 'ℹ️'), - E('div', {}, [ - E('strong', {}, 'Mobile Configuration'), - E('p', {}, 'Generate QR codes to quickly configure WireGuard on mobile devices. ' + - 'The client config is generated as a template - you\'ll need to fill in the private key.') + + // Server endpoint input + E('div', { 'class': 'wg-card' }, [ + E('div', { 'class': 'wg-card-header' }, [ + E('div', { 'class': 'wg-card-title' }, [ + E('span', { 'class': 'wg-card-title-icon' }, '🌐'), + _('Server Endpoint') + ]) + ]), + E('div', { 'class': 'wg-card-body' }, [ + E('p', { 'style': 'margin-bottom: 12px; color: var(--wg-text-secondary);' }, + _('Enter the public IP or hostname of this WireGuard server:')), + E('div', { 'class': 'wg-form-row' }, [ + E('input', { + 'type': 'text', + 'id': 'wg-server-endpoint', + 'class': 'cbi-input-text', + 'placeholder': 'e.g., vpn.example.com or 203.0.113.1', + 'style': 'flex: 1;' + }), + E('button', { + 'class': 'wg-btn wg-btn-primary', + 'click': function() { + var input = document.getElementById('wg-server-endpoint'); + if (input && input.value.trim()) { + sessionStorage.setItem('wg_server_endpoint', input.value.trim()); + ui.addNotification(null, E('p', {}, _('Server endpoint saved')), 'info'); + } + } + }, _('Save')) + ]) ]) ]), - - // Interfaces with QR generation + + // Interface cards interfaces.length > 0 ? - interfaces.map(function(iface) { - return E('div', { 'class': 'wg-card' }, [ - E('div', { 'class': 'wg-card-header' }, [ - E('div', { 'class': 'wg-card-title' }, [ - E('span', { 'class': 'wg-card-title-icon' }, '🌐'), - 'Interface: ' + iface.name + E('div', { 'class': 'wg-interface-list' }, + interfaces.map(function(iface) { + var ifacePeers = peers.filter(function(p) { return p.interface === iface.name; }); + + return E('div', { 'class': 'wg-card' }, [ + E('div', { 'class': 'wg-card-header' }, [ + E('div', { 'class': 'wg-card-title' }, [ + E('span', { 'class': 'wg-card-title-icon' }, '🔐'), + iface.name + ]), + E('div', { 'class': 'wg-card-badge' }, ifacePeers.length + ' peers') ]), - E('div', { 'class': 'wg-card-badge' }, (iface.peers || []).length + ' peers') - ]), - E('div', { 'class': 'wg-card-body' }, [ - E('div', { 'class': 'wg-qr-grid' }, - (iface.peers || []).map(function(peer, idx) { - // Generate client config template - var clientConfig = '[Interface]\n' + - 'PrivateKey = \n' + - 'Address = ' + (peer.allowed_ips || '10.0.0.' + (idx + 2) + '/32') + '\n' + - 'DNS = 1.1.1.1\n\n' + - '[Peer]\n' + - 'PublicKey = ' + iface.public_key + '\n' + - 'Endpoint = :' + (iface.listen_port || 51820) + '\n' + - 'AllowedIPs = 0.0.0.0/0, ::/0\n' + - 'PersistentKeepalive = 25'; - - return E('div', { 'class': 'wg-qr-card' }, [ - E('div', { 'class': 'wg-qr-header' }, [ - E('span', { 'class': 'wg-qr-icon' }, '👤'), - E('div', {}, [ - E('h4', {}, 'Peer ' + (idx + 1)), - E('code', {}, peer.public_key.substring(0, 16) + '...') - ]) - ]), - E('div', { 'class': 'wg-qr-code', 'data-config': clientConfig }, [ - E('div', { 'class': 'wg-qr-placeholder' }, [ - E('span', {}, '📱'), - E('p', {}, 'QR Code Preview'), - E('small', {}, 'Scan with WireGuard app') - ]) - ]), - E('div', { 'class': 'wg-qr-actions' }, [ - E('button', { - 'class': 'wg-btn', - 'click': function() { - // Copy config to clipboard - navigator.clipboard.writeText(clientConfig).then(function() { - ui.addNotification(null, E('p', {}, 'Configuration copied to clipboard!'), 'info'); - }); - } - }, '📋 Copy Config'), - E('button', { + E('div', { 'class': 'wg-card-body' }, [ + E('div', { 'class': 'wg-interface-info' }, [ + E('div', { 'class': 'wg-info-item' }, [ + E('span', { 'class': 'wg-info-label' }, _('Public Key:')), + E('code', {}, (iface.public_key || 'N/A').substring(0, 20) + '...') + ]), + E('div', { 'class': 'wg-info-item' }, [ + E('span', { 'class': 'wg-info-label' }, _('Listen Port:')), + E('span', {}, iface.listen_port || 51820) + ]) + ]), + + ifacePeers.length > 0 ? + E('div', { 'class': 'wg-peer-list' }, + ifacePeers.map(function(peer) { + return E('div', { 'class': 'wg-peer-item' }, [ + E('div', { 'class': 'wg-peer-info' }, [ + E('span', { 'class': 'wg-peer-icon' }, '👤'), + E('div', {}, [ + E('strong', {}, peer.short_key || peer.public_key.substring(0, 8)), + E('div', { 'class': 'wg-peer-ips' }, peer.allowed_ips || 'No IPs') + ]) + ]), + E('button', { 'class': 'wg-btn wg-btn-primary', 'click': function() { - // Download config as file - var blob = new Blob([clientConfig], { type: 'text/plain' }); - var url = URL.createObjectURL(blob); - var a = document.createElement('a'); - a.href = url; - a.download = iface.name + '-peer' + (idx + 1) + '.conf'; - a.click(); - URL.revokeObjectURL(url); + var endpoint = sessionStorage.getItem('wg_server_endpoint'); + if (!endpoint) { + var input = document.getElementById('wg-server-endpoint'); + endpoint = input ? input.value.trim() : ''; + } + if (!endpoint) { + ui.addNotification(null, E('p', {}, _('Please enter the server endpoint first')), 'warning'); + return; + } + self.generateQRForPeer(iface, peer, endpoint); } - }, '💾 Download .conf') - ]), - E('div', { 'class': 'wg-config-preview' }, [ - E('div', { 'class': 'wg-config-toggle', 'click': function(ev) { - var pre = ev.target.parentNode.querySelector('pre'); - pre.style.display = pre.style.display === 'none' ? 'block' : 'none'; - }}, '▶ Show configuration'), - E('pre', { 'style': 'display: none' }, clientConfig) - ]) - ]); - }) - ) - ]) - ]); - }) : + }, '📱 ' + _('QR Code')) + ]); + }) + ) : + E('div', { 'class': 'wg-empty-peers' }, _('No peers configured for this interface')) + ]) + ]); + }) + ) : E('div', { 'class': 'wg-empty' }, [ E('div', { 'class': 'wg-empty-icon' }, '📱'), - E('div', { 'class': 'wg-empty-text' }, 'No WireGuard interfaces configured'), - E('p', {}, 'Create an interface to generate QR codes for mobile clients') + E('div', { 'class': 'wg-empty-text' }, _('No WireGuard interfaces configured')), + E('p', {}, _('Create a WireGuard interface to generate QR codes')) ]) ]); - - // Additional CSS - var css = ` - .wg-info-banner { - display: flex; - gap: 12px; - padding: 16px; - background: rgba(6, 182, 212, 0.1); - border: 1px solid rgba(6, 182, 212, 0.3); - border-radius: 10px; - margin-bottom: 20px; + + // Restore saved endpoint + setTimeout(function() { + var saved = sessionStorage.getItem('wg_server_endpoint'); + if (saved) { + var input = document.getElementById('wg-server-endpoint'); + if (input) input.value = saved; } - .wg-info-banner .wg-info-icon { font-size: 24px; } - .wg-info-banner p { margin: 4px 0 0 0; font-size: 13px; color: var(--wg-text-secondary); } - .wg-qr-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; } - .wg-qr-card { + }, 100); + + // Add CSS + var css = ` + .wg-form-row { + display: flex; + gap: 10px; + align-items: center; + } + .wg-interface-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + padding: 12px; + background: var(--wg-bg-tertiary); + border-radius: 8px; + margin-bottom: 16px; + } + .wg-info-item { + display: flex; + flex-direction: column; + gap: 4px; + } + .wg-info-label { + font-size: 12px; + color: var(--wg-text-muted); + } + .wg-peer-list { + display: flex; + flex-direction: column; + gap: 10px; + } + .wg-peer-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; background: var(--wg-bg-tertiary); border: 1px solid var(--wg-border); - border-radius: 12px; - padding: 20px; + border-radius: 8px; } - .wg-qr-header { + .wg-peer-info { display: flex; align-items: center; gap: 12px; - margin-bottom: 16px; } - .wg-qr-icon { font-size: 28px; } - .wg-qr-header h4 { margin: 0; font-size: 16px; } - .wg-qr-header code { font-size: 10px; color: var(--wg-text-muted); } - .wg-qr-code { - background: white; - border-radius: 12px; + .wg-peer-icon { + font-size: 24px; + } + .wg-peer-ips { + font-size: 12px; + color: var(--wg-text-muted); + font-family: monospace; + } + .wg-empty-peers { + text-align: center; padding: 20px; + color: var(--wg-text-muted); + } + .wg-qr-modal { + text-align: center; + } + .wg-qr-container { + background: white; + padding: 20px; + border-radius: 12px; + display: inline-block; + margin: 20px 0; + } + .wg-qr-image { + max-width: 250px; + max-height: 250px; + } + .wg-qr-hint { + color: var(--wg-text-secondary); + font-size: 14px; + } + .wg-qr-actions { display: flex; justify-content: center; - margin-bottom: 16px; + gap: 10px; + margin: 16px 0; } - .wg-qr-placeholder { - text-align: center; - color: #333; + .wg-config-details { + text-align: left; + margin-top: 16px; } - .wg-qr-placeholder span { font-size: 48px; display: block; margin-bottom: 8px; } - .wg-qr-placeholder p { margin: 0; font-weight: 600; } - .wg-qr-placeholder small { font-size: 11px; color: #666; } - .wg-qr-actions { display: flex; gap: 10px; margin-bottom: 12px; } - .wg-config-preview { margin-top: 12px; } - .wg-config-toggle { + .wg-config-details summary { cursor: pointer; - font-size: 12px; color: var(--wg-accent-cyan); + margin-bottom: 8px; } - .wg-config-preview pre { - margin-top: 10px; + .wg-config-details pre { + background: var(--wg-bg-tertiary); padding: 12px; - background: var(--wg-bg-primary); border-radius: 8px; font-size: 11px; - line-height: 1.6; overflow-x: auto; white-space: pre-wrap; } + .wg-form-group { + margin: 16px 0; + text-align: left; + } + .wg-form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + } `; var style = E('style', {}, css); document.head.appendChild(style); - + var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') }); document.head.appendChild(cssLink); - + return view; }, - + handleSaveApply: null, handleSave: null, handleReset: null diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/traffic.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/traffic.js index 92905243..1457c309 100644 --- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/traffic.js +++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/traffic.js @@ -8,12 +8,84 @@ return view.extend({ title: _('WireGuard Traffic'), - + pollInterval: 5, + pollActive: true, + load: function() { return api.getTraffic(); }, - + + updateTrafficStats: function(traffic) { + var totalRx = traffic.total_rx || 0; + var totalTx = traffic.total_tx || 0; + var totalTraffic = totalRx + totalTx; + + // Update totals + var rxEl = document.querySelector('.wg-traffic-total-rx'); + var txEl = document.querySelector('.wg-traffic-total-tx'); + var totalEl = document.querySelector('.wg-traffic-total'); + + if (rxEl) { + rxEl.textContent = api.formatBytes(totalRx); + rxEl.classList.add('wg-value-updated'); + setTimeout(function() { rxEl.classList.remove('wg-value-updated'); }, 500); + } + if (txEl) { + txEl.textContent = api.formatBytes(totalTx); + txEl.classList.add('wg-value-updated'); + setTimeout(function() { txEl.classList.remove('wg-value-updated'); }, 500); + } + if (totalEl) { + totalEl.textContent = api.formatBytes(totalTraffic); + } + + // Update per-interface stats + var interfaces = traffic.interfaces || []; + interfaces.forEach(function(iface) { + var card = document.querySelector('.wg-interface-card[data-iface="' + iface.name + '"]'); + if (!card) return; + + var ifaceTotal = (iface.total_rx || 0) + (iface.total_tx || 0); + var rxPct = totalTraffic > 0 ? ((iface.total_rx || 0) / totalTraffic * 100) : 0; + var txPct = totalTraffic > 0 ? ((iface.total_tx || 0) / totalTraffic * 100) : 0; + + // Update traffic values + var rxSpan = card.querySelector('.wg-iface-rx'); + var txSpan = card.querySelector('.wg-iface-tx'); + var totalSpan = card.querySelector('.wg-iface-total'); + + if (rxSpan) rxSpan.textContent = '↓ ' + api.formatBytes(iface.total_rx || 0); + if (txSpan) txSpan.textContent = '↑ ' + api.formatBytes(iface.total_tx || 0); + if (totalSpan) totalSpan.textContent = api.formatBytes(ifaceTotal) + ' total'; + + // Update progress bars + var rxBar = card.querySelector('.wg-traffic-bar-rx'); + var txBar = card.querySelector('.wg-traffic-bar-tx'); + if (rxBar) rxBar.style.width = rxPct + '%'; + if (txBar) txBar.style.width = txPct + '%'; + }); + }, + + startPolling: function() { + var self = this; + this.pollActive = true; + + poll.add(L.bind(function() { + if (!this.pollActive) return Promise.resolve(); + + return api.getTraffic().then(L.bind(function(data) { + this.updateTrafficStats(data || {}); + }, this)); + }, this), this.pollInterval); + }, + + stopPolling: function() { + this.pollActive = false; + poll.stop(); + }, + render: function(data) { + var self = this; var traffic = data || {}; var interfaces = traffic.interfaces || []; var totalRx = traffic.total_rx || 0; @@ -37,7 +109,7 @@ return view.extend({ E('span', { 'class': 'wg-quick-stat-icon' }, '📥'), E('span', { 'class': 'wg-quick-stat-label' }, 'Total Downloaded') ]), - E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(totalRx)), + E('div', { 'class': 'wg-quick-stat-value wg-traffic-total-rx' }, api.formatBytes(totalRx)), E('div', { 'class': 'wg-quick-stat-sub' }, 'All interfaces combined') ]), E('div', { 'class': 'wg-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #0ea5e9, #38bdf8)' }, [ @@ -45,7 +117,7 @@ return view.extend({ E('span', { 'class': 'wg-quick-stat-icon' }, '📤'), E('span', { 'class': 'wg-quick-stat-label' }, 'Total Uploaded') ]), - E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(totalTx)), + E('div', { 'class': 'wg-quick-stat-value wg-traffic-total-tx' }, api.formatBytes(totalTx)), E('div', { 'class': 'wg-quick-stat-sub' }, 'All interfaces combined') ]), E('div', { 'class': 'wg-quick-stat' }, [ @@ -53,7 +125,7 @@ return view.extend({ E('span', { 'class': 'wg-quick-stat-icon' }, '📈'), E('span', { 'class': 'wg-quick-stat-label' }, 'Total Traffic') ]), - E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(totalTraffic)), + E('div', { 'class': 'wg-quick-stat-value wg-traffic-total' }, api.formatBytes(totalTraffic)), E('div', { 'class': 'wg-quick-stat-sub' }, 'RX + TX combined') ]) ]), @@ -72,21 +144,21 @@ return view.extend({ var ifaceTotal = (iface.total_rx || 0) + (iface.total_tx || 0); var rxPct = totalTraffic > 0 ? ((iface.total_rx || 0) / totalTraffic * 100) : 0; var txPct = totalTraffic > 0 ? ((iface.total_tx || 0) / totalTraffic * 100) : 0; - - return E('div', { 'class': 'wg-interface-card', 'style': 'margin-bottom: 16px' }, [ + + return E('div', { 'class': 'wg-interface-card', 'data-iface': iface.name, 'style': 'margin-bottom: 16px' }, [ E('div', { 'class': 'wg-interface-header' }, [ E('div', { 'class': 'wg-interface-name' }, [ E('div', { 'class': 'wg-interface-icon' }, '🌐'), E('div', {}, [ E('h3', {}, iface.name), - E('p', {}, api.formatBytes(ifaceTotal) + ' total') + E('p', { 'class': 'wg-iface-total' }, api.formatBytes(ifaceTotal) + ' total') ]) ]) ]), E('div', { 'class': 'wg-traffic-bar' }, [ E('div', { 'class': 'wg-traffic-bar-header' }, [ - E('span', { 'style': 'color: #10b981' }, '↓ ' + api.formatBytes(iface.total_rx || 0)), - E('span', { 'style': 'color: #0ea5e9' }, '↑ ' + api.formatBytes(iface.total_tx || 0)) + E('span', { 'class': 'wg-iface-rx', 'style': 'color: #10b981' }, '↓ ' + api.formatBytes(iface.total_rx || 0)), + E('span', { 'class': 'wg-iface-tx', 'style': 'color: #0ea5e9' }, '↑ ' + api.formatBytes(iface.total_tx || 0)) ]), E('div', { 'class': 'wg-traffic-bar-track' }, [ E('div', { 'class': 'wg-traffic-bar-rx', 'style': 'width:' + rxPct + '%' }), @@ -129,10 +201,13 @@ return view.extend({ // Include CSS var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') }); document.head.appendChild(cssLink); - + + // Start auto-refresh + this.startPolling(); + return view; }, - + handleSaveApply: null, handleSave: null, handleReset: null diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/api.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/api.js index 4b9a8bd2..4a7f6fd7 100644 --- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/api.js +++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/api.js @@ -92,6 +92,26 @@ function formatLastHandshake(timestamp) { return Math.floor(diff / 86400) + 'd ago'; } +function getPeerStatusClass(status) { + if (status === 'active') return 'active'; + if (status === 'idle') return 'idle'; + return 'inactive'; +} + +function shortenKey(key, length) { + if (!key) return 'N/A'; + length = length || 8; + return key.substring(0, length) + '...'; +} + +function formatHandshake(seconds) { + if (!seconds || seconds === 0) return 'Never'; + if (seconds < 60) return seconds + 's ago'; + if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'; + if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago'; + return Math.floor(seconds / 86400) + 'd ago'; +} + return baseclass.extend({ getStatus: callStatus, getPeers: callGetPeers, @@ -105,6 +125,9 @@ return baseclass.extend({ generateQR: callGenerateQR, formatBytes: formatBytes, formatLastHandshake: formatLastHandshake, + getPeerStatusClass: getPeerStatusClass, + shortenKey: shortenKey, + formatHandshake: formatHandshake, // Aggregate function for overview page getAllData: function() { diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/chart.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/chart.js new file mode 100644 index 00000000..6ad5432f --- /dev/null +++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/chart.js @@ -0,0 +1,283 @@ +'use strict'; + +/** + * Lightweight SVG sparkline chart component for WireGuard Dashboard + * No external dependencies - pure SVG generation + */ + +return { + // Default options + defaults: { + width: 200, + height: 50, + strokeWidth: 2, + fill: true, + lineColor: '#0ea5e9', + fillColor: 'rgba(14, 165, 233, 0.1)', + gridColor: 'rgba(255, 255, 255, 0.1)', + showGrid: false, + animate: true + }, + + /** + * Generate SVG sparkline from data points + * @param {number[]} data - Array of numeric values + * @param {Object} opts - Chart options + * @returns {string} SVG markup string + */ + sparkline: function(data, opts) { + opts = Object.assign({}, this.defaults, opts || {}); + + if (!data || data.length < 2) { + return this.emptyChart(opts); + } + + var width = opts.width; + var height = opts.height; + var padding = 4; + var chartWidth = width - padding * 2; + var chartHeight = height - padding * 2; + + // Normalize data + var max = Math.max.apply(null, data); + var min = Math.min.apply(null, data); + var range = max - min || 1; + + // Generate points + var points = []; + var step = chartWidth / (data.length - 1); + for (var i = 0; i < data.length; i++) { + var x = padding + i * step; + var y = padding + chartHeight - ((data[i] - min) / range * chartHeight); + points.push(x + ',' + y); + } + + var pathData = 'M' + points.join(' L'); + + // Build fill path (closed area under line) + var fillPath = ''; + if (opts.fill) { + fillPath = pathData + + ' L' + (padding + chartWidth) + ',' + (padding + chartHeight) + + ' L' + padding + ',' + (padding + chartHeight) + ' Z'; + } + + // Build SVG + var svg = ''; + + // Grid lines + if (opts.showGrid) { + for (var g = 0; g < 4; g++) { + var gy = padding + (chartHeight / 3) * g; + svg += ''; + } + } + + // Fill area + if (opts.fill && fillPath) { + svg += ''; + } + + // Line + svg += ''; + + svg += ''; + return svg; + }, + + /** + * Generate dual-line chart for RX/TX comparison + * @param {number[]} rxData - Download data points + * @param {number[]} txData - Upload data points + * @param {Object} opts - Chart options + * @returns {string} SVG markup string + */ + dualSparkline: function(rxData, txData, opts) { + opts = Object.assign({}, this.defaults, { + width: 300, + height: 60, + rxColor: '#10b981', + txColor: '#0ea5e9', + rxFill: 'rgba(16, 185, 129, 0.1)', + txFill: 'rgba(14, 165, 233, 0.1)' + }, opts || {}); + + var width = opts.width; + var height = opts.height; + var padding = 4; + var chartWidth = width - padding * 2; + var chartHeight = height - padding * 2; + + // Combine for scale + var allData = (rxData || []).concat(txData || []); + if (allData.length < 2) { + return this.emptyChart(opts); + } + + var max = Math.max.apply(null, allData); + var min = 0; + var range = max - min || 1; + + var svg = ''; + + // Draw each line + var datasets = [ + { data: rxData, color: opts.rxColor, fill: opts.rxFill, label: 'RX' }, + { data: txData, color: opts.txColor, fill: opts.txFill, label: 'TX' } + ]; + + datasets.forEach(function(ds) { + if (!ds.data || ds.data.length < 2) return; + + var step = chartWidth / (ds.data.length - 1); + var points = []; + for (var i = 0; i < ds.data.length; i++) { + var x = padding + i * step; + var y = padding + chartHeight - ((ds.data[i] - min) / range * chartHeight); + points.push(x + ',' + y); + } + + var pathData = 'M' + points.join(' L'); + + // Fill + if (opts.fill) { + var fillPath = pathData + + ' L' + (padding + chartWidth) + ',' + (padding + chartHeight) + + ' L' + padding + ',' + (padding + chartHeight) + ' Z'; + svg += ''; + } + + // Line + svg += ''; + }); + + // Legend + svg += '↓ RX'; + svg += '↑ TX'; + + svg += ''; + return svg; + }, + + /** + * Generate empty/placeholder chart + * @param {Object} opts - Chart options + * @returns {string} SVG markup string + */ + emptyChart: function(opts) { + var width = opts.width || 200; + var height = opts.height || 50; + + return '' + + '' + + 'No data' + + ''; + }, + + /** + * Estimate path length for animation + */ + estimatePathLength: function(points, width, height) { + return Math.sqrt(width * width + height * height) * 1.5; + }, + + /** + * Create chart container element + * @param {string} svgContent - SVG markup + * @param {string} title - Chart title + * @returns {HTMLElement} Container element + */ + createContainer: function(svgContent, title) { + var container = document.createElement('div'); + container.className = 'wg-chart-container'; + + if (title) { + var titleEl = document.createElement('div'); + titleEl.className = 'wg-chart-title'; + titleEl.textContent = title; + container.appendChild(titleEl); + } + + var chartEl = document.createElement('div'); + chartEl.className = 'wg-chart-body'; + chartEl.innerHTML = svgContent; + container.appendChild(chartEl); + + return container; + }, + + /** + * Traffic history ring buffer manager + */ + TrafficHistory: { + maxPoints: 60, + data: {}, + + add: function(ifaceName, rx, tx) { + if (!this.data[ifaceName]) { + this.data[ifaceName] = { rx: [], tx: [], timestamps: [] }; + } + + var entry = this.data[ifaceName]; + entry.rx.push(rx); + entry.tx.push(tx); + entry.timestamps.push(Date.now()); + + // Trim to max points + if (entry.rx.length > this.maxPoints) { + entry.rx.shift(); + entry.tx.shift(); + entry.timestamps.shift(); + } + }, + + get: function(ifaceName) { + return this.data[ifaceName] || { rx: [], tx: [], timestamps: [] }; + }, + + getRates: function(ifaceName) { + var entry = this.data[ifaceName]; + if (!entry || entry.rx.length < 2) return { rx: [], tx: [] }; + + var rxRates = []; + var txRates = []; + for (var i = 1; i < entry.rx.length; i++) { + var timeDiff = (entry.timestamps[i] - entry.timestamps[i - 1]) / 1000; + if (timeDiff > 0) { + rxRates.push((entry.rx[i] - entry.rx[i - 1]) / timeDiff); + txRates.push((entry.tx[i] - entry.tx[i - 1]) / timeDiff); + } + } + return { rx: rxRates, tx: txRates }; + }, + + clear: function(ifaceName) { + if (ifaceName) { + delete this.data[ifaceName]; + } else { + this.data = {}; + } + } + } +}; diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/dashboard.css b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/dashboard.css index ad379755..5aab2df6 100644 --- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/dashboard.css +++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/dashboard.css @@ -810,3 +810,333 @@ .wg-btn-primary:hover { box-shadow: var(--wg-shadow-glow); } + +.wg-btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* Auto-refresh control */ +.wg-refresh-control { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + margin-bottom: 16px; + padding: 12px 16px; + background: var(--wg-bg-secondary); + border: 1px solid var(--wg-border); + border-radius: var(--wg-radius); +} + +.wg-refresh-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--wg-text-secondary); +} + +.wg-refresh-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--wg-text-muted); +} + +.wg-refresh-indicator.active { + background: var(--wg-accent-green); + animation: pulse-wg 1.5s ease-in-out infinite; + box-shadow: 0 0 8px var(--wg-accent-green); +} + +.wg-refresh-state { + font-weight: 600; +} + +/* Value update animation */ +@keyframes value-flash { + 0% { background-color: transparent; } + 50% { background-color: rgba(6, 182, 212, 0.3); } + 100% { background-color: transparent; } +} + +.wg-value-updated { + animation: value-flash 0.5s ease-out; + border-radius: 4px; +} + +/* Interface tabs */ +.wg-interface-tabs { + display: flex; + gap: 8px; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--wg-border); + overflow-x: auto; +} + +.wg-tab { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--wg-bg-tertiary); + border: 1px solid var(--wg-border); + border-radius: 8px; + color: var(--wg-text-secondary); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + font-size: 13px; +} + +.wg-tab:hover { + border-color: var(--wg-accent-cyan); +} + +.wg-tab.active { + background: var(--wg-tunnel-gradient); + border-color: transparent; + color: white; +} + +.wg-tab-badge { + background: rgba(255, 255, 255, 0.2); + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; +} + +/* Wizard stepper */ +.wg-stepper { + display: flex; + justify-content: space-between; + margin-bottom: 24px; + padding: 0 20px; +} + +.wg-stepper-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + flex: 1; + position: relative; +} + +.wg-stepper-item:not(:last-child)::after { + content: ''; + position: absolute; + top: 16px; + left: calc(50% + 20px); + width: calc(100% - 40px); + height: 2px; + background: var(--wg-border); +} + +.wg-stepper-item.completed::after { + background: var(--wg-accent-green); +} + +.wg-stepper-circle { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--wg-bg-tertiary); + border: 2px solid var(--wg-border); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + z-index: 1; +} + +.wg-stepper-item.active .wg-stepper-circle { + background: var(--wg-tunnel-gradient); + border-color: transparent; + color: white; +} + +.wg-stepper-item.completed .wg-stepper-circle { + background: var(--wg-accent-green); + border-color: transparent; + color: white; +} + +.wg-stepper-title { + font-size: 11px; + color: var(--wg-text-muted); + text-transform: uppercase; +} + +.wg-stepper-item.active .wg-stepper-title, +.wg-stepper-item.completed .wg-stepper-title { + color: var(--wg-text-primary); +} + +/* Chart styles */ +.wg-chart-container { + background: var(--wg-bg-primary); + border-radius: var(--wg-radius); + padding: 20px; + margin: 20px 0; +} + +.wg-chart { + width: 100%; + height: 150px; +} + +.wg-chart-title { + font-size: 12px; + color: var(--wg-text-muted); + text-transform: uppercase; + margin-bottom: 12px; +} + +/* Traffic history */ +.wg-traffic-history { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wg-traffic-history-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--wg-bg-tertiary); + border-radius: var(--wg-radius); +} + +.wg-traffic-history-time { + font-size: 11px; + color: var(--wg-text-muted); + font-family: var(--wg-font-mono); + min-width: 60px; +} + +.wg-traffic-history-bar { + flex: 1; + height: 6px; + background: var(--wg-bg-primary); + border-radius: 3px; + overflow: hidden; +} + +.wg-traffic-history-fill { + height: 100%; + background: var(--wg-tunnel-gradient); + transition: width 0.3s ease; +} + +/* Interface list */ +.wg-interface-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Form group */ +.wg-form-group { + margin: 16px 0; +} + +.wg-form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + font-size: 13px; +} + +.wg-form-row { + display: flex; + gap: 10px; + align-items: center; +} + +/* Notification badge */ +.wg-notification { + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + background: var(--wg-bg-secondary); + border: 1px solid var(--wg-border); + border-radius: var(--wg-radius); + box-shadow: var(--wg-shadow); + z-index: 1000; + animation: slide-in 0.3s ease-out; +} + +.wg-notification.success { + border-color: var(--wg-accent-green); +} + +.wg-notification.error { + border-color: var(--wg-accent-red); +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateX(100px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Chart animations */ +@keyframes wg-chart-draw { + to { + stroke-dashoffset: 0; + } +} + +.wg-chart-sparkline { + overflow: visible; +} + +.wg-chart-dot { + animation: pulse-wg 2s ease-in-out infinite; +} + +.wg-chart-body { + display: flex; + align-items: center; + justify-content: center; +} + +.wg-chart-legend { + display: flex; + gap: 16px; + margin-top: 12px; + justify-content: center; +} + +.wg-chart-legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--wg-text-secondary); +} + +.wg-chart-legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.wg-chart-legend-dot.rx { + background: var(--wg-accent-green); +} + +.wg-chart-legend-dot.tx { + background: var(--wg-accent-blue); +} diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/qrcode.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/qrcode.js new file mode 100644 index 00000000..b8bfbf3f --- /dev/null +++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/qrcode.js @@ -0,0 +1,355 @@ +'use strict'; +'require baseclass'; + +/** + * Minimal QR Code Generator for WireGuard Dashboard + * Generates QR codes using Reed-Solomon error correction + * Based on QR Code specification ISO/IEC 18004:2015 + */ + +// QR Code version 5 (37x37 modules) with error correction level L +// This size supports up to 154 alphanumeric chars or 106 bytes (sufficient for WireGuard configs) +var QR_VERSION = 5; +var QR_SIZE = 37; +var QR_EC_LEVEL = 'L'; + +// Generator polynomial for Reed-Solomon (version 5, EC level L uses 26 EC codewords) +var RS_GENERATOR = [1, 212, 246, 77, 73, 195, 192, 75, 98, 5, 70, 103, 177, 22, 217, 138, 51, 181, 246, 72, 25, 18, 46, 228, 74, 216, 195]; + +// Galois Field 256 tables +var GF_EXP = []; +var GF_LOG = []; + +// Initialize Galois Field tables +(function() { + var x = 1; + for (var i = 0; i < 256; i++) { + GF_EXP[i] = x; + GF_LOG[x] = i; + x = x << 1; + if (x >= 256) x ^= 0x11d; + } + GF_LOG[1] = 0; +})(); + +// Galois Field multiplication +function gfMul(a, b) { + if (a === 0 || b === 0) return 0; + return GF_EXP[(GF_LOG[a] + GF_LOG[b]) % 255]; +} + +// Reed-Solomon encoding +function rsEncode(data, nsym) { + var gen = RS_GENERATOR.slice(0, nsym + 1); + var res = new Array(data.length + nsym).fill(0); + for (var i = 0; i < data.length; i++) { + res[i] = data[i]; + } + + for (var i = 0; i < data.length; i++) { + var coef = res[i]; + if (coef !== 0) { + for (var j = 0; j < gen.length; j++) { + res[i + j] ^= gfMul(gen[j], coef); + } + } + } + + return res.slice(data.length); +} + +// Encode text to bytes +function textToBytes(text) { + var bytes = []; + for (var i = 0; i < text.length; i++) { + var c = text.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else if (c < 2048) { + bytes.push((c >> 6) | 192); + bytes.push((c & 63) | 128); + } else { + bytes.push((c >> 12) | 224); + bytes.push(((c >> 6) & 63) | 128); + bytes.push((c & 63) | 128); + } + } + return bytes; +} + +// Create data codewords with mode indicator and length +function createDataCodewords(text) { + var bytes = textToBytes(text); + var data = []; + + // Mode indicator: 0100 (byte mode) + // Character count indicator: 8 bits for version 1-9 + var header = (4 << 8) | bytes.length; + data.push((header >> 8) & 0xff); + data.push(header & 0xff); + + // Shift to add 4-bit mode + var bits = []; + bits.push(0, 1, 0, 0); // Byte mode + + // 8-bit count + for (var i = 7; i >= 0; i--) { + bits.push((bytes.length >> i) & 1); + } + + // Data bits + for (var i = 0; i < bytes.length; i++) { + for (var j = 7; j >= 0; j--) { + bits.push((bytes[i] >> j) & 1); + } + } + + // Terminator + for (var i = 0; i < 4 && bits.length < 108 * 8; i++) { + bits.push(0); + } + + // Pad to byte boundary + while (bits.length % 8 !== 0) { + bits.push(0); + } + + // Pad codewords (236 and 17 alternating) + var padBytes = [236, 17]; + var padIdx = 0; + while (bits.length < 108 * 8) { + for (var j = 7; j >= 0; j--) { + bits.push((padBytes[padIdx] >> j) & 1); + } + padIdx = (padIdx + 1) % 2; + } + + // Convert bits to bytes + data = []; + for (var i = 0; i < bits.length; i += 8) { + var byte = 0; + for (var j = 0; j < 8; j++) { + byte = (byte << 1) | bits[i + j]; + } + data.push(byte); + } + + return data.slice(0, 108); +} + +// Create QR matrix +function createMatrix(text) { + var matrix = []; + for (var i = 0; i < QR_SIZE; i++) { + matrix[i] = new Array(QR_SIZE).fill(null); + } + + // Add finder patterns + addFinderPattern(matrix, 0, 0); + addFinderPattern(matrix, QR_SIZE - 7, 0); + addFinderPattern(matrix, 0, QR_SIZE - 7); + + // Add alignment pattern (version 5 has one at 6,30) + addAlignmentPattern(matrix, 30, 30); + + // Add timing patterns + for (var i = 8; i < QR_SIZE - 8; i++) { + matrix[6][i] = i % 2 === 0 ? 1 : 0; + matrix[i][6] = i % 2 === 0 ? 1 : 0; + } + + // Add dark module + matrix[QR_SIZE - 8][8] = 1; + + // Reserve format info areas + for (var i = 0; i < 9; i++) { + if (matrix[8][i] === null) matrix[8][i] = 0; + if (matrix[i][8] === null) matrix[i][8] = 0; + } + for (var i = QR_SIZE - 8; i < QR_SIZE; i++) { + if (matrix[8][i] === null) matrix[8][i] = 0; + if (matrix[i][8] === null) matrix[i][8] = 0; + } + + // Create and place data + var data = createDataCodewords(text); + var ec = rsEncode(data, 26); + var allData = data.concat(ec); + + // Convert to bits + var bits = []; + for (var i = 0; i < allData.length; i++) { + for (var j = 7; j >= 0; j--) { + bits.push((allData[i] >> j) & 1); + } + } + + // Place data bits + var bitIdx = 0; + var up = true; + for (var col = QR_SIZE - 1; col >= 0; col -= 2) { + if (col === 6) col = 5; + + for (var row = up ? QR_SIZE - 1 : 0; up ? row >= 0 : row < QR_SIZE; row += up ? -1 : 1) { + for (var c = 0; c < 2; c++) { + var x = col - c; + if (matrix[row][x] === null && bitIdx < bits.length) { + matrix[row][x] = bits[bitIdx++]; + } + } + } + up = !up; + } + + // Apply mask pattern 0 (checkerboard) + for (var row = 0; row < QR_SIZE; row++) { + for (var col = 0; col < QR_SIZE; col++) { + if (matrix[row][col] !== null && !isReserved(row, col)) { + if ((row + col) % 2 === 0) { + matrix[row][col] ^= 1; + } + } + } + } + + // Add format info + addFormatInfo(matrix); + + return matrix; +} + +function isReserved(row, col) { + // Finder patterns and separators + if (row < 9 && col < 9) return true; + if (row < 9 && col >= QR_SIZE - 8) return true; + if (row >= QR_SIZE - 8 && col < 9) return true; + + // Timing patterns + if (row === 6 || col === 6) return true; + + // Alignment pattern + if (row >= 28 && row <= 32 && col >= 28 && col <= 32) return true; + + return false; +} + +function addFinderPattern(matrix, row, col) { + for (var r = 0; r < 7; r++) { + for (var c = 0; c < 7; c++) { + if (r === 0 || r === 6 || c === 0 || c === 6 || + (r >= 2 && r <= 4 && c >= 2 && c <= 4)) { + matrix[row + r][col + c] = 1; + } else { + matrix[row + r][col + c] = 0; + } + } + } + + // Separator + for (var i = 0; i < 8; i++) { + if (row + 7 < QR_SIZE && col + i < QR_SIZE) matrix[row + 7][col + i] = 0; + if (row + i < QR_SIZE && col + 7 < QR_SIZE) matrix[row + i][col + 7] = 0; + if (row - 1 >= 0 && col + i < QR_SIZE) matrix[row - 1][col + i] = 0; + if (row + i < QR_SIZE && col - 1 >= 0) matrix[row + i][col - 1] = 0; + } +} + +function addAlignmentPattern(matrix, row, col) { + for (var r = -2; r <= 2; r++) { + for (var c = -2; c <= 2; c++) { + if (Math.abs(r) === 2 || Math.abs(c) === 2 || (r === 0 && c === 0)) { + matrix[row + r][col + c] = 1; + } else { + matrix[row + r][col + c] = 0; + } + } + } +} + +function addFormatInfo(matrix) { + // Format info for mask 0 and EC level L + var formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0]; + + // Top-left + for (var i = 0; i < 6; i++) { + matrix[8][i] = formatBits[i]; + } + matrix[8][7] = formatBits[6]; + matrix[8][8] = formatBits[7]; + matrix[7][8] = formatBits[8]; + for (var i = 9; i < 15; i++) { + matrix[14 - i][8] = formatBits[i]; + } + + // Top-right and bottom-left + for (var i = 0; i < 8; i++) { + matrix[8][QR_SIZE - 1 - i] = formatBits[i]; + } + for (var i = 0; i < 7; i++) { + matrix[QR_SIZE - 7 + i][8] = formatBits[8 + i]; + } +} + +// Generate SVG +function generateSVG(text, size) { + size = size || 200; + var matrix = createMatrix(text); + var moduleSize = size / QR_SIZE; + + var svg = ''; + svg += ''; + + for (var row = 0; row < QR_SIZE; row++) { + for (var col = 0; col < QR_SIZE; col++) { + if (matrix[row][col] === 1) { + svg += ''; + } + } + } + + svg += ''; + return svg; +} + +return baseclass.extend({ + /** + * Generate QR code as SVG string + * @param {string} text - Text to encode + * @param {number} size - Size in pixels (default: 200) + * @returns {string} SVG markup + */ + generateSVG: function(text, size) { + try { + return generateSVG(text, size); + } catch (e) { + console.error('QR generation error:', e); + return null; + } + }, + + /** + * Generate QR code as data URL + * @param {string} text - Text to encode + * @param {number} size - Size in pixels + * @returns {string} Data URL for embedding in img src + */ + generateDataURL: function(text, size) { + var svg = this.generateSVG(text, size); + if (!svg) return null; + return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); + }, + + /** + * Render QR code to a DOM element + * @param {HTMLElement} container - Container element + * @param {string} text - Text to encode + * @param {number} size - Size in pixels + */ + render: function(container, text, size) { + var svg = this.generateSVG(text, size); + if (svg) { + container.innerHTML = svg; + } + } +}); diff --git a/package/secubox/luci-app-wireguard-dashboard/root/usr/libexec/rpcd/luci.wireguard-dashboard b/package/secubox/luci-app-wireguard-dashboard/root/usr/libexec/rpcd/luci.wireguard-dashboard index 2e89a1e7..baec31ee 100755 --- a/package/secubox/luci-app-wireguard-dashboard/root/usr/libexec/rpcd/luci.wireguard-dashboard +++ b/package/secubox/luci-app-wireguard-dashboard/root/usr/libexec/rpcd/luci.wireguard-dashboard @@ -74,11 +74,15 @@ get_status() { local transfer=$($WG_CMD show $iface transfer 2>/dev/null) local iface_rx=0 local iface_tx=0 - echo "$transfer" | while read peer rx tx; do - iface_rx=$((iface_rx + rx)) - iface_tx=$((iface_tx + tx)) - done - + while read peer rx tx; do + [ -n "$rx" ] && iface_rx=$((iface_rx + ${rx:-0})) + [ -n "$tx" ] && iface_tx=$((iface_tx + ${tx:-0})) + done << EOF +$transfer +EOF + + json_add_int "rx_bytes" "$iface_rx" + json_add_int "tx_bytes" "$iface_tx" json_close_object done @@ -405,13 +409,30 @@ add_peer() { return fi + # Validate public key format (base64, 44 chars ending with =) + if ! echo "$public_key" | grep -qE '^[A-Za-z0-9+/]{43}=$'; then + json_add_boolean "success" 0 + json_add_string "error" "Invalid public key format" + json_dump + return + fi + + # Check if peer already exists + local existing=$(uci show network 2>/dev/null | grep "public_key='$public_key'" | head -1) + if [ -n "$existing" ]; then + json_add_boolean "success" 0 + json_add_string "error" "Peer with this public key already exists" + json_dump + return + fi + # Default values [ -z "$allowed_ips" ] && allowed_ips="10.0.0.2/32" [ -z "$keepalive" ] && keepalive="25" [ -z "$name" ] && name="peer_$(echo $public_key | cut -c1-8)" - # Create UCI section for peer - local section_name="wgpeer_$(echo $public_key | cut -c1-8 | tr 'A-Z+/=' 'a-z___')" + # Create UCI section for peer using hash of full public key for uniqueness + local section_name="wgpeer_$(echo "$public_key" | md5sum | cut -c1-12)" # Add peer to UCI network config uci -q delete network.$section_name @@ -476,31 +497,37 @@ remove_peer() { return fi - # Find and remove UCI section with this public key - local found=0 - local section="" + # Validate public key format (base64, 44 chars ending with =) + if ! echo "$public_key" | grep -qE '^[A-Za-z0-9+/]{43}=$'; then + json_add_boolean "success" 0 + json_add_string "error" "Invalid public key format" + json_dump + return + fi - config_load network - config_cb() { - local type="$1" - local name="$2" - if [ "$type" = "wireguard_$iface" ]; then - local key=$(uci -q get network.$name.public_key) - if [ "$key" = "$public_key" ]; then - section="$name" - found=1 - fi + # Find UCI section by iterating directly + local found_section="" + local sections=$(uci show network 2>/dev/null | grep "=wireguard_$iface$" | cut -d'.' -f2 | cut -d'=' -f1) + + for section in $sections; do + local key=$(uci -q get network.$section.public_key) + if [ "$key" = "$public_key" ]; then + found_section="$section" + break fi - } - config_load network + done - if [ "$found" = "1" ] && [ -n "$section" ]; then - uci delete network.$section - uci commit network - ifup "$iface" 2>/dev/null - - json_add_boolean "success" 1 - json_add_string "message" "Peer removed successfully" + if [ -n "$found_section" ]; then + uci delete network.$found_section + if uci commit network; then + ifup "$iface" 2>/dev/null + json_add_boolean "success" 1 + json_add_string "message" "Peer removed successfully" + json_add_string "section" "$found_section" + else + json_add_boolean "success" 0 + json_add_string "error" "Failed to commit changes" + fi else json_add_boolean "success" 0 json_add_string "error" "Peer not found in configuration"