luci-mod-network: Add HTTPS SVCB record en/decoding to DHCP
authorPaul Donald <[email protected]>
Mon, 23 Jun 2025 00:43:17 +0000 (02:43 +0200)
committerPaul Donald <[email protected]>
Fri, 4 Jul 2025 12:45:33 +0000 (14:45 +0200)
This was a bit of code golf. Just for fun. Users seem to rely on HTTPS
record creation. The HTTPS record handling en/decodes compliant records
when tested against the RFC defined vectors, although there may exist
edge cases that are not compliant.

Escaped character encoding is not implemented (strict mode).

Any other arbitrary DNS record creation will be possible via the
addition of helper functions in this tools file.

Signed-off-by: Paul Donald <[email protected]>
(cherry picked from commit 9aeb34549f76f7cccd836a27012e2c61ae33805a)

modules/luci-mod-network/htdocs/luci-static/resources/tools/dnsrecordhandlers.js [new file with mode: 0644]
modules/luci-mod-network/htdocs/luci-static/resources/view/network/dhcp.js

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 (file)
index 0000000..6c5922a
--- /dev/null
@@ -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 `::`;
+       }
+});
index e815335c8219033d086652050e5a7b7d843390fc..63af8b42cc8b3fa1b0948ccec51c7862a8eba715 100644 (file)
@@ -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.') + '<br/>' + 
                        _('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<br/>'); // Inserts <br> 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.') + '<br />' +