Server endpoints were stored only in browser sessionStorage, lost on tab close/refresh. Now endpoints are saved in a dedicated UCI config file (wireguard_dashboard) with RPCD methods to manage them. The wizard auto-saves the endpoint after tunnel creation, and peers/QR views use a dropdown of saved endpoints instead of requiring manual re-entry. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
523 lines
15 KiB
JavaScript
523 lines
15 KiB
JavaScript
'use strict';
|
|
'require view';
|
|
'require secubox-theme/theme as Theme';
|
|
'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 Promise.all([
|
|
api.getConfig(),
|
|
api.getInterfaces(),
|
|
api.getPeers(),
|
|
api.getEndpoints()
|
|
]).then(function(results) {
|
|
return {
|
|
config: results[0] || {},
|
|
interfaces: (results[1] || {}).interfaces || [],
|
|
peers: (results[2] || {}).peers || [],
|
|
endpointData: results[3] || {}
|
|
};
|
|
});
|
|
},
|
|
|
|
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;
|
|
},
|
|
|
|
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) {
|
|
this.showQRCode(iface, peer, privateKey, serverEndpoint);
|
|
return;
|
|
}
|
|
|
|
// 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) {
|
|
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'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
showManageEndpointsModal: function() {
|
|
var self = this;
|
|
|
|
api.getEndpoints().then(function(endpointData) {
|
|
var endpoints = (endpointData || {}).endpoints || [];
|
|
var defaultId = (endpointData || {})['default'] || '';
|
|
|
|
var rows = endpoints.map(function(ep) {
|
|
return E('tr', {}, [
|
|
E('td', {}, ep.name || ep.id),
|
|
E('td', {}, E('code', {}, ep.address)),
|
|
E('td', {}, ep.id === defaultId ? E('strong', {}, _('Default')) : E('button', {
|
|
'class': 'cbi-button cbi-button-apply',
|
|
'style': 'padding: 2px 8px; font-size: 0.85em;',
|
|
'click': function() {
|
|
api.setDefaultEndpoint(ep.id).then(function() {
|
|
ui.hideModal();
|
|
self.showManageEndpointsModal();
|
|
});
|
|
}
|
|
}, _('Set Default'))),
|
|
E('td', {}, E('button', {
|
|
'class': 'cbi-button cbi-button-negative',
|
|
'style': 'padding: 2px 8px; font-size: 0.85em;',
|
|
'click': function() {
|
|
api.deleteEndpoint(ep.id).then(function() {
|
|
ui.hideModal();
|
|
self.showManageEndpointsModal();
|
|
});
|
|
}
|
|
}, _('Delete')))
|
|
]);
|
|
});
|
|
|
|
ui.showModal(_('Manage Endpoints'), [
|
|
endpoints.length > 0 ?
|
|
E('table', { 'class': 'table' }, [
|
|
E('thead', {}, [
|
|
E('tr', {}, [
|
|
E('th', {}, _('Name')),
|
|
E('th', {}, _('Address')),
|
|
E('th', {}, _('Status')),
|
|
E('th', {}, _('Actions'))
|
|
])
|
|
]),
|
|
E('tbody', {}, rows)
|
|
]) :
|
|
E('p', { 'style': 'color: #666; text-align: center; padding: 1em;' }, _('No saved endpoints')),
|
|
|
|
E('h4', { 'style': 'margin-top: 1em;' }, _('Add New Endpoint')),
|
|
E('div', { 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;' }, [
|
|
E('input', {
|
|
'type': 'text',
|
|
'id': 'new-ep-name',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Name (e.g. Home Server)')
|
|
}),
|
|
E('input', {
|
|
'type': 'text',
|
|
'id': 'new-ep-address',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Address (e.g. vpn.example.com)')
|
|
})
|
|
]),
|
|
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
|
E('button', {
|
|
'class': 'btn',
|
|
'click': ui.hideModal
|
|
}, _('Close')),
|
|
' ',
|
|
E('button', {
|
|
'class': 'btn cbi-button-action',
|
|
'click': function() {
|
|
var name = document.getElementById('new-ep-name').value.trim();
|
|
var address = document.getElementById('new-ep-address').value.trim();
|
|
if (!address) {
|
|
ui.addNotification(null, E('p', _('Please enter an address')), 'error');
|
|
return;
|
|
}
|
|
var id = (name || address).toLowerCase().replace(/[^a-z0-9]/g, '_').substring(0, 20);
|
|
api.setEndpoint(id, name || address, address).then(function() {
|
|
ui.hideModal();
|
|
self.showManageEndpointsModal();
|
|
});
|
|
}
|
|
}, _('Add Endpoint'))
|
|
])
|
|
]);
|
|
});
|
|
},
|
|
|
|
render: function(data) {
|
|
var self = this;
|
|
var interfaces = data.interfaces || [];
|
|
var configData = (data.config || {}).interfaces || [];
|
|
var peers = data.peers || [];
|
|
var endpointData = data.endpointData || {};
|
|
|
|
this.endpointData = endpointData;
|
|
|
|
// 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 endpointSelector = api.buildEndpointSelector(endpointData, 'wg-server-endpoint');
|
|
|
|
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' }, [
|
|
E('div', { 'class': 'wg-logo-icon' }, '📱'),
|
|
E('div', { 'class': 'wg-logo-text' }, ['QR ', E('span', {}, 'Generator')])
|
|
])
|
|
]),
|
|
|
|
// Server endpoint selector
|
|
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('button', {
|
|
'class': 'wg-btn',
|
|
'style': 'font-size: 0.85em;',
|
|
'click': L.bind(this.showManageEndpointsModal, this)
|
|
}, _('Manage'))
|
|
]),
|
|
E('div', { 'class': 'wg-card-body' }, [
|
|
E('p', { 'style': 'margin-bottom: 12px; color: var(--wg-text-secondary);' },
|
|
_('Select or enter the public IP or hostname of this WireGuard server:')),
|
|
E('div', { 'class': 'wg-form-row' }, [
|
|
E('div', { 'style': 'flex: 1;' }, [ endpointSelector ])
|
|
])
|
|
])
|
|
]),
|
|
|
|
// Interface cards
|
|
interfaces.length > 0 ?
|
|
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-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() {
|
|
var endpoint = api.getEndpointValue('wg-server-endpoint');
|
|
if (!endpoint) {
|
|
ui.addNotification(null, E('p', {}, _('Please select or enter the server endpoint first')), 'warning');
|
|
return;
|
|
}
|
|
self.generateQRForPeer(iface, peer, endpoint);
|
|
}
|
|
}, '📱 ' + _('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 a WireGuard interface to generate QR codes'))
|
|
])
|
|
]);
|
|
|
|
// 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: 8px;
|
|
}
|
|
.wg-peer-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 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;
|
|
gap: 10px;
|
|
margin: 16px 0;
|
|
}
|
|
.wg-config-details {
|
|
text-align: left;
|
|
margin-top: 16px;
|
|
}
|
|
.wg-config-details summary {
|
|
cursor: pointer;
|
|
color: var(--wg-accent-cyan);
|
|
margin-bottom: 8px;
|
|
}
|
|
.wg-config-details pre {
|
|
background: var(--wg-bg-tertiary);
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
font-size: 11px;
|
|
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
|
|
});
|