From 68c9449c016e701c4843e7d1355e1461a5747317 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sun, 8 Mar 2026 09:47:43 +0100 Subject: [PATCH] feat(luci-vortex-dns): Add zone management and secondary DNS UI Dashboard now includes: - Authoritative Zones table with View/Dump/Reload actions - Import Zone modal with domain input - Zone content viewer with download option - Secondary DNS providers section - Add Secondary modal (OVH/Gandi/Cloudflare support) New RPC calls for zone_list, zone_dump, zone_import, zone_export, zone_reload, secondary_list, secondary_add, secondary_remove. Co-Authored-By: Claude Opus 4.5 --- .../resources/view/vortex-dns/dashboard.js | 444 +++++++++++++++++- 1 file changed, 443 insertions(+), 1 deletion(-) diff --git a/package/secubox/luci-app-vortex-dns/htdocs/luci-static/resources/view/vortex-dns/dashboard.js b/package/secubox/luci-app-vortex-dns/htdocs/luci-static/resources/view/vortex-dns/dashboard.js index 4d7c1d57..43527908 100644 --- a/package/secubox/luci-app-vortex-dns/htdocs/luci-static/resources/view/vortex-dns/dashboard.js +++ b/package/secubox/luci-app-vortex-dns/htdocs/luci-static/resources/view/vortex-dns/dashboard.js @@ -57,13 +57,71 @@ var callGetPublished = rpc.declare({ expect: { services: [] } }); +// Zone Management RPC calls +var callZoneList = rpc.declare({ + object: 'luci.vortex-dns', + method: 'zone_list', + expect: { zones: [] } +}); + +var callZoneDump = rpc.declare({ + object: 'luci.vortex-dns', + method: 'zone_dump', + params: ['domain'], + expect: {} +}); + +var callZoneImport = rpc.declare({ + object: 'luci.vortex-dns', + method: 'zone_import', + params: ['domain'], + expect: {} +}); + +var callZoneExport = rpc.declare({ + object: 'luci.vortex-dns', + method: 'zone_export', + params: ['domain'], + expect: {} +}); + +var callZoneReload = rpc.declare({ + object: 'luci.vortex-dns', + method: 'zone_reload', + params: ['domain'], + expect: {} +}); + +// Secondary DNS RPC calls +var callSecondaryList = rpc.declare({ + object: 'luci.vortex-dns', + method: 'secondary_list', + expect: { secondaries: [] } +}); + +var callSecondaryAdd = rpc.declare({ + object: 'luci.vortex-dns', + method: 'secondary_add', + params: ['provider', 'domain'], + expect: {} +}); + +var callSecondaryRemove = rpc.declare({ + object: 'luci.vortex-dns', + method: 'secondary_remove', + params: ['provider', 'domain'], + expect: {} +}); + return view.extend({ load: function() { return Promise.all([ callStatus(), callGetSlaves(), callGetPeers(), - callGetPublished() + callGetPublished(), + callZoneList(), + callSecondaryList() ]); }, @@ -72,6 +130,8 @@ return view.extend({ var slaves = data[1] || []; var peers = data[2] || []; var published = data[3] || []; + var zones = data[4] || []; + var secondaries = data[5] || []; var view = E('div', { 'class': 'cbi-map' }, [ E('h2', {}, 'Vortex DNS'), @@ -103,6 +163,12 @@ return view.extend({ ]) ]), + // Zones Section (NEW) + this.renderZonesSection(zones), + + // Secondary DNS Section (NEW) + this.renderSecondarySection(secondaries), + // Master Section (if master mode) status.master ? this.renderMasterSection(status.master, slaves) : null, @@ -135,6 +201,114 @@ return view.extend({ }, (mode || 'standalone').toUpperCase()); }, + // NEW: Zones Section + renderZonesSection: function(zones) { + var self = this; + + return E('div', { 'class': 'cbi-section' }, [ + E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center;' }, [ + E('h3', {}, 'Authoritative Zones'), + E('button', { + 'class': 'btn cbi-button-positive', + 'style': 'padding: 6px 12px;', + 'click': function() { self.showImportZoneDialog(); } + }, '+ Import Zone') + ]), + E('div', { 'class': 'cbi-section-descr' }, + 'DNS zones managed by this server as authoritative master'), + + zones.length > 0 ? E('table', { 'class': 'table', 'style': 'margin-top: 12px;' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, 'Domain'), + E('th', { 'class': 'th' }, 'Records'), + E('th', { 'class': 'th' }, 'Status'), + E('th', { 'class': 'th' }, 'Actions') + ]) + ].concat(zones.map(function(z) { + var statusBadge; + if (z.enabled && z.authoritative) { + statusBadge = E('span', { 'class': 'badge success' }, 'Active'); + } else if (z.enabled) { + statusBadge = E('span', { 'class': 'badge info' }, 'Enabled'); + } else { + statusBadge = E('span', { 'class': 'badge warning' }, 'Disabled'); + } + + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, E('strong', {}, z.domain)), + E('td', { 'class': 'td' }, z.records || 0), + E('td', { 'class': 'td' }, statusBadge), + E('td', { 'class': 'td' }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'style': 'padding: 2px 8px; margin-right: 4px;', + 'click': function() { self.showZoneContentDialog(z.domain); }, + 'title': 'View zone file' + }, 'View'), + E('button', { + 'class': 'btn cbi-button-neutral', + 'style': 'padding: 2px 8px; margin-right: 4px;', + 'click': function() { self.doZoneDump(z.domain); }, + 'title': 'Re-dump zone from DNS' + }, 'Dump'), + E('button', { + 'class': 'btn cbi-button-apply', + 'style': 'padding: 2px 8px;', + 'click': function() { self.doZoneReload(z.domain); }, + 'title': 'Reload zone in dnsmasq' + }, 'Reload') + ]) + ]); + }))) : E('p', { 'style': 'color: #888; margin-top: 8px;' }, + 'No zones configured. Click "Import Zone" to add a domain.') + ]); + }, + + // NEW: Secondary DNS Section + renderSecondarySection: function(secondaries) { + var self = this; + + return E('div', { 'class': 'cbi-section' }, [ + E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center;' }, [ + E('h3', {}, 'Secondary DNS Providers'), + E('button', { + 'class': 'btn cbi-button-positive', + 'style': 'padding: 6px 12px;', + 'click': function() { self.showAddSecondaryDialog(); } + }, '+ Add Secondary') + ]), + E('div', { 'class': 'cbi-section-descr' }, + 'External DNS providers configured as secondary servers (zone transfer via AXFR)'), + + secondaries.length > 0 ? E('table', { 'class': 'table', 'style': 'margin-top: 12px;' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, 'Provider'), + E('th', { 'class': 'th' }, 'Status'), + E('th', { 'class': 'th' }, 'Actions') + ]) + ].concat(secondaries.map(function(s) { + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, E('strong', {}, s.provider.toUpperCase())), + E('td', { 'class': 'td' }, s.enabled ? + E('span', { 'class': 'badge success' }, 'Configured') : + E('span', { 'class': 'badge warning' }, 'Disabled')), + E('td', { 'class': 'td' }, [ + E('button', { + 'class': 'btn cbi-button-remove', + 'style': 'padding: 2px 8px;', + 'click': function() { self.doSecondaryRemove(s.provider); } + }, 'Remove') + ]) + ]); + }))) : E('div', { 'style': 'margin-top: 12px; padding: 16px; background: #1a1a2e; border-radius: 8px;' }, [ + E('p', { 'style': 'color: #888; margin: 0;' }, + 'No secondary DNS providers configured.'), + E('p', { 'style': 'color: #666; font-size: 0.9em; margin-top: 8px;' }, + 'Secondary providers (OVH, Gandi) can mirror your zones via AXFR for redundancy.') + ]) + ]); + }, + renderMasterSection: function(master, slaves) { return E('div', { 'class': 'cbi-section' }, [ E('h3', {}, 'Master Node'), @@ -318,6 +492,274 @@ return view.extend({ ]); }, + // Zone Management Actions + showImportZoneDialog: function() { + var self = this; + ui.showModal('Import DNS Zone', [ + E('p', {}, 'Import a zone from external DNS and become authoritative master:'), + E('div', { 'style': 'margin: 16px 0;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px;' }, 'Domain:'), + E('input', { + 'type': 'text', + 'id': 'import-domain', + 'placeholder': 'example.com', + 'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;' + }) + ]), + E('div', { 'style': 'background: #0d1117; padding: 12px; border-radius: 4px; margin: 12px 0;' }, [ + E('p', { 'style': 'margin: 0; font-size: 0.9em; color: #888;' }, [ + E('strong', {}, 'What this does:'), + E('br'), + '1. Query public DNS for all records (A, MX, TXT, etc.)', + E('br'), + '2. Generate BIND format zone file in /srv/dns/zones/', + E('br'), + '3. Configure dnsmasq as authoritative server' + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 16px;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, 'Cancel'), + E('button', { + 'class': 'btn cbi-button-positive', + 'style': 'margin-left: 8px;', + 'click': function() { + var domain = document.getElementById('import-domain').value.trim(); + if (domain) { + ui.hideModal(); + self.doZoneImport(domain); + } else { + ui.addNotification(null, E('p', {}, 'Please enter a domain name'), 'warning'); + } + } + }, 'Import Zone') + ]) + ]); + }, + + showZoneContentDialog: function(domain) { + ui.showModal('Zone: ' + domain, [ + E('p', { 'class': 'spinning' }, 'Loading zone file...') + ]); + + callZoneExport(domain).then(function(res) { + if (res.success && res.content) { + ui.hideModal(); + ui.showModal('Zone: ' + domain, [ + E('div', { 'style': 'max-height: 60vh; overflow: auto;' }, [ + E('pre', { + 'style': 'background: #0d1117; padding: 12px; border-radius: 4px; font-family: monospace; font-size: 0.85em; white-space: pre-wrap; color: #c9d1d9;' + }, res.content) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 16px;' }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'click': function() { + var blob = new Blob([res.content], { type: 'text/plain' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = domain + '.zone'; + a.click(); + } + }, 'Download'), + E('button', { + 'class': 'btn', + 'style': 'margin-left: 8px;', + 'click': ui.hideModal + }, 'Close') + ]) + ]); + } else { + ui.hideModal(); + ui.addNotification(null, E('p', {}, 'Failed to load zone: ' + (res.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, 'Failed to load zone: ' + e.message), 'error'); + }); + }, + + doZoneDump: function(domain) { + ui.showModal('Dumping Zone', [ + E('p', { 'class': 'spinning' }, 'Querying DNS for ' + domain + '...') + ]); + + callZoneDump(domain).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', {}, 'Zone dumped: ' + res.records + ' records in ' + res.file), 'success'); + location.reload(); + } else { + ui.addNotification(null, E('p', {}, 'Dump failed: ' + (res.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, 'Dump failed: ' + e.message), 'error'); + }); + }, + + doZoneImport: function(domain) { + ui.showModal('Importing Zone', [ + E('p', { 'class': 'spinning' }, 'Importing ' + domain + ' and configuring as authoritative...') + ]); + + callZoneImport(domain).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.showModal('Zone Imported', [ + E('p', {}, [ + 'Successfully imported ', + E('strong', {}, domain), + ' with ', + E('strong', {}, res.records), + ' records.' + ]), + E('div', { 'style': 'background: #0d1117; padding: 12px; border-radius: 4px; margin: 12px 0;' }, [ + E('p', { 'style': 'margin: 0; font-size: 0.9em;' }, [ + E('strong', {}, 'Zone file: '), + res.file + ]), + E('p', { 'style': 'margin: 8px 0 0 0; font-size: 0.9em;' }, [ + E('strong', {}, 'Test: '), + E('code', {}, 'dig @127.0.0.1 ' + domain) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button-positive', + 'click': function() { location.reload(); } + }, 'OK') + ]) + ]); + } else { + ui.addNotification(null, E('p', {}, 'Import failed: ' + (res.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, 'Import failed: ' + e.message), 'error'); + }); + }, + + doZoneReload: function(domain) { + ui.showModal('Reloading Zone', [ + E('p', { 'class': 'spinning' }, 'Reloading ' + domain + ' in dnsmasq...') + ]); + + callZoneReload(domain).then(function(res) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, 'Zone reloaded: ' + (res.message || 'Success')), 'success'); + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, 'Reload failed: ' + e.message), 'error'); + }); + }, + + // Secondary DNS Actions + showAddSecondaryDialog: function() { + var self = this; + ui.showModal('Add Secondary DNS Provider', [ + E('p', {}, 'Configure an external DNS provider as secondary server:'), + E('div', { 'style': 'margin: 16px 0;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px;' }, 'Provider:'), + E('select', { + 'id': 'secondary-provider', + 'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;' + }, [ + E('option', { 'value': 'ovh' }, 'OVH'), + E('option', { 'value': 'gandi' }, 'Gandi'), + E('option', { 'value': 'cloudflare' }, 'Cloudflare') + ]) + ]), + E('div', { 'style': 'margin: 16px 0;' }, [ + E('label', { 'style': 'display: block; margin-bottom: 4px;' }, 'Domain:'), + E('input', { + 'type': 'text', + 'id': 'secondary-domain', + 'placeholder': 'example.com', + 'style': 'width: 100%; padding: 8px; background: #1a1a2e; border: 1px solid #333; color: #fff; border-radius: 4px;' + }) + ]), + E('div', { 'style': 'background: #0d1117; padding: 12px; border-radius: 4px; margin: 12px 0;' }, [ + E('p', { 'style': 'margin: 0; font-size: 0.9em; color: #888;' }, [ + E('strong', {}, 'Requirements:'), + E('br'), + '1. Domain must be imported as authoritative zone first', + E('br'), + '2. Provider API credentials must be configured', + E('br'), + '3. Provider will pull zones via AXFR' + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top: 16px;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, 'Cancel'), + E('button', { + 'class': 'btn cbi-button-positive', + 'style': 'margin-left: 8px;', + 'click': function() { + var provider = document.getElementById('secondary-provider').value; + var domain = document.getElementById('secondary-domain').value.trim(); + if (provider && domain) { + ui.hideModal(); + self.doSecondaryAdd(provider, domain); + } else { + ui.addNotification(null, E('p', {}, 'Please fill all fields'), 'warning'); + } + } + }, 'Add Secondary') + ]) + ]); + }, + + doSecondaryAdd: function(provider, domain) { + ui.showModal('Configuring Secondary', [ + E('p', { 'class': 'spinning' }, 'Adding ' + provider.toUpperCase() + ' as secondary for ' + domain + '...') + ]); + + callSecondaryAdd(provider, domain).then(function(res) { + ui.hideModal(); + if (res.success) { + ui.addNotification(null, E('p', {}, provider.toUpperCase() + ' configured as secondary for ' + domain), 'success'); + location.reload(); + } else { + ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, 'Failed: ' + e.message), 'error'); + }); + }, + + doSecondaryRemove: function(provider) { + var self = this; + ui.showModal('Confirm Remove', [ + E('p', {}, 'Remove ' + provider.toUpperCase() + ' as secondary DNS provider?'), + E('div', { 'class': 'right', 'style': 'margin-top: 16px;' }, [ + E('button', { + 'class': 'btn', + 'click': ui.hideModal + }, 'Cancel'), + E('button', { + 'class': 'btn cbi-button-remove', + 'style': 'margin-left: 8px;', + 'click': function() { + ui.hideModal(); + callSecondaryRemove(provider, '').then(function() { + ui.addNotification(null, E('p', {}, provider.toUpperCase() + ' removed'), 'success'); + location.reload(); + }).catch(function(e) { + ui.addNotification(null, E('p', {}, 'Failed: ' + e.message), 'error'); + }); + } + }, 'Remove') + ]) + ]); + }, + doMeshSync: function() { ui.showModal('Syncing...', [ E('p', { 'class': 'spinning' }, 'Syncing with mesh peers...')