diff --git a/src/features/dns/zone.test.ts b/src/features/dns/zone.test.ts index f0f8f5e..977d0ca 100644 --- a/src/features/dns/zone.test.ts +++ b/src/features/dns/zone.test.ts @@ -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([]); diff --git a/src/features/dns/zone.ts b/src/features/dns/zone.ts index 1a5e9d4..ebb03e0 100644 --- a/src/features/dns/zone.ts +++ b/src/features/dns/zone.ts @@ -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);