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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-08 09:47:43 +01:00
parent 592e46bde8
commit 68c9449c01

View File

@ -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...')