luci-mod-network: Add HTTPS SVCB record en/decoding to DHCP

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 <newtwen+github@gmail.com>
(cherry picked from commit 9aeb34549f)
This commit is contained in:
Paul Donald
2025-06-23 02:43:17 +02:00
parent 94ce1f01ec
commit 25d10facab
2 changed files with 471 additions and 11 deletions

View File

@@ -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 `::`;
}
});

View 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 />' +