diff --git a/modules/luci-mod-network/htdocs/luci-static/resources/tools/dnsrecordhandlers.js b/modules/luci-mod-network/htdocs/luci-static/resources/tools/dnsrecordhandlers.js new file mode 100644 index 0000000000..6c5922a5a6 --- /dev/null +++ b/modules/luci-mod-network/htdocs/luci-static/resources/tools/dnsrecordhandlers.js @@ -0,0 +1,407 @@ +'use strict'; +'require baseclass'; + + +const svcParamKeyMap = { + /* RFC9460 §14.3.2 */ + mandatory: 0, + alpn: 1, + 'no-default-alpn': 2, + port: 3, + ipv4hint: 4, + ech: 5, + ipv6hint: 6 +}; + + +return baseclass.extend({ + + /* RFC9460 Test Vectors pass: + D1 Figure 2: AliasMode + D2 Figure 3: TargetName Is "." + D2 Figure 4: Specifies a Port + D2 Figure 5: A Generic Key and Unquoted Value + + D2 Figure 7: Two Quoted IPv6 Hints + D2 Figure 8: An IPv6 Hint Using the Embedded IPv4 Syntax + D2 Figure 9: SvcParamKey Ordering Is Arbitrary in Presentation Format but Sorted in Wire Format + + Failure cases (pass): + D3 Figure 11: Multiple Instances of the Same SvcParamKey + D3 Figure 12: Missing SvcParamValues That Must Be Non-Empty + D3 Figure 13: The "no-default-alpn" SvcParamKey Value Must Be Empty + D3 Figure 14: A Mandatory SvcParam Is Missing + D3 Figure 15: The "mandatory" SvcParamKey Must Not Be Included in the Mandatory List + D3 Figure 16: Multiple Instances of the Same SvcParamKey in the Mandatory List + + Encoding - Not implemented - escape sequence handling + D2 Figure 6: A Generic Key and Quoted Value with a Decimal Escape + D2 Figure 10: An "alpn" Value with an Escaped Comma and an Escaped Backslash in Two Presentation Formats + */ + + buildSvcbHex(priority, target, params) { + let buf = []; + + priority = isNaN(priority) ? 1 : priority; + + // Priority: 2 bytes + buf.push((priority >> 8) & 0xff, priority & 0xff); + + // TargetName in DNS wire format (labels with length prefixes) + if (target !== '.') { // D2 Figure 3 + if (target.endsWith('.')) target = target.slice(0, -1); + target.split('.').forEach(part => { + buf.push(part.length); + for (let i = 0; i < part.length; i++) + buf.push(part.charCodeAt(i)); + }); + } + buf.push(0); // end of name + + if (priority === 0) { + // AliasMode (priority 0) shall point to something; target '.' is ServiceMode + if (target === '.') return null; + + /* RFC 9461 §1.2: "SvcPriority (Section 2.4.1): The priority of this record + (relative to others, with lower values preferred). A value of 0 indicates AliasMode." + So return here - AliasMode needs only priority and target. */ + return buf.map(b => b.toString(16).padStart(2, '0')).join(''); + } + + // Collect all parameters as { keyNum, keyName, valueBytes } + const seenKeys = new Set(); + const paramList = []; + let mandatoryKeys = new Set(); + let definedAlpn = new Set(); + let noDefaultAlpn = false; + + params.forEach(line => { + if (!line.trim()) return; + + let [keyName, val = ''] = line.split('='); + keyName = keyName.trim().replace(/^"(.*)"$/, '$1'); + val = val.trim().replace(/^"(.*)"$/, '$1'); + + let keyNum = this.svcParamKeyToNumber(keyName); + if (keyNum == null) return null; // Stop on unknown keys + + // Stop on duplicate keys - D3 Figure 11 + if (seenKeys.has(keyName)) return null; + seenKeys.add(keyName); + + // Only 'no-default-alpn' key takes no values - D3 Figure 12 + if (keyNum !== 2 && val === '') + return null; + + // Stash 'mandatory' keys + if (keyNum === 0) mandatoryKeys = new Set(val.split(',').filter(n => n != '')); + + // Stash 'alpn' values + if (keyNum === 1) definedAlpn = new Set(val.split(',').filter(n => n != '')); + + // Encountered 'no-default-alpn' + if (keyNum === 2) noDefaultAlpn = true; + + let valueBytes = this.encodeSvcParamValue(keyName, val); + paramList.push({ keyNum, keyName, valueBytes }); + }); + + /* RFC9460 - §7.1.1 + When "no-default-alpn" is specified in an RR, "alpn" must also be + specified in order for the RR to be "self-consistent" (Section 2.4.3). */ + if (noDefaultAlpn && definedAlpn.size === 0) return null; + + // Ensure we got mandated keys - D3 Figure 14 + for (const key of mandatoryKeys) { + if (!seenKeys.has(key)) { + return null; + } + } + + // Sort by numeric key - D2 Figure 9 + paramList.sort((a, b) => a.keyNum - b.keyNum); + + // Write each key/value in wire format + for (const p of paramList) { + buf.push((p.keyNum >> 8) & 0xff, p.keyNum & 0xff); + buf.push((p.valueBytes.length >> 8) & 0xff, p.valueBytes.length & 0xff); + buf.push(...p.valueBytes); + } + + // Convert to hex string + return buf.map(b => b.toString(16).padStart(2, '0')).join(''); + }, + + svcParamKeyToNumber(name) { + name = name.toLowerCase(); + if (name in svcParamKeyMap) + return svcParamKeyMap[name]; + + const match = name.match(/^key(\d{1,5})$/); + if (match) { + const n = parseInt(match[1], 10); + if (n >= 0 && n <= 65535) return n; + } + return null; + }, + + encodeSvcParamValue(key, value) { + switch (key) { + case 'mandatory': + const seen = new Set(); + const keys = value.split(',') + .map(k => k.trim()) + .filter(k => { + if (seen.has(k)) return false; // D3 Figure 16 + seen.add(k); + return true; + }) + .map(k => this.svcParamKeyToNumber(k)) + .filter(n => n != null) + .filter(n => n != 0) // D3 Figure 15 + .sort((a, b) => a - b); // Ascending order - D2 Figure 9 + return keys.map(n => [(n >> 8) & 0xff, n & 0xff]).flat(); + + case 'ech': // Assume ech is in base64 + case 'alpn': + /* (RFC 9460 §7.1.1 The wire-format value for "alpn" consists of + at least one alpn-id prefixed by its length as a single octet */ + return value.split(',').map(v => { + const len = v.length; + return [len, ...[...v].map(c => c.charCodeAt(0))]; + }).flat(); + + case 'no-default-alpn': + return []; // zero-length value - D3 Figure 13 + + case 'port': // D2 Figure 4 + const port = parseInt(value, 10); + return [(port >> 8) & 0xff, port & 0xff]; + + case 'ipv4hint': + return value.split(',').map(ip => ip.trim().split('.').map(x => parseInt(x, 10))).flat(); + + // case 'ech': + // return value.match(/.{1,2}/g).map(b => parseInt(b, 16)); + + case 'ipv6hint': + return value.split(',').map(ip => { + ip = ip.trim(); + + // Check for IPv4-in-IPv6 (e.g. ::192.0.2.33) - D2 Figure 8 + let ipv4Tail = null; + if (ip.match(/\d+\.\d+\.\d+\.\d+$/)) { + const parts = ip.split(':'); + ipv4Tail = parts.pop(); // last part is IPv4 + ip = parts.join(':'); + + const octets = ipv4Tail.split('.').map(n => parseInt(n, 10)); + if (octets.length !== 4) return null; + + const word1 = ((octets[0] << 8) | octets[1]).toString(16).padStart(4, '0'); + const word2 = ((octets[2] << 8) | octets[3]).toString(16).padStart(4, '0'); + + ip += `:${word1}:${word2}`; + } + + // Split and expand abbreviated :: + let parts = ip.trim().split(':'); + // Expand shorthand :: into full 8-part address + if (parts.includes('')) { + const missing = 8 - parts.filter(p => p !== '').length; + const expanded = []; + for (let i = 0; i < parts.length; i++) { + if (parts[i] === '' && (i === 0 || parts[i - 1] !== '')) { + for (let j = 0; j < missing; j++) expanded.push('0000'); + } else if (parts[i] !== '') { + expanded.push(parts[i].padStart(4, '0')); + } + } + parts = expanded; + } else { + parts = parts.map(p => p.padStart(4, '0')); + } + return parts.map(p => [ + parseInt(p.slice(0, 2), 16), + parseInt(p.slice(2, 4), 16) + ]).flat(); + }).flat(); + + default: + // Support custom keyNNNN = value (RFC 9461 §8) + /* In wire format, the keys are represented by their numeric values + in network byte order, concatenated in strictly increasing numeric order. */ + if (/^key\d{1,5}$/i.test(key)) { + return value.split(',').map(v => { + // interpret as ASCII text — one value or comma-separated + return [...v].map(c => c.charCodeAt(0)); + }).flat(); + } + return []; + } + }, + + parseSvcbHex(hex) { + if (!hex) return null; + + let data = hex.replace(/[\s:]/g, '').toLowerCase(); + let buf = new Uint8Array(data.match(/.{2}/g).map(b => parseInt(b, 16))); + let view = new DataView(buf.buffer); + + let offset = 0; + + // Parse priority + if (buf.length < 2) return null; + let priority = view.getUint16(offset); + offset += 2; + + // Parse target name (DNS wire format) + function parseName() { + let labels = []; + while (offset < buf.length) { + let len = buf[offset++]; + if (len === 0) break; + if (offset + len > buf.length) return null; + let label = String.fromCharCode(...buf.slice(offset, offset + len)); + labels.push(label); + offset += len; + } + return labels.join('.') + '.'; + } + let target = parseName(); + if (target === null) return null; + + let svcParams = []; + + // Parse svcParams + while (offset + 4 <= buf.length) { + let key = view.getUint16(offset); + let len = view.getUint16(offset + 2); + offset += 4; + + if (offset + len > buf.length) break; + + let valBuf = buf.slice(offset, offset + len); + offset += len; + + let keyname = this.svcParamKeyFromNumber(key); + + // Handle empty-value flag "no-default-alpn" + if (keyname === 'no-default-alpn' && valBuf.length === 0) { + svcParams.push(keyname); + } else { + let valstr = this.decodeSvcParamValue(keyname, valBuf); + svcParams.push(`${keyname}=${valstr}`); + } + } + + return { + priority, + target, + params: svcParams + }; + }, + + svcParamKeyFromNumber(num) { + for (const [key, val] of Object.entries(svcParamKeyMap)) { + if (val === num) return key; + } + return `key${num}`; + }, + + decodeSvcParamValue(key, buf) { + switch (key) { + case 'mandatory': + const keys = []; + for (let i = 0; i + 1 < buf.length; i += 2) { + const k = (buf[i] << 8) | buf[i + 1]; + keys.push(this.svcParamKeyFromNumber(k)); + } + return keys.join(','); + + case 'ech': + case 'alpn': { + let pos = 0, result = []; + while (pos < buf.length) { + let len = buf[pos++]; + if (pos + len > buf.length) break; + let s = String.fromCharCode(...buf.slice(pos, pos + len)); + result.push(s); + pos += len; + } + return result.join(','); + } + + case 'no-default-alpn': + return ''; // Flag only + + case 'port': + return (buf[0] << 8 | buf[1]).toString(); + + case 'ipv4hint': + return [...buf].reduce((acc, byte, i) => { + if (i % 4 === 0) acc.push([]); + acc[acc.length - 1].push(byte); + return acc; + }, []).map(ip => ip.join('.')).join(','); + + // case 'ech': + // return Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join(''); + + case 'ipv6hint': + const addrs = []; + for (let i = 0; i + 15 <= buf.length; i += 16) { + let addr = []; + for (let j = 0; j < 16; j += 2) { + const hi = buf[i + j]; + const lo = buf[i + j + 1]; + const word = ((hi << 8) | lo).toString(16).padStart(4, '0'); + addr.push(word); + } + addrs.push(this.compressIPv6(addr)); + } + return addrs.join(','); + + default: + // Decode keyNNNN=... as raw ASCII if it's a custom numeric key + if (/^key\d{1,5}$/i.test(key)) { + return String.fromCharCode(...buf); + } + return Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join(''); + } + }, + + compressIPv6(hextets) { + // hextets: Array of 8 strings like ['2001', '0db8', '0000', ..., '0001'] + + // Normalize to lowercase + strip leading zeros + const normalized = hextets.map(h => parseInt(h, 16).toString(16)); + + // Find the longest run of zeroes + let bestStart = -1, bestLen = 0; + for (let i = 0; i < normalized.length; ) { + if (normalized[i] !== '0') { + i++; + continue; + } + let start = i; + while (i < normalized.length && normalized[i] === '0') i++; + let len = i - start; + if (len > bestLen) { + bestStart = start; + bestLen = len; + } + } + + // If no run of two or more zeroes, no compression + if (bestLen < 2) return normalized.join(':'); + + // Compress + const head = normalized.slice(0, bestStart).join(':'); + const tail = normalized.slice(bestStart + bestLen).join(':'); + if (head && tail) return `${head}::${tail}`; + else if (head) return `${head}::`; + else if (tail) return `::${tail}`; + else return `::`; + } +}); diff --git a/modules/luci-mod-network/htdocs/luci-static/resources/view/network/dhcp.js b/modules/luci-mod-network/htdocs/luci-static/resources/view/network/dhcp.js index e815335c82..63af8b42cc 100644 --- a/modules/luci-mod-network/htdocs/luci-static/resources/view/network/dhcp.js +++ b/modules/luci-mod-network/htdocs/luci-static/resources/view/network/dhcp.js @@ -8,6 +8,7 @@ 'require network'; 'require validation'; 'require tools.widgets as widgets'; +'require tools.dnsrecordhandlers as drh'; var callHostHints, callDUIDHints, callDHCPLeases, CBILeaseStatus, CBILease6Status; @@ -1054,7 +1055,7 @@ return view.extend({ so.value(ipv4, '%s (%s)'.format(ipv4, ipaddrs[ipv4])); }); - o = dnss.taboption('dnsrr', form.SectionValue, '__dnsrr__', form.TableSection, 'dnsrr', null, + o = dnss.taboption('dnsrr', form.SectionValue, '__dnsrr__', form.GridSection, 'dnsrr', null, _('Set an arbitrary resource record (RR) type.') + '
' + _('Hexdata is automatically en/decoded on save and load')); @@ -1067,7 +1068,7 @@ return view.extend({ ss.nodescriptions = true; function hexdecodeload(section_id) { - let value = uci.get('dhcp', section_id, this.option) || ''; + let value = uci.get('dhcp', section_id, 'hexdata') || ''; // Remove any spaces or colons from the hex string - they're allowed value = value.replace(/[\s:]/g, ''); // Hex-decode the string before displaying @@ -1098,25 +1099,77 @@ return view.extend({ so.datatype = 'uinteger'; so.placeholder = '64'; - so = ss.option(form.Value, 'hexdata', _('Raw Data')); + so = ss.option(form.Value, '_hexdata', _('Raw Data')); so.rmempty = true; so.datatype = 'string'; so.placeholder = 'free-form string'; so.load = hexdecodeload; so.write = hexencodesave; + so.modalonly = true; + so.depends({ rrnumber: '65', '!reverse': true }); - so = ss.option(form.DummyValue, '_hexdata', _('Hex Data')); - so.width = '10%'; + so = ss.option(form.DummyValue, 'hexdata', _('Hex Data')); + so.width = '50%'; so.rawhtml = true; so.load = function(section_id) { let hexdata = uci.get('dhcp', section_id, 'hexdata') || ''; hexdata = hexdata.replace(/[:]/g, ''); - if (hexdata) { - return hexdata.replace(/(.{20})/g, '$1
'); // Inserts
after every 2 characters (hex pair) - } else { - return ''; - } - } + return hexdata.replace(/(.{2})/g, '$1 '); + }; + + function writetype65(section_id, value) { + let rrnum = uci.get('dhcp', section_id, 'rrnumber'); + if (rrnum !== '65') return; + + let priority = parseInt(this.section.formvalue(section_id, '_svc_priority'), 10); + let target = this.section.formvalue(section_id, '_svc_target') || '.'; + let params = value.trim().split('\n').map(l => l.trim()).filter(Boolean); + + const hex = drh.buildSvcbHex(priority, target, params); + uci.set('dhcp', section_id, 'hexdata', hex); + }; + + function loadtype65(section_id) { + let rrnum = uci.get('dhcp', section_id, 'rrnumber'); + if (rrnum !== '65') return null; + + let hexdata = uci.get('dhcp', section_id, 'hexdata'); + return drh.parseSvcbHex(hexdata); + }; + + // Type 65 builder fields (hidden unless rrnumber === 65) + so = ss.option(form.Value, '_svc_priority', _('Svc Priority')); + so.placeholder = 1; + so.datatype = 'and(uinteger,min(0),max(65535))' + so.modalonly = true; + so.depends({ rrnumber: '65' }); + so.write = writetype65; + so.load = function(section_id) { + const parsed = loadtype65(section_id); + return parsed?.priority?.toString() || ''; + }; + + so = ss.option(form.Value, '_svc_target', _('Svc Target')); + so.placeholder = 'svc.example.com.'; + so.dataype = 'hostname'; + so.modalonly = true; + so.depends({ rrnumber: '65' }); + so.write = writetype65; + so.load = function(section_id) { + const parsed = loadtype65(section_id); + return parsed?.target || ''; + }; + + so = ss.option(form.TextValue, '_svc_params', _('Svc Parameters')); + so.placeholder = 'alpn=h2,h3\nipv4hint=192.0.2.1,192.0.2.2\nipv6hint=2001:db8::1,2001:db8::2\nport=8000'; + so.modalonly = true; + so.rows = 4; + so.depends({ rrnumber: '65' }); + so.write = writetype65; + so.load = function(section_id) { + const parsed = loadtype65(section_id); + return parsed?.params?.join('\n') || ''; + }; 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.') + '
' +