DNS checks read a long TXT record the server splits across lines #64

Merged
jcoffey-dev merged 1 commits from fix/zone-split-txt into main 2026-09-29 17:24:22 +00:00
2 changed files with 74 additions and 1 deletions

No files matched your search

+21
View File
@@ -54,6 +54,27 @@ describe('parseZone', () => {
expect(records.find((r) => r.kind === 'dkim')?.value).toBe('"v=DKIM1; k=ed25519; p=abc"');
});
it('joins a long TXT record the server splits across lines, as BIND does', () => {
const zone = [
'example.com. IN MX 10 mail.example.com.',
'rsa._domainkey.example.com. IN TXT (',
' "v=DKIM1; k=rsa; p=MIIBIjAN"',
' "BgkqhkiG9w0BAQEFAAOC"',
')',
'_dmarc.example.com. IN TXT "v=DMARC1; p=reject"',
].join('\n');
const records = parseZone(zone);
const dkim = records.find((r) => r.kind === 'dkim');
expect(dkim?.value).toBe('"v=DKIM1; k=rsa; p=MIIBIjAN" "BgkqhkiG9w0BAQEFAAOC"');
expect(normalizeValue('TXT', dkim!.value)).toBe(normalizeValue('TXT', '"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOC"'));
expect(records.map((r) => r.kind)).toEqual(['mx', 'dkim', 'dmarc']);
});
it('keeps parentheses that are inside a quoted value', () => {
const [r] = parseZone('_dmarc.example.com. IN TXT "v=DMARC1; p=none; (test)"');
expect(r.value).toBe('"v=DMARC1; p=none; (test)"');
});
it('copes with nothing', () => {
expect(parseZone(undefined)).toEqual([]);
expect(parseZone('')).toEqual([]);
+53 -1
View File
@@ -67,10 +67,62 @@ function kindOf(name: string, type: string, value: string): RecordKind | null {
return null;
}
/** The text with the quoted strings blanked, so parentheses inside them don't count. */
function unquoted(line: string): string {
return line.replace(/"(?:[^"\\]|\\.)*"/g, '""');
}
/**
* Join the lines a record spans. The server writes a TXT value longer than
* 255 bytes the way BIND does, as quoted chunks inside parentheses on lines
* of their own:
*
* sel._domainkey.example.com. IN TXT (
* "v=DKIM1; k=rsa; p=MIIB…"
* "…IDAQAB"
* )
*
* Read a line at a time, the record was "(" and the DKIM key never matched
* what DNS returns, so every domain with an RSA key showed as missing it.
*/
function logicalLines(text: string): string[] {
const out: string[] = [];
let pending: string | null = null;
for (const raw of text.split('\n')) {
const line = raw.trim();
if (pending !== null) {
pending += ` ${line}`;
if (unquoted(line).includes(')')) {
out.push(pending);
pending = null;
}
continue;
}
const bare = unquoted(line);
if (bare.includes('(') && !bare.includes(')')) pending = line;
else out.push(line);
}
if (pending !== null) out.push(pending);
// The parentheses only group lines; the record doesn't contain them.
return out.map((l) => {
const bare = unquoted(l);
if (!bare.includes('(')) return l;
let result = '';
let inQuote = false;
for (let i = 0; i < l.length; i++) {
const c = l[i];
if (c === '"' && l[i - 1] !== '\\') inQuote = !inQuote;
if (!inQuote && (c === '(' || c === ')')) continue;
result += c;
}
return result.replace(/\s+/g, ' ').trim();
});
}
/** Parse the zone text. Lines it can't place are left out, not guessed at. */
export function parseZone(text: string | null | undefined): ZoneRecord[] {
const out: ZoneRecord[] = [];
for (const raw of (text ?? '').split('\n')) {
for (const raw of logicalLines(text ?? '')) {
const line = raw.trim();
if (!line || line.startsWith(';')) continue;
const f = fields(line);