From 2ab096591737deb160ef64799448728f393e9298 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 3 Feb 2026 16:54:26 +0100 Subject: [PATCH] feat(wireguard-dashboard): Persist peer private keys in UCI for QR code generation Store the client private key in UCI config (_client_private_key) when a peer is created, so QR codes and config files can be generated after page refresh without prompting the user to manually re-enter the key. Old peers without stored keys still get the manual entry fallback. Co-Authored-By: Claude Opus 4.5 --- .../view/wireguard-dashboard/peers.js | 275 ++++++++++-------- .../view/wireguard-dashboard/qrcodes.js | 88 +++--- .../resources/wireguard-dashboard/api.js | 2 +- .../usr/libexec/rpcd/luci.wireguard-dashboard | 44 ++- 4 files changed, 254 insertions(+), 155 deletions(-) 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 eddb6dc2..a53d014f 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 @@ -358,7 +358,7 @@ return view.extend({ var privkey = document.getElementById('peer-privkey').value; - API.addPeer(iface, name, allowed_ips, pubkey, psk, endpoint, keepalive).then(function(result) { + API.addPeer(iface, name, allowed_ips, pubkey, psk, endpoint, keepalive, privkey).then(function(result) { ui.hideModal(); if (result.success) { // Store private key for QR generation @@ -460,37 +460,47 @@ return view.extend({ 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'; + var buildLocalConfig = function(privKey) { + return '[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 = ' + 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) { + // Try backend QR generation (it will look up stored key if privateKey is empty) + API.generateQR(peer.interface, peer.public_key, privateKey || '', serverEndpoint).then(function(result) { if (result && result.qrcode && !result.error) { + var config = result.config || buildLocalConfig(privateKey); self.displayQRModal(peer, result.qrcode, config, false); - } else { - // Fall back to JavaScript QR generation + } else if (privateKey) { + // Backend failed but we have a key - fall back to JavaScript QR generation + var config = buildLocalConfig(privateKey); 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'); } + } else { + ui.addNotification(null, E('p', result.error || _('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); + if (privateKey) { + // Fall back to JavaScript QR generation + var config = buildLocalConfig(privateKey); + 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'); + } } else { - ui.addNotification(null, E('p', _('Failed to generate QR code')), 'error'); + ui.addNotification(null, E('p', _('Error: %s').format(err.message || err)), 'error'); } }); }, @@ -552,55 +562,91 @@ return view.extend({ ]); }, + showPrivateKeyPrompt: function(peer, ifaceObj, callback) { + var self = this; + 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;' }, + _('The private key was not found on the server. This can happen for peers created before key persistence was enabled.')), + 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; + } + self.storePrivateKey(peer.public_key, key); + ui.hideModal(); + callback(key); + } + }, _('Continue')) + ]) + ]); + }, + 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')) - ]) - ]); + if (privateKey) { + this.promptForEndpointAndShowQR(peer, ifaceObj, privateKey); return; } - this.promptForEndpointAndShowQR(peer, ifaceObj, privateKey); + // Try backend with empty private key - it will look up the stored key + var savedEndpoint = sessionStorage.getItem('wg_server_endpoint') || ''; + if (savedEndpoint) { + API.generateQR(peer.interface, peer.public_key, '', savedEndpoint).then(function(result) { + if (result && result.qrcode && !result.error) { + self.displayQRModal(peer, result.qrcode, result.config, false); + } else { + self.showPrivateKeyPrompt(peer, ifaceObj, function(key) { + self.promptForEndpointAndShowQR(peer, ifaceObj, key); + }); + } + }).catch(function() { + self.showPrivateKeyPrompt(peer, ifaceObj, function(key) { + self.promptForEndpointAndShowQR(peer, ifaceObj, key); + }); + }); + } else { + // No saved endpoint yet - need to prompt for endpoint first + // Try a test call to see if backend has the key + API.generateConfig(peer.interface, peer.public_key, '', 'test').then(function(result) { + if (result && result.config && !result.error) { + // Backend has the key, proceed with endpoint prompt + self.promptForEndpointAndShowQR(peer, ifaceObj, ''); + } else { + self.showPrivateKeyPrompt(peer, ifaceObj, function(key) { + self.promptForEndpointAndShowQR(peer, ifaceObj, key); + }); + } + }).catch(function() { + self.showPrivateKeyPrompt(peer, ifaceObj, function(key) { + self.promptForEndpointAndShowQR(peer, ifaceObj, key); + }); + }); + } }, handleDownloadConfig: function(peer, interfaces, ev) { @@ -608,6 +654,17 @@ return view.extend({ var privateKey = this.getStoredPrivateKey(peer.public_key); var ifaceObj = interfaces.find(function(i) { return i.name === peer.interface; }) || {}; + var downloadConfig = function(config) { + 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.addNotification(null, E('p', _('Configuration file downloaded')), 'info'); + }; + var showConfigModal = function(privKey) { var savedEndpoint = sessionStorage.getItem('wg_server_endpoint') || ''; @@ -640,27 +697,31 @@ return view.extend({ 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'); + + if (privKey) { + 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'; + downloadConfig(config); + } else { + // Use backend to generate config (it has the stored key) + API.generateConfig(peer.interface, peer.public_key, '', endpoint).then(function(result) { + if (result && result.config && !result.error) { + downloadConfig(result.config); + } else { + ui.addNotification(null, E('p', result.error || _('Failed to generate config')), 'error'); + } + }).catch(function(err) { + ui.addNotification(null, E('p', _('Error: %s').format(err.message || err)), 'error'); + }); + } } }, _('Download')) ]) @@ -668,42 +729,22 @@ return view.extend({ }; 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')) - ]) - ]); + // Try backend first - it may have the stored key + API.generateConfig(peer.interface, peer.public_key, '', 'test').then(function(result) { + if (result && result.config && !result.error) { + // Backend has the key, show config modal with backend-generated config + showConfigModal(''); + } else { + // Fallback to manual prompt + self.showPrivateKeyPrompt(peer, ifaceObj, function(key) { + showConfigModal(key); + }); + } + }).catch(function() { + self.showPrivateKeyPrompt(peer, ifaceObj, function(key) { + showConfigModal(key); + }); + }); return; } 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 311285b0..5b0dbb35 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 @@ -34,49 +34,65 @@ return view.extend({ return null; }, + showPrivateKeyPrompt: function(iface, peer, serverEndpoint) { + var self = this; + ui.showModal(_('Private Key Required'), [ + E('p', {}, _('To generate a QR code, you need the peer\'s private key.')), + E('p', {}, _('The private key was not found on the server. This can happen for peers created before key persistence was enabled.')), + 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')) + ]) + ]); + }, + 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')) - ]) - ]); + if (privateKey) { + this.showQRCode(iface, peer, privateKey, serverEndpoint); return; } - this.showQRCode(iface, peer, privateKey, serverEndpoint); + // Try backend first with empty private key - it will look up the stored key + api.generateQR(iface.name, peer.public_key, '', serverEndpoint).then(function(result) { + if (result && result.qrcode && !result.error) { + // Backend found the stored key and generated QR + self.displayQRModal(iface, peer, result.qrcode, result.config); + } else { + // Backend doesn't have the key - prompt user + self.showPrivateKeyPrompt(iface, peer, serverEndpoint); + } + }).catch(function() { + self.showPrivateKeyPrompt(iface, peer, serverEndpoint); + }); }, showQRCode: function(iface, peer, privateKey, serverEndpoint) { 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 20118b43..e61255b6 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 @@ -44,7 +44,7 @@ var callCreateInterface = rpc.declare({ var callAddPeer = rpc.declare({ object: 'luci.wireguard-dashboard', method: 'add_peer', - params: ['interface', 'name', 'allowed_ips', 'public_key', 'preshared_key', 'endpoint', 'persistent_keepalive'], + params: ['interface', 'name', 'allowed_ips', 'public_key', 'preshared_key', 'endpoint', 'persistent_keepalive', 'private_key'], expect: { } }); 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 db4531b1..f57b15f2 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 @@ -506,6 +506,7 @@ add_peer() { json_get_var psk preshared_key json_get_var endpoint endpoint json_get_var keepalive persistent_keepalive + json_get_var client_privkey private_key json_init @@ -572,6 +573,11 @@ add_peer() { uci set network.$section_name.persistent_keepalive="$keepalive" fi + # Store client private key for QR/config generation + if [ -n "$client_privkey" ]; then + uci set network.$section_name._client_private_key="$client_privkey" + fi + # Route allowed IPs uci set network.$section_name.route_allowed_ips="1" @@ -655,6 +661,24 @@ generate_config() { json_init + # If private key not provided, look it up from UCI + if [ -z "$peer_privkey" ]; then + 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" = "$peer_key" ]; then + peer_privkey=$(uci -q get network.$section._client_private_key) + break + fi + done + fi + + if [ -z "$peer_privkey" ]; then + json_add_string "error" "Private key not available. Please provide it manually." + json_dump + return + fi + # Get interface details local server_pubkey=$($WG_CMD show $iface public-key 2>/dev/null) local server_port=$($WG_CMD show $iface listen-port 2>/dev/null) @@ -709,6 +733,24 @@ generate_qr() { return fi + # If private key not provided, look it up from UCI + if [ -z "$peer_privkey" ]; then + 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" = "$peer_key" ]; then + peer_privkey=$(uci -q get network.$section._client_private_key) + break + fi + done + fi + + if [ -z "$peer_privkey" ]; then + json_add_string "error" "Private key not available. Please provide it manually." + json_dump + return + fi + # Get interface details local server_pubkey=$($WG_CMD show $iface public-key 2>/dev/null) local server_port=$($WG_CMD show $iface listen-port 2>/dev/null) @@ -1008,7 +1050,7 @@ get_bandwidth_rates() { # Main dispatcher case "$1" in list) - echo '{"status":{},"interfaces":{},"peers":{},"traffic":{},"config":{},"generate_keys":{},"create_interface":{"name":"str","private_key":"str","listen_port":"str","addresses":"str","mtu":"str"},"add_peer":{"interface":"str","name":"str","allowed_ips":"str","public_key":"str","preshared_key":"str","endpoint":"str","persistent_keepalive":"str"},"remove_peer":{"interface":"str","public_key":"str"},"generate_config":{"interface":"str","peer":"str","private_key":"str","endpoint":"str"},"generate_qr":{"interface":"str","peer":"str","private_key":"str","endpoint":"str"},"bandwidth_history":{},"endpoint_info":{"endpoint":"str"},"ping_peer":{"ip":"str"},"interface_control":{"interface":"str","action":"str"},"peer_descriptions":{},"bandwidth_rates":{}}' + echo '{"status":{},"interfaces":{},"peers":{},"traffic":{},"config":{},"generate_keys":{},"create_interface":{"name":"str","private_key":"str","listen_port":"str","addresses":"str","mtu":"str"},"add_peer":{"interface":"str","name":"str","allowed_ips":"str","public_key":"str","preshared_key":"str","endpoint":"str","persistent_keepalive":"str","private_key":"str"},"remove_peer":{"interface":"str","public_key":"str"},"generate_config":{"interface":"str","peer":"str","private_key":"str","endpoint":"str"},"generate_qr":{"interface":"str","peer":"str","private_key":"str","endpoint":"str"},"bandwidth_history":{},"endpoint_info":{"endpoint":"str"},"ping_peer":{"ip":"str"},"interface_control":{"interface":"str","action":"str"},"peer_descriptions":{},"bandwidth_rates":{}}' ;; call) case "$2" in