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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-09 09:32:31 +01:00
parent e4a553a6d5
commit 9ef0b6db18
10 changed files with 2077 additions and 271 deletions

View File

@ -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

View File

@ -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 = '<span class="wg-status-dot"></span>' + (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;
},

View File

@ -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 = '<div>↓ ' + API.formatBytes(peer.rx_bytes) + '</div>' +
'<div>↑ ' + API.formatBytes(peer.tx_bytes) + '</div>';
}
});
// 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;

View File

@ -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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + qrSize + ' ' + qrSize + '" width="' + qrSize + '" height="' + qrSize + '">';
svg += '<rect width="100%" height="100%" fill="white"/>';
// Simplified pattern - in real implementation, use proper QR encoding
// Draw finder patterns (corners)
var drawFinder = function(x, y) {
var s = moduleSize * 7;
svg += '<rect x="' + x + '" y="' + y + '" width="' + s + '" height="' + s + '" fill="black"/>';
svg += '<rect x="' + (x + moduleSize) + '" y="' + (y + moduleSize) + '" width="' + (s - moduleSize * 2) + '" height="' + (s - moduleSize * 2) + '" fill="white"/>';
svg += '<rect x="' + (x + moduleSize * 2) + '" y="' + (y + moduleSize * 2) + '" width="' + (s - moduleSize * 4) + '" height="' + (s - moduleSize * 4) + '" fill="black"/>';
};
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 += '<rect x="' + (col * moduleSize) + '" y="' + (row * moduleSize) + '" width="' + moduleSize + '" height="' + moduleSize + '" fill="black"/>';
}
getStoredPrivateKey: function(publicKey) {
try {
var stored = sessionStorage.getItem('wg_peer_keys');
if (stored) {
var keys = JSON.parse(stored);
return keys[publicKey] || null;
}
}
svg += '</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 = <YOUR_PRIVATE_KEY>\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 = <YOUR_SERVER_IP>:' + (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

View File

@ -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

View File

@ -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() {

View File

@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + width + ' ' + height + '" ' +
'width="' + width + '" height="' + height + '" class="wg-chart-sparkline">';
// Grid lines
if (opts.showGrid) {
for (var g = 0; g < 4; g++) {
var gy = padding + (chartHeight / 3) * g;
svg += '<line x1="' + padding + '" y1="' + gy + '" x2="' + (width - padding) + '" y2="' + gy + '" ' +
'stroke="' + opts.gridColor + '" stroke-width="1" stroke-dasharray="2,2"/>';
}
}
// Fill area
if (opts.fill && fillPath) {
svg += '<path d="' + fillPath + '" fill="' + opts.fillColor + '" class="wg-chart-fill"/>';
}
// Line
svg += '<path d="' + pathData + '" fill="none" stroke="' + opts.lineColor + '" ' +
'stroke-width="' + opts.strokeWidth + '" stroke-linecap="round" stroke-linejoin="round" ' +
'class="wg-chart-line"';
if (opts.animate) {
var pathLength = this.estimatePathLength(data.length, chartWidth, chartHeight);
svg += ' stroke-dasharray="' + pathLength + '" stroke-dashoffset="' + pathLength + '" ' +
'style="animation: wg-chart-draw 1s ease-out forwards"';
}
svg += '/>';
// End point dot
var lastX = padding + (data.length - 1) * step;
var lastY = padding + chartHeight - ((data[data.length - 1] - min) / range * chartHeight);
svg += '<circle cx="' + lastX + '" cy="' + lastY + '" r="3" fill="' + opts.lineColor + '" class="wg-chart-dot"/>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + width + ' ' + height + '" ' +
'width="' + width + '" height="' + height + '" class="wg-chart-dual">';
// 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 += '<path d="' + fillPath + '" fill="' + ds.fill + '" opacity="0.5"/>';
}
// Line
svg += '<path d="' + pathData + '" fill="none" stroke="' + ds.color + '" ' +
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
});
// Legend
svg += '<text x="' + (width - 60) + '" y="12" font-size="10" fill="' + opts.rxColor + '">↓ RX</text>';
svg += '<text x="' + (width - 30) + '" y="12" font-size="10" fill="' + opts.txColor + '">↑ TX</text>';
svg += '</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 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + width + ' ' + height + '" ' +
'width="' + width + '" height="' + height + '" class="wg-chart-empty">' +
'<line x1="10" y1="' + (height / 2) + '" x2="' + (width - 10) + '" y2="' + (height / 2) + '" ' +
'stroke="rgba(255,255,255,0.2)" stroke-width="1" stroke-dasharray="4,4"/>' +
'<text x="' + (width / 2) + '" y="' + (height / 2 + 4) + '" ' +
'text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">No data</text>' +
'</svg>';
},
/**
* 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 = {};
}
}
}
};

View File

@ -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);
}

View File

@ -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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + size + ' ' + size + '" width="' + size + '" height="' + size + '">';
svg += '<rect width="100%" height="100%" fill="white"/>';
for (var row = 0; row < QR_SIZE; row++) {
for (var col = 0; col < QR_SIZE; col++) {
if (matrix[row][col] === 1) {
svg += '<rect x="' + (col * moduleSize) + '" y="' + (row * moduleSize) + '" ';
svg += 'width="' + moduleSize + '" height="' + moduleSize + '" fill="black"/>';
}
}
}
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;
}
}
});

View File

@ -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"