Skip to content

Commit 6bbf2ae

Browse files
chrisbbreuerclaude
andcommitted
fix(deploy): find the mail record being replaced, not just apex ones
The previous commit replaced records selected from `listRecords(domain, type)`. That is not a portable server-side filter: Porkbun implements it as `retrieveByNameType` scoped to the zone apex, so a TXT listing returned the apex SPF and omitted every subdomain record. `_dmarc` and `<selector>._domainkey` read as absent, the replacement found nothing to remove, and it created a second record beside the existing one. Caught in production on theopentimes.org: the deploy meant to relax DMARC from quarantine to none published `p=none` alongside the old `p=quarantine`. Two DMARC records is not a redundant policy — RFC 7489 has receivers ignore the policy entirely — so a change intended to soften enforcement removed DMARC outright, while the zone looked more configured than before. The stale record has been removed and the domain is back to a single p=none. Selection now happens over a full zone listing, in `selectRecordsAt`, with `zoneFqdn` normalizing the several ways providers spell a name (relative, absolute, `@`, empty, mixed case) so the two sides of the comparison cannot silently fail to match. Both are pure and covered. A failed delete also no longer proceeds to the create. Leaving the zone untouched is always better than half-replacing it, and an unchecked delete is precisely what turns one bad response into a duplicate record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e44cb6e commit 6bbf2ae

2 files changed

Lines changed: 117 additions & 16 deletions

File tree

storage/framework/core/buddy/src/commands/deploy.ts

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2631,6 +2631,35 @@ export function resolveDmarcPolicy(policy: unknown): 'none' | 'quarantine' | 're
26312631
return policy === 'none' || policy === 'quarantine' || policy === 'reject' ? policy : 'quarantine'
26322632
}
26332633

2634+
/**
2635+
* Normalize a provider's record name to a lowercase FQDN.
2636+
*
2637+
* Providers disagree: some return `_dmarc`, some `_dmarc.example.com.`, some
2638+
* `@` or `''` for the apex. Comparing raw names across them silently matches
2639+
* nothing, which reads as "no existing record" and duplicates it.
2640+
*/
2641+
export function zoneFqdn(name: unknown, zone: string): string {
2642+
const apex = zone.replace(/\.$/, '').toLowerCase()
2643+
const n = String(name ?? '').replace(/\.$/, '').toLowerCase()
2644+
if (!n || n === '@')
2645+
return apex
2646+
return n === apex || n.endsWith(`.${apex}`) ? n : `${n}.${apex}`
2647+
}
2648+
2649+
/**
2650+
* Pick every record at one name and type out of a FULL zone listing.
2651+
*
2652+
* Separated out because the failure it guards is invisible: a provider's
2653+
* `listRecords(domain, type)` is not a portable filter — Porkbun scopes it to
2654+
* the apex — so selecting from a type-filtered listing finds no `_dmarc` or
2655+
* `<selector>._domainkey` record and the caller adds a duplicate rather than
2656+
* replacing. Selection must happen here, over everything the zone holds.
2657+
*/
2658+
export function selectRecordsAt<T extends { name?: unknown, type?: unknown }>(records: T[], fqdn: string, type: string, zone: string): T[] {
2659+
const target = zoneFqdn(fqdn, zone)
2660+
return records.filter(r => String(r.type).toUpperCase() === type.toUpperCase() && zoneFqdn(r.name, zone) === target)
2661+
}
2662+
26342663
/** The TXT content a provider returned, unquoted and trimmed for comparison. */
26352664
export function txtContent(record: { content?: unknown, value?: unknown }): string {
26362665
return String(record.content ?? record.value ?? '').replace(/^"|"$/g, '')
@@ -2731,22 +2760,32 @@ export async function reconcileMailDns(res: MailTenantResult, ip: string, logger
27312760
return byHand(`no configured DNS provider administers this zone`)
27322761

27332762
// Names are written as FQDNs; providers derive the zone root from `domain`
2734-
// and strip it back off. Records come back either way, so normalize before
2735-
// comparing.
2736-
const toFqdn = (name: string): string => {
2737-
const n = String(name || '').replace(/\.$/, '').toLowerCase()
2738-
if (!n || n === '@')
2739-
return domain.toLowerCase()
2740-
return n === domain.toLowerCase() || n.endsWith(`.${domain.toLowerCase()}`) ? n : `${n}.${domain.toLowerCase()}`
2741-
}
2763+
// and strip it back off. Records come back either way, so `zoneFqdn`
2764+
// normalizes both sides before anything is compared.
27422765
const apex = domain.toLowerCase()
27432766
const dkimFqdn = `${dkimName}.${domain}`
27442767
const dmarcFqdn = `_dmarc.${domain}`
27452768
const mailFqdn = `mail.${domain}`
27462769

2747-
const list = async (type: string): Promise<any[]> => {
2748-
const res = await provider.listRecords(domain, type)
2749-
return res?.success ? (res.records || []) : []
2770+
/**
2771+
* Every record at one name and type, read from a FULL zone listing.
2772+
*
2773+
* `listRecords(domain, type)` must not be used here. It is not a portable
2774+
* server-side filter: Porkbun implements it as `retrieveByNameType` scoped to
2775+
* the zone apex, so asking for TXT returns the apex TXT records and silently
2776+
* omits every subdomain one — `_dmarc` and `<selector>._domainkey` come back
2777+
* as "not present". A replacement that cannot see the existing record does
2778+
* not replace it, it adds a second one beside it. Two DMARC records is not a
2779+
* redundant policy: RFC 7489 has receivers ignore the policy entirely, so the
2780+
* domain silently loses DMARC while the zone looks more configured than
2781+
* before. Observed in production on the deploy that introduced this function.
2782+
*
2783+
* Re-read per call rather than cached, because the writes below change the
2784+
* answer and a stale listing reintroduces exactly the bug above.
2785+
*/
2786+
const recordsAt = async (fqdn: string, type: string): Promise<any[]> => {
2787+
const res = await provider.listRecords(domain)
2788+
return selectRecordsAt(res?.success ? (res.records || []) : [], fqdn, type, domain)
27502789
}
27512790

27522791
/**
@@ -2762,13 +2801,20 @@ export async function reconcileMailDns(res: MailTenantResult, ip: string, logger
27622801
* be replaced surgically.
27632802
*/
27642803
const replaceTxt = async (fqdn: string, content: string, owns: (existing: string) => boolean): Promise<void> => {
2765-
const existing = (await list('TXT')).filter(r => toFqdn(r.name) === fqdn.toLowerCase())
2804+
const existing = await recordsAt(fqdn, 'TXT')
27662805
const { remove, create } = planTxtReplacement(existing, content, owns)
27672806
if (!create)
27682807
return
27692808

2770-
for (const record of remove)
2771-
await provider.deleteRecord(domain, { ...record, name: fqdn, type: 'TXT' })
2809+
// A delete that fails must not be followed by a create: the old record
2810+
// stays, the new one lands beside it, and for `_dmarc` two records mean no
2811+
// policy at all (RFC 7489). Failing loudly leaves the zone as it was, which
2812+
// is always better than half-replacing it.
2813+
for (const record of remove) {
2814+
const removed = await provider.deleteRecord(domain, { ...record, name: fqdn, type: 'TXT' })
2815+
if (removed && removed.success === false)
2816+
throw new Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message || 'provider refused the delete'})`)
2817+
}
27722818

27732819
const created = await provider.createRecord(domain, { name: fqdn, type: 'TXT', content, ttl: 600 })
27742820
if (!created?.success)
@@ -2779,7 +2825,7 @@ export async function reconcileMailDns(res: MailTenantResult, ip: string, logger
27792825
// MX: this domain's mail is hosted on our box, so our host replaces the
27802826
// set. Anything else pointing elsewhere is named as it is removed — a
27812827
// silently dropped MX is how a domain stops receiving mail entirely.
2782-
const mxRecords = (await list('MX')).filter(r => toFqdn(r.name) === apex)
2828+
const mxRecords = await recordsAt(apex, 'MX')
27832829
const staleMx = mxRecords.filter(r => String(r.content ?? r.value ?? '').replace(/\.$/, '').toLowerCase() !== mailHost.toLowerCase())
27842830
for (const record of staleMx) {
27852831
logger.warn(` Mail DNS: replacing an existing MX for ${domain}${record.content ?? record.value}`)

storage/framework/core/buddy/tests/mail-dns-records.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
*/
2828

2929
import { describe, expect, it } from 'bun:test'
30-
import { dnsProviderConfigsFromEnv, planTxtReplacement, resolveDmarcPolicy, txtContent } from '../src/commands/deploy'
30+
import { dnsProviderConfigsFromEnv, planTxtReplacement, resolveDmarcPolicy, selectRecordsAt, txtContent, zoneFqdn } from '../src/commands/deploy'
3131

3232
/**
3333
* Mirrors the publisher's selector derivation. `mail` is the fallback only for
@@ -57,6 +57,61 @@ describe('DKIM record name', () => {
5757
})
5858
})
5959

60+
describe('finding the existing record to replace', () => {
61+
/**
62+
* The bug this covers reached production. `listRecords(domain, type)` is not
63+
* a portable server-side filter — Porkbun scopes it to the zone apex — so a
64+
* TXT listing came back holding the apex SPF and nothing else. `_dmarc` and
65+
* `mail._domainkey` looked absent, the replacement found nothing to remove,
66+
* and it created a SECOND `_dmarc` record beside the old one. Under RFC 7489
67+
* a domain with two DMARC records has no usable policy at all, so the deploy
68+
* that was meant to relax quarantine to none removed DMARC entirely.
69+
*
70+
* Selection therefore happens here, over a full zone listing.
71+
*/
72+
const zone = 'theopentimes.org'
73+
const fullZone = [
74+
{ type: 'TXT', name: 'theopentimes.org', content: 'v=spf1 ip4:178.105.248.188 ~all' },
75+
{ type: 'TXT', name: 'theopentimes.org', content: 'google-site-verification=abc' },
76+
{ type: 'TXT', name: '_dmarc.theopentimes.org', content: 'v=DMARC1; p=quarantine; rua=mailto:no-reply@theopentimes.org' },
77+
{ type: 'TXT', name: 'mail._domainkey.theopentimes.org', content: 'v=DKIM1; k=rsa; p=AAAA' },
78+
{ type: 'MX', name: 'theopentimes.org', content: 'mail.theopentimes.org' },
79+
{ type: 'A', name: 'mail.theopentimes.org', content: '178.105.248.188' },
80+
]
81+
82+
it('finds a subdomain TXT record, not just apex ones', () => {
83+
const found = selectRecordsAt(fullZone, `_dmarc.${zone}`, 'TXT', zone)
84+
85+
expect(found).toHaveLength(1)
86+
expect(found[0]!.content).toContain('p=quarantine')
87+
})
88+
89+
it('finds the DKIM record under its selector', () => {
90+
expect(selectRecordsAt(fullZone, `mail._domainkey.${zone}`, 'TXT', zone)).toHaveLength(1)
91+
})
92+
93+
it('separates the two apex TXT records from every subdomain one', () => {
94+
expect(selectRecordsAt(fullZone, zone, 'TXT', zone)).toHaveLength(2)
95+
})
96+
97+
it('does not confuse types at the same name', () => {
98+
expect(selectRecordsAt(fullZone, zone, 'MX', zone)).toHaveLength(1)
99+
expect(selectRecordsAt(fullZone, `mail.${zone}`, 'A', zone)).toHaveLength(1)
100+
})
101+
102+
it('normalizes however a provider spells a name', () => {
103+
// Relative, absolute, apex-as-@ and apex-as-empty all name the same thing.
104+
expect(zoneFqdn('_dmarc', zone)).toBe(`_dmarc.${zone}`)
105+
expect(zoneFqdn('_dmarc.theopentimes.org.', zone)).toBe(`_dmarc.${zone}`)
106+
expect(zoneFqdn('@', zone)).toBe(zone)
107+
expect(zoneFqdn('', zone)).toBe(zone)
108+
expect(zoneFqdn('_DMARC.TheOpenTimes.ORG', zone)).toBe(`_dmarc.${zone}`)
109+
110+
const relative = [{ type: 'TXT', name: '_dmarc', content: 'v=DMARC1; p=none' }]
111+
expect(selectRecordsAt(relative, `_dmarc.${zone}`, 'TXT', zone)).toHaveLength(1)
112+
})
113+
})
114+
60115
describe('replacing a TXT record in a shared name', () => {
61116
const isSpf = (content: string): boolean => content.toLowerCase().startsWith('v=spf1')
62117
const spf = 'v=spf1 ip4:178.105.248.188 ~all'

0 commit comments

Comments
 (0)