Files
inbuxa-admin/src/features/dns/detect.ts
T
jcoffey-dev 5641560a91 Guided wizards, opt-in every time, and automatic DNS as the first
A job that has a wizard now asks "Guide me / I'll do it myself" each time
it starts; nothing is remembered. The shared wizard shell gives every guide
a stepper, a side panel on what each step does and how to undo it, and the
way forward or back.

Automatic DNS, from a domain's DNS section:
- finds where the domain's DNS is hosted from its SOA and NS records, and
  offers that host when the server can drive it;
- for the major hosts, steps to create the narrowest credential, and the
  field named as the steps name it;
- records grouped by what they do, TLSA off unless the zone is signed;
- saves the provider and switches the domain over, removing the provider
  again if the switch fails;
- watches the publishing task and public DNS, ticking each record green,
  and boils a host's refusal down to its distinct messages;
- for hosts it can't drive, or domains not in DNS yet, every record laid
  out for copying, with the same live checks.
2026-09-19 01:41:00 -07:00

94 lines
3.7 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Where is a domain's DNS hosted? Found the way any resolver would: the SOA
* record names the zone that holds the domain (which may be a parent zone),
* and the zone's NS records name the host. The host is then matched against
* nameserver patterns we know for the providers the server can drive.
*/
import { dohQuery } from './liveCheck';
export interface DnsHosting {
/** The zone holding the domain, e.g. example.com for mail.example.com. */
zone: string;
nameservers: string[];
/** The server's provider type, when the host is one it can update. */
variant: string | null;
}
/**
* Nameserver suffixes by provider type. Only hosts whose nameservers are
* unambiguous are listed: a wrong guess costs the user more than no guess.
*/
const NAMESERVERS: [RegExp, string][] = [
[/\.ns\.cloudflare\.com$/, 'Cloudflare'],
[/\.awsdns-\d+\.(com|net|org|co\.uk)$/, 'Route53'],
[/^ns-cloud-[a-z]\d+\.googledomains\.com$/, 'GoogleCloudDns'],
[/\.azure-dns\.(com|net|org|info)$/, 'AzureDns'],
[/^ns\d\.digitalocean\.com$/, 'DigitalOcean'],
[/\.ns\.hetzner\.(com|de)$/, 'Hetzner'],
[/\.(ovh\.net|ovh\.ca|anycast\.me)$/, 'Ovh'],
[/\.domaincontrol\.com$/, 'Godaddy'],
[/\.porkbun\.com$/, 'Porkbun'],
[/^ns\d\.desec\.(io|org)$/, 'DeSEC'],
[/\.linode\.com$/, 'Linode'],
[/\.vultr\.com$/, 'Vultr'],
[/\.gandi\.net$|\.gandi-ns\.(fr|com|net)$/, 'GandiV5'],
[/\.registrar-servers\.com$/, 'Namecheap'],
[/\.dnsimple(-edge)?\.(com|net|org|info)$/, 'Dnsimple'],
[/\.bunny\.net$/, 'Bunny'],
[/\.nsone\.net$/, 'Ns1'],
[/\.ui-dns\.(com|de|org|biz)$/, 'Ionos'],
[/\.dnsmadeeasy\.com$/, 'DnsMadeEasy'],
[/\.cloudns\.net$/, 'ClouDns'],
[/^ns\d\.he\.net$/, 'Hurricane'],
[/\.vercel-dns\.com$/, 'Vercel'],
[/\.name\.com$/, 'NameDotCom'],
[/\.inwx\.(de|net|eu)$/, 'Inwx'],
[/\.transip\.(nl|net|eu)$/, 'Transip'],
[/\.scw\.cloud$/, 'Scaleway'],
[/\.infomaniak\.ch$/, 'Infomaniak'],
[/\.dns-parking\.com$/, 'Hostinger'],
[/\.akam\.net$/, 'EdgeDns'],
[/\.exoscale\.(ch|net|io|com)$/, 'Exoscale'],
[/\.netcup\.net$/, 'Netcup'],
[/\.joker\.com$/, 'Joker'],
[/\.glesys\.se$/, 'Glesys'],
[/\.dreamhost\.com$/, 'Dreamhost'],
[/\.easydns\.(com|net|org|info)$/, 'EasyDns'],
[/\.ultradns\.(com|net|org|biz|info|co\.uk)$/, 'UltraDns'],
[/\.mythic-beasts\.com$/, 'MythicBeasts'],
[/\.luadns\.net$/, 'LuaDns'],
[/\.spaceship\.net$/, 'Spaceship'],
[/\.hosting\.de$/, 'HostingDe'],
];
/** The provider type for a set of nameservers, if they all point to one we know. */
export function providerForNameservers(nameservers: string[]): string | null {
const hits = new Set<string>();
for (const ns of nameservers) {
const host = ns.toLowerCase().replace(/\.$/, '');
const match = NAMESERVERS.find(([re]) => re.test(host));
if (!match) return null;
hits.add(match[1]);
}
return hits.size === 1 ? [...hits][0] : null;
}
/** Find the zone and host for a domain; null when it isn't in public DNS yet. */
export async function detectHosting(domain: string, signal?: AbortSignal): Promise<DnsHosting | null> {
const soa = await dohQuery(domain, 'SOA', signal);
const zoneRecord = [...soa.answer, ...soa.authority].find((a) => a.type === 6);
const zone = zoneRecord?.name.replace(/\.$/, '').toLowerCase();
// NXDOMAIN answers carry the TLD's SOA: that's "not registered", not a zone.
if (!zone || !zone.includes('.') || soa.status === 3) return null;
const ns = await dohQuery(zone, 'NS', signal);
const nameservers = ns.answer.filter((a) => a.type === 2).map((a) => a.data.replace(/\.$/, '').toLowerCase());
if (nameservers.length === 0) return null;
return { zone, nameservers, variant: providerForNameservers(nameservers) };
}