luci-mod-network: dhcp.js: group DNS record related options in one tab
[project/luci.git] / modules / luci-mod-network / htdocs / luci-static / resources / view / network / dhcp.js
index 4f71d2d989ffd5dfd9920c5e4801a6b6b15be6d6..7be024f68a300d43e0fe962415b531a23991a928 100644 (file)
@@ -89,6 +89,21 @@ function calculateNetwork(addr, mask) {
        ];
 }
 
+function generateDnsmasqInstanceEntry(data) {
+       const nameValueMap = new Map(Object.entries(data));
+       let formatString = nameValueMap.get('.index') + ' (' +  _('Name') + (nameValueMap.get('.anonymous') ? ': dnsmasq[' + nameValueMap.get('.index') + ']': ': ' + nameValueMap.get('.name'));
+
+       if (data.domain) {
+               formatString += ', ' +  _('Domain')  + ': ' + data.domain;
+       }
+       if (data.local) {
+               formatString += ', ' +  _('Local')  + ': ' + data.local;
+       }
+       formatString += ')';
+
+       return nameValueMap.get('.name'), formatString;
+}
+
 function getDHCPPools() {
        return uci.load('dhcp').then(function() {
                let sections = uci.sections('dhcp', 'dhcp'),
@@ -194,6 +209,34 @@ function validateServerSpec(sid, s) {
        return true;
 }
 
+function expandAndFormatMAC(macs) {
+       let result = [];
+
+       macs.forEach(mac => {
+               if (isValidMAC(mac)) {
+                       const expandedMac = mac.split(':').map(part => {
+                               return (part.length === 1 && part !== '*') ? '0' + part : part;
+                       }).join(':').toUpperCase();
+                       result.push(expandedMac);
+               }
+       });
+
+       return result.length ? result.join(' ') : null;
+}
+
+function isValidMAC(sid, s) {
+       if (!s)
+               return true;
+
+       let macaddrs = L.toArray(s);
+
+       for (var i = 0; i < macaddrs.length; i++)
+               if (!macaddrs[i].match(/^(([0-9a-f]{1,2}|\*)[:-]){5}([0-9a-f]{1,2}|\*)$/i))
+                       return _('Expecting a valid MAC address, optionally including wildcards') + _('; invalid MAC: ') + macaddrs[i];
+
+       return true;
+}
+
 function validateMACAddr(pools, sid, s) {
        if (s == null || s == '')
                return true;
@@ -225,7 +268,7 @@ function validateMACAddr(pools, sid, s) {
                }
        }
 
-       return true;
+       return isValidMAC(sid, s);
 }
 
 return view.extend({
@@ -234,7 +277,7 @@ return view.extend({
                        callHostHints(),
                        callDUIDHints(),
                        getDHCPPools(),
-                       network.getDevices()
+                       network.getNetworks()
                ]);
        },
 
@@ -243,61 +286,171 @@ return view.extend({
                    hosts = hosts_duids_pools[0],
                    duids = hosts_duids_pools[1],
                    pools = hosts_duids_pools[2],
-                   ndevs = hosts_duids_pools[3],
-                   m, s, o, ss, so;
+                   networks = hosts_duids_pools[3],
+                   m, s, o, ss, so, dnss;
+
+               let noi18nstrings = {
+                       etc_hosts: '<code>/etc/hosts</code>',
+                       etc_ethers: '<code>/etc/ethers</code>',
+                       localhost_v6: '<code>::1</code>',
+                       loopback_slash_8_v4: '<code>127.0.0.0/8</code>',
+                       not_found: '<code>Not found</code>',
+                       nxdomain: '<code>NXDOMAIN</code>',
+                       rfc_1918_link: '<a href="https://www.rfc-editor.org/rfc/rfc1918">RFC1918</a>',
+                       rfc_4193_link: '<a href="https://www.rfc-editor.org/rfc/rfc4193">RFC4193</a>',
+                       rfc_4291_link: '<a href="https://www.rfc-editor.org/rfc/rfc4291">RFC4291</a>',
+                       rfc_6303_link: '<a href="https://www.rfc-editor.org/rfc/rfc6303">RFC6303</a>',
+                       reverse_arpa: '<code>*.IN-ADDR.ARPA,*.IP6.ARPA</code>',
+                       servers_file_entry01: '<code>server=1.2.3.4</code>',
+                       servers_file_entry02: '<code>server=/domain/1.2.3.4</code>',
+
+               };
+
+               function customi18n(template, values) {
+                       if (!values)
+                               values = noi18nstrings;
+                       return template.replace(/\{(\w+)\}/g, (match, key) => values[key] || match);
+               };
 
                m = new form.Map('dhcp', _('DHCP and DNS'),
                        _('Dnsmasq is a lightweight <abbr title="Dynamic Host Configuration Protocol">DHCP</abbr> server and <abbr title="Domain Name System">DNS</abbr> forwarder.'));
 
                s = m.section(form.TypedSection, 'dnsmasq');
-               s.anonymous = true;
-               s.addremove = false;
+               s.anonymous = false;
+               s.addremove = true;
+               s.addbtntitle = _('Add server instance', 'Dnsmasq instance');
+
+               s.renderContents = function(/* ... */) {
+                       var renderTask = form.TypedSection.prototype.renderContents.apply(this, arguments),
+                           sections = this.cfgsections();
+
+                       return Promise.resolve(renderTask).then(function(nodes) {
+                               if (sections.length < 2) {
+                                       nodes.querySelector('#cbi-dhcp-dnsmasq > h3').remove();
+                                       nodes.querySelector('#cbi-dhcp-dnsmasq > .cbi-section-remove').remove();
+                               }
+                               else {
+                                       nodes.querySelectorAll('#cbi-dhcp-dnsmasq > .cbi-section-remove').forEach(function(div, i) {
+                                               var section = uci.get('dhcp', sections[i]),
+                                                   hline = div.nextElementSibling,
+                                                   btn = div.firstElementChild;
+
+                                               if (!section || section['.anonymous']) {
+                                                       hline.innerText = i ? _('Unnamed instance #%d', 'Dnsmasq instance').format(i+1) : _('Default instance', 'Dnsmasq instance');
+                                                       btn.innerText = i ? _('Remove instance #%d', 'Dnsmasq instance').format(i+1) : _('Remove default instance', 'Dnsmasq instance');
+                                               }
+                                               else {
+                                                       hline.innerText = _('Instance "%q"', 'Dnsmasq instance').format(section['.name']);
+                                                       btn.innerText = _('Remove instance "%q"', 'Dnsmasq instance').format(section['.name']);
+                                               }
+                                       });
+                               }
 
-               s.tab('general', _('General Settings'));
-               s.tab('relay', _('Relay'));
-               s.tab('files', _('Resolv and Hosts Files'));
-               s.tab('pxe_tftp', _('PXE/TFTP Settings'));
-               s.tab('advanced', _('Advanced Settings'));
+                               nodes.querySelector('#cbi-dhcp-dnsmasq > .cbi-section-create input').placeholder = _('New instance name…', 'Dnsmasq instance');
+
+                               return nodes;
+                       });
+               };
+
+
+               s.tab('general', _('General'));
+               s.tab('devices', _('Devices &amp; Ports'));
+               s.tab('dnsrecords', _('DNS Records'));
+               s.tab('dnssecopt', _('DNSSEC'));
+               s.tab('filteropts', _('Filter'));
+               s.tab('forward', _('Forwards'));
+               s.tab('limits', _('Limits'));
+               s.tab('logging', _('Log'));
+               s.tab('files', _('Resolv &amp; Hosts Files'));
                s.tab('leases', _('Static Leases'));
-               s.tab('hosts', _('Hostnames'));
-               s.tab('srvhosts', _('SRV'));
-               s.tab('mxhosts', _('MX'));
                s.tab('ipsets', _('IP Sets'));
+               s.tab('relay', _('Relay'));
+               s.tab('pxe_tftp', _('PXE/TFTP'));
 
-               s.taboption('general', form.Flag, 'domainneeded',
+               s.taboption('filteropts', form.Flag, 'domainneeded',
                        _('Domain required'),
-                       _('Do not forward DNS queries without dots or domain parts.'));
-
+                       _('Never forward DNS queries which lack dots or domain parts.') + '<br />' +
+                       customi18n(_('Names not in {etc_hosts} are answered {not_found}.') )
+               );
                s.taboption('general', form.Flag, 'authoritative',
                        _('Authoritative'),
                        _('This is the only DHCP server in the local network.'));
 
-               s.taboption('general', form.Value, 'local',
-                       _('Local server'),
-                       _('Never forward matching domains and subdomains, resolve from DHCP or hosts files only.'));
+               o = s.taboption('general', form.Value, 'local',
+                       _('Resolve these locally'),
+                       _('Never forward these matching domains or subdomains; resolve from DHCP or hosts files only.'));
+               o.placeholder = '/internal.example.com/private.example.com/example.org';
 
                s.taboption('general', form.Value, 'domain',
                        _('Local domain'),
                        _('Local domain suffix appended to DHCP names and hosts file entries.'));
 
-               o = s.taboption('general', form.Flag, 'logqueries',
+               s.taboption('general', form.Flag, 'expandhosts',
+                       _('Expand hosts'),
+                       _('Add local domain suffix to names served from hosts files.'));
+
+               o = s.taboption('logging', form.Flag, 'logqueries',
                        _('Log queries'),
-                       _('Write received DNS queries to syslog.'));
+                       _('Write received DNS queries to syslog.') + ' ' + _('Dump cache on SIGUSR1, include requesting IP.'));
+               o.optional = true;
+
+               o = s.taboption('logging', form.Flag, 'logdhcp',
+                       _('Extra DHCP logging'),
+                       _('Log all options sent to DHCP clients and the tags used to determine them.'));
                o.optional = true;
 
-               o = s.taboption('general', form.DynamicList, 'server',
-                       _('DNS forwardings'),
-                       _('List of upstream resolvers to forward queries to.'));
+               o = s.taboption('logging', form.Value, 'logfacility',
+                       _('Log facility'),
+                       _('Set log class/facility for syslog entries.'));
+               o.optional = true;
+               o.value('KERN');
+               o.value('USER');
+               o.value('MAIL');
+               o.value('DAEMON');
+               o.value('AUTH');
+               o.value('LPR');
+               o.value('NEWS');
+               o.value('UUCP');
+               o.value('CRON');
+               o.value('LOCAL0');
+               o.value('LOCAL1');
+               o.value('LOCAL2');
+               o.value('LOCAL3');
+               o.value('LOCAL4');
+               o.value('LOCAL5');
+               o.value('LOCAL6');
+               o.value('LOCAL7');
+               o.value('-', _('stderr'));
+
+               o = s.taboption('forward', form.DynamicList, 'server',
+                       _('DNS Forwards'),
+                       _('Forward specific domain queries to specific upstream servers.'));
                o.optional = true;
-               o.placeholder = '/example.org/10.1.2.3';
+               o.placeholder = '/*.example.org/10.1.2.3';
                o.validate = validateServerSpec;
 
                o = s.taboption('general', form.DynamicList, 'address',
                        _('Addresses'),
                        _('Resolve specified FQDNs to an IP.') + '<br />' +
-                       _('Syntax: <code>/fqdn[/fqdn…]/[ipaddr]</code>.') + '<br />' +
-                       _('<code>/#/</code> matches any domain. <code>/example.com/</code> returns NXDOMAIN.') + '<br />' +
-                       _('<code>/example.com/#</code> returns NULL addresses (<code>0.0.0.0</code> and <code>::</code>) for example.com and its subdomains.'));
+                       customi18n(_('Syntax: {code_syntax}.'),
+                               {code_syntax: '<code>/fqdn[/fqdn…]/[ipaddr]</code>'}) + '<br />' +
+                       customi18n(_('{example_nx} returns {nxdomain}.',
+                               'hint: <code>/example.com/</code> returns <code>NXDOMAIN</code>.'),
+                               {example_nx: '<code>/example.com/</code>', nxdomain: '<code>NXDOMAIN</code>'}) + '<br />' +
+                       customi18n(_('{any_domain} matches any domain (and returns {nxdomain}).',
+                               'hint: <code>/#/</code> matches any domain (and returns NXDOMAIN).'),
+                               {any_domain:'<code>/#/</code>', nxdomain: '<code>NXDOMAIN</code>'}) + '<br />' +
+                       customi18n(
+                               _('{example_null} returns {null_addr} addresses ({null_ipv4}, {null_ipv6}) for {example_com} and its subdomains.',
+                                       'hint: <code>/example.com/#</code> returns NULL addresses (<code>0.0.0.0</code>, <code>::</code>) for example.com and its subdomains.'),
+                               {       example_null: '<code>/example.com/#</code>',
+                                       null_addr: '<code>NULL</code>', 
+                                       null_ipv4: '<code>0.0.0.0</code>',
+                                       null_ipv6: '<code>::</code>',
+                                       example_com: '<code>example.com</code>',
+                               }
+                       )
+               );
                o.optional = true;
                o.placeholder = '/router.local/router.lan/192.168.0.1';
 
@@ -307,48 +460,55 @@ return view.extend({
                o.optional = true;
                o.placeholder = '/example.org/ipset,ipset6';
 
-               o = s.taboption('general', form.Flag, 'rebind_protection',
+               o = s.taboption('filteropts', form.Flag, 'rebind_protection',
                        _('Rebind protection'),
-                       _('Discard upstream responses containing <a href="%s">RFC1918</a> addresses.').format('https://datatracker.ietf.org/doc/html/rfc1918'));
+                       customi18n(_('Discard upstream responses containing {rfc_1918_link} addresses.') ) + '<br />' +
+                       customi18n(_('Discard also upstream responses containing {rfc_4193_link}, Link-Local and private IPv4-Mapped {rfc_4291_link} IPv6 Addresses.') )        
+               );
                o.rmempty = false;
 
-               o = s.taboption('general', form.Flag, 'rebind_localhost',
+               o = s.taboption('filteropts', form.Flag, 'rebind_localhost',
                        _('Allow localhost'),
-                       _('Exempt <code>127.0.0.0/8</code> and <code>::1</code> from rebinding checks, e.g. for RBL services.'));
+                       customi18n(
+                       _('Exempt {loopback_slash_8_v4} and {localhost_v6} from rebinding checks, e.g. for <abbr title="Real-time Block List">RBL</abbr> services.')
+                       )
+               );
                o.depends('rebind_protection', '1');
 
-               o = s.taboption('general', form.DynamicList, 'rebind_domain',
+               o = s.taboption('filteropts', form.DynamicList, 'rebind_domain',
                        _('Domain whitelist'),
-                       _('List of domains to allow RFC1918 responses for.'));
+                       customi18n(_('List of domains to allow {rfc_1918_link} responses for.') )
+               );
                o.depends('rebind_protection', '1');
                o.optional = true;
                o.placeholder = 'ihost.netflix.com';
                o.validate = validateAddressList;
 
-               o = s.taboption('general', form.Flag, 'localservice',
+               o = s.taboption('filteropts', form.Flag, 'localservice',
                        _('Local service only'),
                        _('Accept DNS queries only from hosts whose address is on a local subnet.'));
                o.optional = false;
                o.rmempty = false;
 
-               o = s.taboption('general', form.Flag, 'nonwildcard',
+               o = s.taboption('devices', form.Flag, 'nonwildcard',
                        _('Non-wildcard'),
-                       _('Bind dynamically to interfaces rather than wildcard address.'));
+                       _('Bind only to configured interface addresses, instead of the wildcard address.'));
                o.default = o.enabled;
                o.optional = false;
                o.rmempty = true;
 
-               o = s.taboption('general', form.DynamicList, 'interface',
+               o = s.taboption('devices', widgets.NetworkSelect, 'interface',
                        _('Listen interfaces'),
                        _('Listen only on the specified interfaces, and loopback if not excluded explicitly.'));
-               o.optional = true;
-               o.placeholder = 'lan';
+               o.multiple = true;
+               o.nocreate = true;
 
-               o = s.taboption('general', form.DynamicList, 'notinterface',
+               o = s.taboption('devices', widgets.NetworkSelect, 'notinterface',
                        _('Exclude interfaces'),
                        _('Do not listen on the specified interfaces.'));
-               o.optional = true;
-               o.placeholder = 'loopback';
+               o.loopback = true;
+               o.multiple = true;
+               o.nocreate = true;
 
                o = s.taboption('relay', form.SectionValue, '__relays__', form.TableSection, 'relay', null,
                        _('Relay DHCP requests elsewhere. OK: v4↔v4, v6↔v6. Not OK: v4↔v6, v6↔v4.')
@@ -363,28 +523,27 @@ return view.extend({
                ss.rowcolors = true;
                ss.nodescriptions = true;
 
-               so = ss.option(form.Value, 'id', _('ID'));
-               so.rmempty = false;
-               so.optional = true;
-
-               so = ss.option(widgets.NetworkSelect, 'interface', _('Interface'));
-               so.optional = true;
-               so.rmempty = false;
-               so.placeholder = 'lan';
-
-               so = ss.option(form.Value, 'local_addr', _('Listen address'));
+               so = ss.option(form.Value, 'local_addr', _('Relay from'));
                so.rmempty = false;
                so.datatype = 'ipaddr';
 
                for (var family = 4; family <= 6; family += 2) {
-                       for (var i = 0; i < ndevs.length; i++) {
-                               var addrs = (family == 6) ? ndevs[i].getIP6Addrs() : ndevs[i].getIPAddrs();
-                               for (var j = 0; j < addrs.length; j++)
-                                       so.value(addrs[j].split('/')[0]);
+                       for (var i = 0; i < networks.length; i++) {
+                               if (networks[i].getName() != 'loopback') {
+                                       var addrs = (family == 6) ? networks[i].getIP6Addrs() : networks[i].getIPAddrs();
+                                       for (var j = 0; j < addrs.length; j++) {
+                                               var addr = addrs[j].split('/')[0];
+                                               so.value(addr, E([], [
+                                                       addr, ' (',
+                                                       widgets.NetworkSelect.prototype.renderIfaceBadge(networks[i]),
+                                                       ')'
+                                               ]));
+                                       }
+                               }
                        }
                }
 
-               so = ss.option(form.Value, 'server_addr', _('Relay To address'));
+               so = ss.option(form.Value, 'server_addr', _('Relay to address'));
                so.rmempty = false;
                so.optional = false;
                so.placeholder = '192.168.10.1#535';
@@ -393,26 +552,36 @@ return view.extend({
                        var m = this.section.formvalue(section, 'local_addr'),
                            n = this.section.formvalue(section, 'server_addr'),
                            p;
-                       if (n != null && n != '')
-                           p = n.split('#');
+
+                       if (!m || !n) {
+                               return _('Both "Relay from" and "Relay to address" must be specified.');
+                       }
+                       else {
+                               p = n.split('#');
                                if (p.length > 1 && !/^[0-9]+$/.test(p[1]))
                                        return _('Expected port number.');
                                else
                                        n = p[0];
 
-                       if ((m == null || m == '') && (n == null || n == ''))
-                               return _('Both Listen addr and Relay To must be specified.');
-
-                       if ((validation.parseIPv6(m) && validation.parseIPv6(n)) ||
-                               validation.parseIPv4(m) && validation.parseIPv4(n))
-                               return true;
-                       else
-                               return _('Listen and Relay To IP family must be homogeneous.')
+                               if ((validation.parseIPv6(m) && validation.parseIPv6(n)) ||
+                                       validation.parseIPv4(m) && validation.parseIPv4(n))
+                                       return true;
+                               else
+                                       return _('Address families of "Relay from" and "Relay to address" must match.')
+                       }
+                       return true;
                };
 
+
+               so = ss.option(widgets.NetworkSelect, 'interface', _('Only accept replies via'));
+               so.optional = true;
+               so.rmempty = false;
+               so.placeholder = 'lan';
+
                s.taboption('files', form.Flag, 'readethers',
-                       _('Use <code>/etc/ethers</code>'),
-                       _('Read <code>/etc/ethers</code> to configure the DHCP server.'));
+                       customi18n(_('Use {etc_ethers}') ),
+                       customi18n(_('Read {etc_ethers} to configure the DHCP server.') )
+                       );
 
                s.taboption('files', form.Value, 'leasefile',
                        _('Lease file'),
@@ -429,8 +598,14 @@ return view.extend({
                o.placeholder = '/tmp/resolv.conf.d/resolv.conf.auto';
                o.optional = true;
 
+               o = s.taboption('files', form.Flag, 'strictorder',
+                       _('Strict order'),
+                       _('Query upstream resolvers in the order they appear in the resolv file.'));
+               o.optional = true;
+
                o = s.taboption('files', form.Flag, 'nohosts',
-                       _('Ignore <code>/etc/hosts</code>'));
+                       customi18n(_('Ignore {etc_hosts}') )
+               );
                o.optional = true;
 
                o = s.taboption('files', form.DynamicList, 'addnhosts',
@@ -438,125 +613,152 @@ return view.extend({
                o.optional = true;
                o.placeholder = '/etc/dnsmasq.hosts';
 
-               o = s.taboption('advanced', form.Flag, 'quietdhcp',
+               o = s.taboption('logging', form.Flag, 'quietdhcp',
                        _('Suppress logging'),
                        _('Suppress logging of the routine operation for the DHCP protocol.'));
                o.optional = true;
+               o.depends('logdhcp', '0');
 
-               o = s.taboption('advanced', form.Flag, 'sequential_ip',
+               o = s.taboption('general', form.Flag, 'sequential_ip',
                        _('Allocate IPs sequentially'),
                        _('Allocate IP addresses sequentially, starting from the lowest available address.'));
                o.optional = true;
 
-               o = s.taboption('advanced', form.Flag, 'boguspriv',
+               o = s.taboption('filteropts', form.Flag, 'boguspriv',
                        _('Filter private'),
-                       _('Do not forward reverse lookups for local networks.'));
+                       customi18n(
+                       _('Reject reverse lookups to {rfc_6303_link} IP ranges ({reverse_arpa}) not in {etc_hosts}.') )
+               ); 
                o.default = o.enabled;
 
-               s.taboption('advanced', form.Flag, 'filterwin2k',
+               s.taboption('filteropts', form.Flag, 'filterwin2k',
                        _('Filter SRV/SOA service discovery'),
                        _('Filters SRV/SOA service discovery, to avoid triggering dial-on-demand links.') + '<br />' +
                        _('May prevent VoIP or other services from working.'));
 
-               o = s.taboption('advanced', form.Flag, 'filter_aaaa',
+               o = s.taboption('filteropts', form.Flag, 'filter_aaaa',
                        _('Filter IPv6 AAAA records'),
                        _('Remove IPv6 addresses from the results and only return IPv4 addresses.') + '<br />' +
                        _('Can be useful if ISP has IPv6 nameservers but does not provide IPv6 routing.'));
                o.optional = true;
 
-               o = s.taboption('advanced', form.Flag, 'filter_a',
+               o = s.taboption('filteropts', form.Flag, 'filter_a',
                        _('Filter IPv4 A records'),
                        _('Remove IPv4 addresses from the results and only return IPv6 addresses.'));
                o.optional = true;
 
-               s.taboption('advanced', form.Flag, 'localise_queries',
+               s.taboption('filteropts', form.Flag, 'localise_queries',
                        _('Localise queries'),
-                       _('Return answers to DNS queries matching the subnet from which the query was received if multiple IPs are available.'));
+                       customi18n(_('Limit response records (from {etc_hosts}) to those that fall within the subnet of the querying interface.') ) + '<br />' +
+                       _('This prevents unreachable IPs in subnets not accessible to you.') + '<br />' +
+                       _('Note: IPv4 only.'));
 
                if (L.hasSystemFeature('dnsmasq', 'dnssec')) {
-                       o = s.taboption('advanced', form.Flag, 'dnssec',
+                       o = s.taboption('dnssecopt', form.Flag, 'dnssec',
                                _('DNSSEC'),
                                _('Validate DNS replies and cache DNSSEC data, requires upstream to support DNSSEC.'));
                        o.optional = true;
 
-                       o = s.taboption('advanced', form.Flag, 'dnsseccheckunsigned',
+                       o = s.taboption('dnssecopt', form.Flag, 'dnsseccheckunsigned',
                                _('DNSSEC check unsigned'),
                                _('Verify unsigned domain responses really come from unsigned domains.'));
                        o.default = o.enabled;
                        o.optional = true;
                }
 
-               s.taboption('advanced', form.Flag, 'expandhosts',
-                       _('Expand hosts'),
-                       _('Add local domain suffix to names served from hosts files.'));
-
-               s.taboption('advanced', form.Flag, 'nonegcache',
+               s.taboption('filteropts', form.Flag, 'nonegcache',
                        _('No negative cache'),
                        _('Do not cache negative replies, e.g. for non-existent domains.'));
 
-               o = s.taboption('advanced', form.Value, 'serversfile',
+               o = s.taboption('forward', form.Value, 'serversfile',
                        _('Additional servers file'),
-                       _('File listing upstream resolvers, optionally domain-specific, e.g. <code>server=1.2.3.4</code>, <code>server=/domain/1.2.3.4</code>.'));
+                       customi18n(_('File listing upstream resolvers, optionally domain-specific, e.g. {servers_file_entry01}, {servers_file_entry02}.') )
+               );
                o.placeholder = '/etc/dnsmasq.servers';
 
-               o = s.taboption('advanced', form.Flag, 'strictorder',
-                       _('Strict order'),
-                       _('Upstream resolvers will be queried in the order of the resolv file.'));
-               o.optional = true;
-
-               o = s.taboption('advanced', form.Flag, 'allservers',
+               o = s.taboption('general', form.Flag, 'allservers',
                        _('All servers'),
-                       _('Query all available upstream resolvers.'));
+                       _('Query all available upstream resolvers.') + ' ' + _('First answer wins.'));
                o.optional = true;
 
-               o = s.taboption('advanced', form.DynamicList, 'bogusnxdomain',
-                       _('IPs to override with NXDOMAIN'),
-                       _('List of IP addresses to convert into NXDOMAIN responses.'));
+               o = s.taboption('filteropts', form.DynamicList, 'bogusnxdomain',
+                       customi18n(_('IPs to override with {nxdomain}') ),
+                       customi18n(_('Transform replies which contain the specified addresses or subnets into {nxdomain} responses.') )
+               );
                o.optional = true;
                o.placeholder = '64.94.110.11';
 
-               o = s.taboption('advanced', form.Value, 'port',
+               o = s.taboption('devices', form.Value, 'port',
                        _('DNS server port'),
                        _('Listening port for inbound DNS queries.'));
                o.optional = true;
                o.datatype = 'port';
                o.placeholder = 53;
 
-               o = s.taboption('advanced', form.Value, 'queryport',
+               o = s.taboption('devices', form.Value, 'queryport',
                        _('DNS query port'),
                        _('Fixed source port for outbound DNS queries.'));
                o.optional = true;
                o.datatype = 'port';
                o.placeholder = _('any');
 
-               o = s.taboption('advanced', form.Value, 'dhcpleasemax',
+               o = s.taboption('devices', form.Value, 'minport',
+                       _('Minimum source port #'),
+                       _('Min valid value %s.').format('<code>1024</code>') + ' ' + _('Useful for systems behind firewalls.'));
+               o.optional = true;
+               o.datatype = 'port';
+               o.placeholder = 1024;
+               o.depends('queryport', '');
+
+               o = s.taboption('devices', form.Value, 'maxport',
+                       _('Maximum source port #'),
+                       _('Max valid value %s.').format('<code>65535</code>') + ' ' + _('Useful for systems behind firewalls.'));
+               o.optional = true;
+               o.datatype = 'port';
+               o.placeholder = 50000;
+               o.depends('queryport', '');
+
+               o = s.taboption('limits', form.Value, 'dhcpleasemax',
                        _('Max. DHCP leases'),
                        _('Maximum allowed number of active DHCP leases.'));
                o.optional = true;
                o.datatype = 'uinteger';
-               o.placeholder = _('unlimited');
+               o.placeholder = 150;
 
-               o = s.taboption('advanced', form.Value, 'ednspacket_max',
+               o = s.taboption('limits', form.Value, 'ednspacket_max',
                        _('Max. EDNS0 packet size'),
                        _('Maximum allowed size of EDNS0 UDP packets.'));
                o.optional = true;
                o.datatype = 'uinteger';
                o.placeholder = 1280;
 
-               o = s.taboption('advanced', form.Value, 'dnsforwardmax',
+               o = s.taboption('limits', form.Value, 'dnsforwardmax',
                        _('Max. concurrent queries'),
                        _('Maximum allowed number of concurrent DNS queries.'));
                o.optional = true;
                o.datatype = 'uinteger';
                o.placeholder = 150;
 
-               o = s.taboption('advanced', form.Value, 'cachesize',
+               o = s.taboption('limits', form.Value, 'cachesize',
                        _('Size of DNS query cache'),
                        _('Number of cached DNS entries, 10000 is maximum, 0 is no caching.'));
                o.optional = true;
                o.datatype = 'range(0,10000)';
                o.placeholder = 1000;
 
+               o = s.taboption('limits', form.Value, 'min_cache_ttl',
+                       _('Min cache TTL'),
+                       _('Extend short TTL values to the seconds value given when caching them. Use with caution.') +
+                       _(' (Max 1h == 3600)'));
+               o.optional = true;
+               o.placeholder = 60;
+
+               o = s.taboption('limits', form.Value, 'max_cache_ttl',
+                       _('Max cache TTL'),
+                       _('Set a maximum seconds TTL value for entries in the cache.'));
+               o.optional = true;
+               o.placeholder = 3600;
+
                o = s.taboption('pxe_tftp', form.Flag, 'enable_tftp',
                        _('Enable TFTP server'),
                        _('Enable the built-in single-instance TFTP server.'));
@@ -582,6 +784,7 @@ return view.extend({
                ss = o.subsection;
                ss.addremove = true;
                ss.anonymous = true;
+               ss.modaltitle = _('Edit PXE/TFTP/BOOTP Host');
                ss.nodescriptions = true;
 
                so = ss.option(form.Value, 'filename',
@@ -604,19 +807,20 @@ return view.extend({
 
                so = ss.option(form.DynamicList, 'dhcp_option',
                        _('DHCP Options'),
-                       _('Options for the Network-ID. (Note: needs also Network-ID.) E.g. "<code>42,192.168.1.4</code>" for NTP server, "<code>3,192.168.4.4</code>" for default route. <code>0.0.0.0</code> means "the address of the system running dnsmasq".'));
+                       _('Additional options to send to the below match tags.') + '<br />' +
+                       _('%s means "the address of the system running dnsmasq".').format('<code>0.0.0.0</code>'));
                so.optional = true;
-               so.placeholder = '42,192.168.1.4';
+               so.placeholder = 'option:root-path,192.168.1.2:/data/netboot/root';
 
-               so = ss.option(widgets.DeviceSelect, 'networkid',
-                       _('Network-ID'),
-                       _('Apply DHCP Options to this net. (Empty = all clients).'));
+               so = ss.option(form.Value, 'networkid',
+                       _('Match this Tag'),
+                       _('Only DHCP Clients with this tag are sent this boot option.'));
                so.optional = true;
                so.noaliases = true;
 
                so = ss.option(form.Flag, 'force',
                        _('Force'),
-                       _('Always send DHCP Options. Sometimes needed, with e.g. PXELinux.'));
+                       _('Always send the chosen DHCP options. Sometimes needed, with e.g. PXELinux.'));
                so.optional = true;
 
                so = ss.option(form.Value, 'instance',
@@ -625,10 +829,23 @@ return view.extend({
                so.optional = true;
 
                Object.values(L.uci.sections('dhcp', 'dnsmasq')).forEach(function(val, index) {
-                       so.value(index, '%s (Domain: %s, Local: %s)'.format(index, val.domain || '?', val.local || '?'));
+                       var name, display_str = generateDnsmasqInstanceEntry(val);
+                       so.value(index, display_str);
                });
 
-               o = s.taboption('srvhosts', form.SectionValue, '__srvhosts__', form.TableSection, 'srvhost', null,
+               o = s.taboption('dnsrecords', form.SectionValue, '__dnsrecords__', form.TypedSection, '__dnsrecords__');
+
+               dnss = o.subsection;
+
+               dnss.anonymous = true;
+               dnss.cfgsections = function() { return [ '__dnsrecords__' ] };
+
+               dnss.tab('hosts', _('Hostnames'));
+               dnss.tab('srvhosts', _('SRV'));
+               dnss.tab('mxhosts', _('MX'));
+               dnss.tab('cnamehosts', _('CNAME'));
+
+               o = dnss.taboption('srvhosts', form.SectionValue, '__srvhosts__', form.TableSection, 'srvhost', null,
                        _('Bind service records to a domain name: specify the location of services. See <a href="%s">RFC2782</a>.').format('https://datatracker.ietf.org/doc/html/rfc2782')
                        + '<br />' + _('_service: _sip, _ldap, _imap, _stun, _xmpp-client, … . (Note: while _http is possible, no browsers support SRV records.)')
                        + '<br />' + _('_proto: _tcp, _udp, _sctp, _quic, … .')
@@ -642,15 +859,15 @@ return view.extend({
                ss.sortable  = true;
                ss.rowcolors = true;
 
-               so = ss.option(form.Value, 'srv', _('SRV'), _('Syntax: <code>_service._proto.example.com</code>.'));
+               so = ss.option(form.Value, 'srv', _('SRV'), _('Syntax:') + ' ' + '<code>_service._proto.example.com.</code>');
                so.rmempty = false;
                so.datatype = 'hostname';
-               so.placeholder = '_sip._tcp.example.com';
+               so.placeholder = '_sip._tcp.example.com.';
 
                so = ss.option(form.Value, 'target', _('Target'), _('CNAME or fqdn'));
                so.rmempty = false;
                so.datatype = 'hostname';
-               so.placeholder = 'sip.example.com';
+               so.placeholder = 'sip.example.com.';
 
                so = ss.option(form.Value, 'port', _('Port'));
                so.rmempty = false;
@@ -667,7 +884,7 @@ return view.extend({
                so.datatype = 'range(0,65535)';
                so.placeholder = '50';
 
-               o = s.taboption('mxhosts', form.SectionValue, '__mxhosts__', form.TableSection, 'mxhost', null,
+               o = dnss.taboption('mxhosts', form.SectionValue, '__mxhosts__', form.TableSection, 'mxhost', null,
                        _('Bind service records to a domain name: specify the location of services.')
                         + '<br />' + _('You may add multiple records for the same domain.'));
 
@@ -682,19 +899,40 @@ return view.extend({
                so = ss.option(form.Value, 'domain', _('Domain'));
                so.rmempty = false;
                so.datatype = 'hostname';
-               so.placeholder = 'example.com';
+               so.placeholder = 'example.com.';
 
                so = ss.option(form.Value, 'relay', _('Relay'));
                so.rmempty = false;
                so.datatype = 'hostname';
-               so.placeholder = 'relay.example.com';
+               so.placeholder = 'relay.example.com.';
 
                so = ss.option(form.Value, 'pref', _('Priority'), _('Ordinal: lower comes first.'));
                so.rmempty = true;
                so.datatype = 'range(0,65535)';
                so.placeholder = '0';
 
-               o = s.taboption('hosts', form.SectionValue, '__hosts__', form.GridSection, 'domain', null,
+               o = dnss.taboption('cnamehosts', form.SectionValue, '__cname__', form.TableSection, 'cname', null,
+                       _('Set an alias for a hostname.'));
+
+               ss = o.subsection;
+
+               ss.addremove = true;
+               ss.anonymous = true;
+               ss.sortable  = true;
+               ss.rowcolors = true;
+               ss.nodescriptions = true;
+
+               so = ss.option(form.Value, 'cname', _('Domain'));
+               so.rmempty = false;
+               so.validate = validateHostname;
+               so.placeholder = 'www.example.com.';
+
+               so = ss.option(form.Value, 'target', _('Target'));
+               so.rmempty = false;
+               so.datatype = 'hostname';
+               so.placeholder = 'example.com.';
+
+               o = dnss.taboption('hosts', form.SectionValue, '__hosts__', form.GridSection, 'domain', null,
                        _('Hostnames are used to bind a domain name to an IP address. This setting is redundant for hostnames already configured with static leases, but it can be useful to rebind an FQDN.'));
 
                ss = o.subsection;
@@ -725,22 +963,41 @@ return view.extend({
                });
 
                o = s.taboption('ipsets', form.SectionValue, '__ipsets__', form.GridSection, 'ipset', null,
-                       _('List of IP sets to populate with the IPs of DNS lookup results of the FQDNs also specified here.'));
+                       _('List of IP sets to populate with the IPs of DNS lookup results of the FQDNs also specified here.') + '<br />' +
+                       _('The netfilter components below are only regarded when running fw4.'));
 
                ss = o.subsection;
 
                ss.addremove = true;
                ss.anonymous = true;
                ss.sortable  = true;
+               ss.rowcolors = true;
+               ss.nodescriptions = true;
+               ss.modaltitle = _('Edit IP set');
 
-               so = ss.option(form.DynamicList, 'name', _('IP set'));
+               so = ss.option(form.DynamicList, 'name', _('Name of the set'));
                so.rmempty = false;
+               so.editable = true;
                so.datatype = 'string';
 
-               so = ss.option(form.DynamicList, 'domain', _('Domain'));
+               so = ss.option(form.DynamicList, 'domain', _('FQDN'));
                so.rmempty = false;
+               so.editable = true;
                so.datatype = 'hostname';
 
+               so = ss.option(form.Value, 'table', _('Netfilter table name'), _('Defaults to fw4.'));
+               so.editable = true;
+               so.placeholder = 'fw4';
+               so.rmempty = true;
+
+               so = ss.option(form.ListValue, 'table_family', _('Table IP family'), _('Defaults to IPv4+6.') + ' ' + _('Can be hinted by adding 4 or 6 to the name.') + '<br />' +
+                       _('Adding an IPv6 to an IPv4 set and vice-versa silently fails.'));
+               so.editable = true;
+               so.rmempty = true;
+               so.value('inet', _('IPv4+6'));
+               so.value('ip', _('IPv4'));
+               so.value('ip6', _('IPv6'));
+
                o = s.taboption('leases', form.SectionValue, '__leases__', form.GridSection, 'host', null,
                        _('Static leases are used to assign fixed IP addresses and symbolic hostnames to DHCP clients. They are also required for non-dynamic interface configurations where only hosts with a corresponding lease are served.') + '<br /><br />' +
                        _('Use the <em>Add</em> Button to add a new lease entry. The <em>MAC address</em> identifies the host, the <em>IPv4 address</em> specifies the fixed address to use, and the <em>Hostname</em> is assigned as a symbolic name to the requesting host. The optional <em>Lease time</em> can be used to set non-standard host-specific lease time, e.g. 12h, 3d or infinite.') + '<br /><br />' +
@@ -769,61 +1026,18 @@ return view.extend({
                        uci.unset('dhcp', section, 'dns');
                };
 
-               so = ss.option(form.Value, 'mac',
+               //this can be a .DynamicList or a .Value with a widget and dnsmasq handles multimac OK.
+               so = ss.option(form.DynamicList, 'mac',
                        _('MAC address(es)'),
-                       _('The hardware address(es) of this entry/host, separated by spaces.') + '<br /><br />' + 
+                       _('The hardware address(es) of this entry/host.') + '<br /><br />' + 
                        _('In DHCPv4, it is possible to include more than one mac address. This allows an IP address to be associated with multiple macaddrs, and dnsmasq abandons a DHCP lease to one of the macaddrs when another asks for a lease. It only works reliably if only one of the macaddrs is active at any time.'));
                //As a special case, in DHCPv4, it is possible to include more than one hardware address. eg: --dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2 This allows an IP address to be associated with multiple hardware addresses, and gives dnsmasq permission to abandon a DHCP lease to one of the hardware addresses when another one asks for a lease
-               so.validate = function(section_id, value) {
-                       var macaddrs = L.toArray(value);
-
-                       for (var i = 0; i < macaddrs.length; i++)
-                               if (!macaddrs[i].match(/^([a-fA-F0-9]{2}|\*):([a-fA-F0-9]{2}:|\*:){4}(?:[a-fA-F0-9]{2}|\*)$/))
-                                       return _('Expecting a valid MAC address, optionally including wildcards');
-
-                       return true;
-               };
                so.rmempty  = true;
                so.cfgvalue = function(section) {
-                       var macs = L.toArray(uci.get('dhcp', section, 'mac')),
-                           result = [];
-
-                       for (var i = 0, mac; (mac = macs[i]) != null; i++)
-                               if (/^([0-9a-fA-F]{1,2}|\*):([0-9a-fA-F]{1,2}|\*):([0-9a-fA-F]{1,2}|\*):([0-9a-fA-F]{1,2}|\*):([0-9a-fA-F]{1,2}|\*):([0-9a-fA-F]{1,2}|\*)$/.test(mac)) {
-                                       var m = [
-                                               parseInt(RegExp.$1, 16), parseInt(RegExp.$2, 16),
-                                               parseInt(RegExp.$3, 16), parseInt(RegExp.$4, 16),
-                                               parseInt(RegExp.$5, 16), parseInt(RegExp.$6, 16)
-                                       ];
-
-                                       result.push(m.map(function(n) { return isNaN(n) ? '*' : '%02X'.format(n) }).join(':'));
-                               }
-                       return result.length ? result.join(' ') : null;
-               };
-               so.renderWidget = function(section_id, option_index, cfgvalue) {
-                       var node = form.Value.prototype.renderWidget.apply(this, [section_id, option_index, cfgvalue]),
-                           ipopt = this.section.children.filter(function(o) { return o.option == 'ip' })[0];
-
-                       node.addEventListener('cbi-dropdown-change', L.bind(function(ipopt, section_id, ev) {
-                               var mac = ev.detail.value.value;
-                               if (mac == null || mac == '' || !hosts[mac])
-                                       return;
-
-                               var iphint = L.toArray(hosts[mac].ipaddrs || hosts[mac].ipv4)[0];
-                               if (iphint == null)
-                                       return;
-
-                               var ip = ipopt.formvalue(section_id);
-                               if (ip != null && ip != '')
-                                       return;
-
-                               var node = ipopt.map.findElement('id', ipopt.cbid(section_id));
-                               if (node)
-                                       dom.callClassMethod(node, 'setValue', iphint);
-                       }, this, ipopt, section_id));
-
-                       return node;
+                       var macs = L.toArray(uci.get('dhcp', section, 'mac'));
+                       return expandAndFormatMAC(macs);
                };
+               //removed jows renderwidget function which hindered multi-mac entry
                so.validate = validateMACAddr.bind(so, pools);
                Object.keys(hosts).forEach(function(mac) {
                        var hint = hosts[mac].name || L.toArray(hosts[mac].ipaddrs || hosts[mac].ipv4)[0];
@@ -837,10 +1051,10 @@ return view.extend({
                        var m = this.section.formvalue(section, 'mac'),
                            n = this.section.formvalue(section, 'name');
 
-                       if ((m == null || m == '') && (n == null || n == ''))
+                       if ((m && !m.length > 0) && !n)
                                return _('One of hostname or MAC address must be specified!');
 
-                       if (value == null || value == '' || value == 'ignore')
+                       if (!value || value == 'ignore')
                                return true;
 
                        var leases = uci.sections('dhcp', 'host');
@@ -883,8 +1097,8 @@ return view.extend({
 
                so = ss.option(form.Value, 'hostid',
                        _('IPv6-Suffix (hex)'),
-                       _('The IPv6 interface identifier (address suffix) as hexadecimal number (max. 8 chars).'));
-               so.datatype = 'and(rangelength(0,8),hexstring)';
+                       _('The IPv6 interface identifier (address suffix) as hexadecimal number (max. 16 chars).'));
+               so.datatype = 'and(rangelength(0,16),hexstring)';
 
                so = ss.option(form.DynamicList, 'tag',
                        _('Tag'),
@@ -892,9 +1106,9 @@ return view.extend({
 
                so = ss.option(form.DynamicList, 'match_tag',
                        _('Match Tag'),
-                       _('When a host matches an entry then the special tag <em>known</em> is set. Use <em>known</em> to match all known hosts.') + '<br /><br />' +
-                       _('Ignore requests from unknown machines using <em>!known</em>.') + '<br /><br />' +
-                       _('If a host matches an entry which cannot be used because it specifies an address on a different subnet, the tag <em>known-othernet</em> is set.'));
+                       _('When a host matches an entry then the special tag %s is set. Use %s to match all known hosts.').format('<code>known</code>', '<code>known</code>') + '<br /><br />' +
+                       _('Ignore requests from unknown machines using %s.').format('<code>!known</code>') + '<br /><br />' +
+                       _('If a host matches an entry which cannot be used because it specifies an address on a different subnet, the tag %s is set.').format('<code>known-othernet</code>'));
                so.value('known', _('known'));
                so.value('!known', _('!known (not known)'));
                so.value('known-othernet', _('known-othernet (on different subnet)'));
@@ -906,7 +1120,8 @@ return view.extend({
                so.optional = true;
 
                Object.values(L.uci.sections('dhcp', 'dnsmasq')).forEach(function(val, index) {
-                       so.value(index, '%s (Domain: %s, Local: %s)'.format(index, val.domain || '?', val.local || '?'));
+                       var name, display_str = generateDnsmasqInstanceEntry(val);
+                       so.value(index, display_str);
                });
 
 
@@ -983,7 +1198,7 @@ return view.extend({
 
                                                                return [
                                                                        host || '-',
-                                                                       lease.ip6addrs ? lease.ip6addrs.join(' ') : lease.ip6addr,
+                                                                       lease.ip6addrs ? lease.ip6addrs.join('<br />') : lease.ip6addr,
                                                                        lease.duid,
                                                                        exp
                                                                ];