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 44f3346595e2c24389d3bd6061c6bfa300fb527a..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'),
@@ -126,7 +141,7 @@ function validateHostname(sid, s) {
        if (s.length > 256)
                return _('Expecting: %s').format(_('valid hostname'));
 
-       var labels = s.replace(/^\.+|\.$/g, '').split(/\./);
+       var labels = s.replace(/^\*?\.?|\.$/g, '').split(/\./);
 
        for (var i = 0; i < labels.length; i++)
                if (!labels[i].match(/^[a-z0-9_](?:[a-z0-9-]{0,61}[a-z0-9])?$/i))
@@ -156,13 +171,15 @@ function validateServerSpec(sid, s) {
        if (s == null || s == '')
                return true;
 
-       var m = s.match(/^(?:\/(.+)\/)?(.*)$/);
+       var m = s.match(/^(\/.*\/)?(.*)$/);
        if (!m)
                return _('Expecting: %s').format(_('valid hostname'));
 
-       var res = validateAddressList(sid, m[1]);
-       if (res !== true)
-               return res;
+       if (m[1] != '//' && m[1] != '/#/') {
+               var res = validateAddressList(sid, m[1]);
+               if (res !== true)
+                       return res;
+       }
 
        if (m[2] == '' || m[2] == '#')
                return true;
@@ -192,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;
@@ -223,7 +268,7 @@ function validateMACAddr(pools, sid, s) {
                }
        }
 
-       return true;
+       return isValidMAC(sid, s);
 }
 
 return view.extend({
@@ -231,7 +276,8 @@ return view.extend({
                return Promise.all([
                        callHostHints(),
                        callDUIDHints(),
-                       getDHCPPools()
+                       getDHCPPools(),
+                       network.getNetworks()
                ]);
        },
 
@@ -240,111 +286,302 @@ return view.extend({
                    hosts = hosts_duids_pools[0],
                    duids = hosts_duids_pools[1],
                    pools = hosts_duids_pools[2],
-                   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']);
+                                               }
+                                       });
+                               }
+
+                               nodes.querySelector('#cbi-dhcp-dnsmasq > .cbi-section-create input').placeholder = _('New instance name…', 'Dnsmasq instance');
 
-               s.tab('general', _('General Settings'));
-               s.tab('files', _('Resolv and Hosts Files'));
-               s.tab('pxe_tftp', _('PXE/TFTP Settings'));
-               s.tab('advanced', _('Advanced Settings'));
+                               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('general', form.DynamicList, 'server',
-                       _('DNS forwardings'),
-                       _('List of upstream resolvers to forward queries to.'));
+               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('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'),
-                       _('List of domains to force to an IP address.'));
+                       _('Resolve specified FQDNs to an IP.') + '<br />' +
+                       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/192.168.0.1';
+               o.placeholder = '/router.local/router.lan/192.168.0.1';
 
                o = s.taboption('general', form.DynamicList, 'ipset',
                        _('IP sets'),
-                       _('List of IP sets to populate with the specified domain IPs.'));
+                       _('List of IP sets to populate with the IPs of DNS lookup results of the FQDNs also specified here.'));
                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.')
+                       + '<br />' + _('Note: you may also need a DHCP Proxy (currently unavailable) when specifying a non-standard Relay To port(<code>addr#port</code>).')
+                       + '<br />' + _('You may add multiple unique Relay To on the same Listen addr.'));
+
+               ss = o.subsection;
+
+               ss.addremove = true;
+               ss.anonymous = true;
+               ss.sortable  = true;
+               ss.rowcolors = true;
+               ss.nodescriptions = true;
+
+               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 < 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.rmempty = false;
+               so.optional = false;
+               so.placeholder = '192.168.10.1#535';
+
+               so.validate = function(section, value) {
+                       var m = this.section.formvalue(section, 'local_addr'),
+                           n = this.section.formvalue(section, 'server_addr'),
+                           p;
+
+                       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 ((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'),
@@ -361,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',
@@ -370,113 +613,151 @@ 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',
-                       _('Filter useless'),
-                       _('Avoid uselessly triggering dial-on-demand links (filters SRV/SOA records and names with underscores).') + '<br />' +
+               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.'));
 
-               s.taboption('advanced', form.Flag, 'localise_queries',
+               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('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('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 = 150;
+               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'),
@@ -503,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',
@@ -525,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',
@@ -546,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, … .')
@@ -563,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;
@@ -588,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.'));
 
@@ -603,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;
@@ -646,33 +963,58 @@ return view.extend({
                });
 
                o = s.taboption('ipsets', form.SectionValue, '__ipsets__', form.GridSection, 'ipset', null,
-                       _('List of IP sets to populate with the specified domain IPs.'));
+                       _('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 />' +
-                       _('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.'));
+                       _('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 />' +
+                       _('The tag construct filters which host directives are used; more than one tag can be provided, in this case the request must match all of them. Tagged directives are used in preference to untagged ones. Note that one of mac, duid or hostname still needs to be specified (can be a wildcard).'));
 
                ss = o.subsection;
 
                ss.addremove = true;
                ss.anonymous = true;
                ss.sortable = true;
+               ss.nodescriptions = true;
+               ss.max_cols = 8;
+               ss.modaltitle = _('Edit static lease');
 
-               so = ss.option(form.Value, 'name', _('Hostname'));
+               so = ss.option(form.Value, 'name', 
+                       _('Hostname'),
+                       _('Optional hostname to assign'));
                so.validate = validateHostname;
                so.rmempty  = true;
                so.write = function(section, value) {
@@ -684,62 +1026,35 @@ return view.extend({
                        uci.unset('dhcp', section, 'dns');
                };
 
-               so = ss.option(form.Value, 'mac', _('MAC address'));
-               so.datatype = 'list(macaddr)';
+               //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.') + '<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.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))
-                                       result.push('%02X:%02X:%02X:%02X:%02X:%02X'.format(
-                                               parseInt(RegExp.$1, 16), parseInt(RegExp.$2, 16),
-                                               parseInt(RegExp.$3, 16), parseInt(RegExp.$4, 16),
-                                               parseInt(RegExp.$5, 16), parseInt(RegExp.$6, 16)));
-
-                       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];
                        so.value(mac, hint ? '%s (%s)'.format(mac, hint) : mac);
                });
 
-               so = ss.option(form.Value, 'ip', _('IPv4 address'));
+               so = ss.option(form.Value, 'ip', _('IPv4 address'), _('The IP address to be used for this host, or <em>ignore</em> to ignore any DHCP request from this host.'));
+               so.value('ignore', _('Ignore'));
                so.datatype = 'or(ip4addr,"ignore")';
                so.validate = function(section, value) {
                        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');
@@ -762,16 +1077,61 @@ return view.extend({
                        so.value(ipv4, ipaddrs[ipv4] ? '%s (%s)'.format(ipv4, ipaddrs[ipv4]) : ipv4);
                });
 
-               so = ss.option(form.Value, 'leasetime', _('Lease time'));
+               so = ss.option(form.Value, 'leasetime', 
+                       _('Lease time'),
+                       _('Host-specific lease time, e.g. <code>5m</code>, <code>3h</code>, <code>7d</code>.'));
                so.rmempty = true;
-
-               so = ss.option(form.Value, 'duid', _('DUID'));
+               so.value('5m', _('5m (5 minutes)'));
+               so.value('3h', _('3h (3 hours)'));
+               so.value('12h', _('12h (12 hours - default)'));
+               so.value('7d', _('7d (7 days)'));
+               so.value('infinite', _('infinite (lease does not expire)'));
+
+               so = ss.option(form.Value, 'duid',
+                       _('DUID'),
+                       _('The DHCPv6-DUID (DHCP unique identifier) of this host.'));
                so.datatype = 'and(rangelength(20,36),hexstring)';
                Object.keys(duids).forEach(function(duid) {
                        so.value(duid, '%s (%s)'.format(duid, duids[duid].hostname || duids[duid].macaddr || duids[duid].ip6addr || '?'));
                });
 
-               so = ss.option(form.Value, 'hostid', _('IPv6 suffix (hex)'));
+               so = ss.option(form.Value, 'hostid',
+                       _('IPv6-Suffix (hex)'),
+                       _('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'),
+                       _('Assign new, freeform tags to this entry.'));
+
+               so = ss.option(form.DynamicList, 'match_tag',
+                       _('Match Tag'),
+                       _('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)'));
+               so.optional = true;
+
+               so = ss.option(form.Value, 'instance',
+                       _('Instance'),
+                       _('Dnsmasq instance to which this DHCP host section is bound. If unspecified, the section is valid for all dnsmasq instances.'));
+               so.optional = true;
+
+               Object.values(L.uci.sections('dhcp', 'dnsmasq')).forEach(function(val, index) {
+                       var name, display_str = generateDnsmasqInstanceEntry(val);
+                       so.value(index, display_str);
+               });
+
+
+               so = ss.option(form.Flag, 'broadcast',
+                       _('Broadcast'),
+                       _('Force broadcast DHCP response.'));
+
+               so = ss.option(form.Flag, 'dns',
+                       _('Forward/reverse DNS'),
+                       _('Add static forward and reverse DNS entries for this host.'));
 
                o = s.taboption('leases', CBILeaseStatus, '__status__');
 
@@ -838,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
                                                                ];