Skip to content

Commit 17deaf8

Browse files
committed
fix(dns): infer provider from authoritative nameservers
1 parent 82f9fbf commit 17deaf8

2 files changed

Lines changed: 64 additions & 3 deletions

File tree

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1816,6 +1816,30 @@ export function configDnsDomains(sites: Record<string, any>): string[] {
18161816
return [...domains]
18171817
}
18181818

1819+
/**
1820+
* Infer a DNS provider from a zone's authoritative nameservers.
1821+
*
1822+
* Provider API probes are intentionally the primary detection mechanism, but
1823+
* some registrars disable record API access per-domain. In that state the
1824+
* provider still owns the zone and should receive the attempted write so the
1825+
* deploy reports the real authorization error instead of incorrectly calling
1826+
* the zone externally managed.
1827+
*/
1828+
export function dnsProviderNameFromNameservers(nameservers: string[]): 'porkbun' | 'cloudflare' | 'route53' | 'godaddy' | null {
1829+
const normalized = nameservers.map(name => name.toLowerCase().replace(/\.$/, ''))
1830+
1831+
if (normalized.some(name => name.endsWith('.porkbun.com')))
1832+
return 'porkbun'
1833+
if (normalized.some(name => name.endsWith('.ns.cloudflare.com')))
1834+
return 'cloudflare'
1835+
if (normalized.some(name => /(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))
1836+
return 'route53'
1837+
if (normalized.some(name => name.endsWith('.domaincontrol.com')))
1838+
return 'godaddy'
1839+
1840+
return null
1841+
}
1842+
18191843
async function reconcileConfigDns(sites: Record<string, any>, logger: typeof log): Promise<void> {
18201844
const projectDnsConfig = await loadProjectDnsConfig(dnsConfig)
18211845
const declared = (['a', 'aaaa', 'cname', 'mx', 'txt'] as const)
@@ -1886,6 +1910,8 @@ async function reconcileHetznerDns(sites: Record<string, any>, ip: string, logge
18861910
providerConfigs.push({ provider: 'porkbun', apiKey: process.env.PORKBUN_API_KEY, secretKey: process.env.PORKBUN_SECRET_KEY })
18871911
if (process.env.CLOUDFLARE_API_TOKEN)
18881912
providerConfigs.push({ provider: 'cloudflare', apiToken: process.env.CLOUDFLARE_API_TOKEN })
1913+
if (process.env.GODADDY_API_KEY && process.env.GODADDY_API_SECRET)
1914+
providerConfigs.push({ provider: 'godaddy', apiKey: process.env.GODADDY_API_KEY, apiSecret: process.env.GODADDY_API_SECRET, environment: process.env.GODADDY_ENVIRONMENT })
18891915
if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_PROFILE)
18901916
providerConfigs.push({ provider: 'route53' })
18911917

@@ -1896,7 +1922,7 @@ async function reconcileHetznerDns(sites: Record<string, any>, ip: string, logge
18961922
return
18971923
}
18981924

1899-
const { detectDnsProvider } = await import('@stacksjs/ts-cloud') as any
1925+
const { createDnsProvider, detectDnsProvider } = await import('@stacksjs/ts-cloud') as any
19001926
logger.info('Reconciling DNS records...')
19011927

19021928
// Best-effort A-record lookup so externally managed domains that already
@@ -1911,9 +1937,25 @@ async function reconcileHetznerDns(sites: Record<string, any>, ip: string, logge
19111937
}
19121938
}
19131939

1940+
const resolveAuthoritativeNameservers = async (domain: string): Promise<string[]> => {
1941+
try {
1942+
const { resolveNs } = await import('node:dns/promises')
1943+
return await resolveNs(domain)
1944+
}
1945+
catch {
1946+
return []
1947+
}
1948+
}
1949+
19141950
for (const domain of domains) {
19151951
try {
1916-
const provider = await detectDnsProvider(domain, providerConfigs)
1952+
let provider = await detectDnsProvider(domain, providerConfigs)
1953+
if (!provider) {
1954+
const providerName = dnsProviderNameFromNameservers(await resolveAuthoritativeNameservers(domain))
1955+
const providerConfig = providerConfigs.find(config => config.provider === providerName)
1956+
if (providerConfig)
1957+
provider = createDnsProvider(providerConfig)
1958+
}
19171959
if (!provider) {
19181960
// No configured provider owns this zone — the records may still be
19191961
// correct (managed at the registrar). Only warn when they aren't.

storage/framework/core/buddy/tests/deploy-dns-domains.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'bun:test'
2-
import { configDnsDomains } from '../src/commands/deploy'
2+
import { configDnsDomains, dnsProviderNameFromNameservers } from '../src/commands/deploy'
33

44
describe('configDnsDomains', () => {
55
it('keeps application zones and normalizes www aliases', () => {
@@ -18,3 +18,22 @@ describe('configDnsDomains', () => {
1818
})).toEqual(['example.com'])
1919
})
2020
})
21+
22+
describe('dnsProviderNameFromNameservers', () => {
23+
it('recognizes Porkbun authoritative nameservers', () => {
24+
expect(dnsProviderNameFromNameservers([
25+
'maceio.ns.porkbun.com.',
26+
'salvador.ns.porkbun.com.',
27+
])).toBe('porkbun')
28+
})
29+
30+
it('recognizes other supported DNS providers', () => {
31+
expect(dnsProviderNameFromNameservers(['ada.ns.cloudflare.com.'])).toBe('cloudflare')
32+
expect(dnsProviderNameFromNameservers(['ns-123.awsdns-45.org.'])).toBe('route53')
33+
expect(dnsProviderNameFromNameservers(['ns01.domaincontrol.com.'])).toBe('godaddy')
34+
})
35+
36+
it('does not guess for an unknown nameserver network', () => {
37+
expect(dnsProviderNameFromNameservers(['ns1.example.net.'])).toBeNull()
38+
})
39+
})

0 commit comments

Comments
 (0)