import { promises as dns } from 'node:dns'; /** * What DNS can tell us about a domain, and what it cannot. * * Two questions, asked by the check button before a domain is saved and again whenever it is refreshed: * **who answers for this name**, and **where does it point**. Both are cheap, both are observations rather * than settings, and neither is stored as authority — see `proxyManagerDomains`. */ /** Never let a slow or missing resolver hold a request open. */ const TIMEOUT_MS = 5_000; const withTimeout = async (work: Promise, fallback: T): Promise => { try { return await Promise.race([ work, new Promise((resolve) => setTimeout(() => resolve(fallback), TIMEOUT_MS)), ]); } catch { // NXDOMAIN, SERVFAIL, no records of that type — all ordinary answers to "does this exist", and none // of them is an error worth propagating. The caller distinguishes them by what came back empty. return fallback; } }; /** * Nameserver suffix → the provider that runs it. * * **This identifies the DNS HOST, which is the one that matters here**: it is whose API can place an * `_acme-challenge` record. It is frequently NOT the registrar — a domain bought at GoDaddy and delegated * to Cloudflare answers `cloudflare` — and the UI says "DNS provider" for that reason. * * Longest suffix wins, so `ns-cloud-a1.googledomains.com` cannot be caught by a shorter entry first. * An unknown nameserver is reported as `null` with the raw records kept: "we do not recognise this" is a * better answer than a wrong guess, and the owner can still read the nameservers themselves. */ const NS_PROVIDERS: { suffix: string; provider: string }[] = [ { suffix: 'cloudflare.com', provider: 'cloudflare' }, { suffix: 'domaincontrol.com', provider: 'godaddy' }, { suffix: 'godaddy.com', provider: 'godaddy' }, { suffix: 'awsdns-00.org', provider: 'route53' }, { suffix: 'awsdns.com', provider: 'route53' }, { suffix: 'awsdns.net', provider: 'route53' }, { suffix: 'awsdns.org', provider: 'route53' }, { suffix: 'awsdns.co.uk', provider: 'route53' }, { suffix: 'digitalocean.com', provider: 'digitalocean' }, { suffix: 'registrar-servers.com', provider: 'namecheap' }, { suffix: 'googledomains.com', provider: 'google' }, { suffix: 'google.com', provider: 'google' }, { suffix: 'azure-dns.com', provider: 'azure' }, { suffix: 'azure-dns.net', provider: 'azure' }, { suffix: 'azure-dns.org', provider: 'azure' }, { suffix: 'linode.com', provider: 'linode' }, { suffix: 'vultr.com', provider: 'vultr' }, { suffix: 'hetzner.com', provider: 'hetzner' }, { suffix: 'hetzner.de', provider: 'hetzner' }, { suffix: 'ovh.net', provider: 'ovh' }, { suffix: 'gandi.net', provider: 'gandi' }, { suffix: 'porkbun.com', provider: 'porkbun' }, { suffix: 'dnsimple.com', provider: 'dnsimple' }, { suffix: 'desec.io', provider: 'desec' }, { suffix: 'njal.la', provider: 'njalla' }, { suffix: 'name.com', provider: 'namedotcom' }, { suffix: 'dnsmadeeasy.com', provider: 'dnsmadeeasy' }, { suffix: 'nsone.net', provider: 'ns1' }, { suffix: 'hover.com', provider: 'hover' }, { suffix: 'dreamhost.com', provider: 'dreamhost' }, { suffix: 'bunny.net', provider: 'bunny' }, ]; /** Providers that commonly sit IN FRONT of an origin, so an address that is not ours may still be right. */ const PROXYING_PROVIDERS = new Set(['cloudflare', 'bunny']); export const providerFromNameservers = (nameservers: string[]): string | null => { const found = nameservers .map((ns) => ns.toLowerCase().replace(/\.$/, '')) .flatMap((ns) => NS_PROVIDERS.filter((entry) => ns.endsWith(entry.suffix))) .sort((a, b) => b.suffix.length - a.suffix.length); return found[0]?.provider ?? null; }; /** * Where a domain points, as a verdict rather than an address. * * `here` at least one address is this machine's public IP. * `private` it resolves inside a VPN range — correct for a tailnet-only name, and invisible to any * check that only compares against a public address. * `elsewhere` it resolves, and not to us. Which may be wrong, or may be a proxy — see `maybeProxied`. * `nowhere` it does not resolve at all. A new domain, or a typo. * `unknown` we could not establish our own public address, so "is that us" has no answer. Saying so is * the point: reporting `elsewhere` here would be a confident wrong answer. */ export type PointsTo = 'here' | 'private' | 'elsewhere' | 'nowhere' | 'unknown'; /** RFC1918, plus the CGNAT block Tailscale uses (100.64.0.0/10). */ const isPrivateIp = (ip: string): boolean => { const [a = 0, b = 0] = ip.split('.').map(Number); if (a === 10 || a === 127) return true; if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 100 && b >= 64 && b <= 127) return true; return false; }; /** * This machine's public address, cached. * * Asked of an outside service because there is no reliable local answer: the host may be behind NAT, and * the interface addresses are not what the internet sees. Cached for an hour — a residential address can * change, and re-asking on every check would put a third-party request behind a button. * * Failure is `null` and that is a real state, not an error. It means the "does it point here" question * cannot be answered, which the verdict reports as `unknown` rather than guessing. */ let publicIpCache: { ip: string | null; at: number } | null = null; const PUBLIC_IP_TTL_MS = 60 * 60 * 1000; export async function publicIp(): Promise { if (publicIpCache && Date.now() - publicIpCache.at < PUBLIC_IP_TTL_MS) return publicIpCache.ip; let ip: string | null = null; try { const response = await fetch('https://api.ipify.org', { signal: AbortSignal.timeout(TIMEOUT_MS) }); if (response.ok) { const text = (await response.text()).trim(); if (/^\d{1,3}(\.\d{1,3}){3}$/.test(text)) ip = text; } } catch { // Offline, blocked, or the service is down. All the same answer here. } publicIpCache = { ip, at: Date.now() }; return ip; } export type DomainCheck = { domain: string; nameservers: string[]; dnsProvider: string | null; resolvedIps: string[]; pointsTo: PointsTo; /** * `elsewhere`, but the DNS provider is one that proxies — so this is plausibly a working setup with the * provider in front, rather than a misconfiguration. Stated as a maybe because from a DNS lookup alone * it genuinely is one. */ maybeProxied: boolean; /** Our own public address at the time of the check, so the verdict can be read rather than trusted. */ machineIp: string | null; }; /** Lowercased, trailing dot and any scheme or path removed. `https://Foo.Dev/` → `foo.dev`. */ export const normaliseDomain = (input: string): string => input .trim() .toLowerCase() .replace(/^[a-z]+:\/\//, '') .replace(/\/.*$/, '') .replace(/\.$/, ''); /** Loose on purpose: this rejects obvious nonsense, and DNS itself is the real test. */ export const looksLikeDomain = (domain: string): boolean => /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(domain) && domain.length <= 253; /** * Nameservers for a name, falling back to its parent. * * **Only an apex has NS records.** `platform.officer.dev` answers nothing, which would report "provider not * recognised" for a name whose DNS is perfectly well known — and people paste subdomains, whatever the * list is nominally for. So walk up a label at a time until something answers. * * Bounded at three steps, which reaches the apex of anything real (`a.b.c.example.co.uk`) without turning * a typo into a queue of lookups. */ async function nameserversFor(domain: string): Promise { let name = domain; for (let step = 0; step < 4; step += 1) { const found = await withTimeout(dns.resolveNs(name), [] as string[]); if (found.length > 0) return found; const dot = name.indexOf('.'); // Two labels left means we are at the apex already; there is no parent worth asking. if (dot === -1 || name.split('.').length <= 2) return []; name = name.slice(dot + 1); } return []; } export async function checkDomain(input: string): Promise { const domain = normaliseDomain(input); // Asked together: they are independent, and a slow NS lookup should not delay the address one. const [nameservers, v4, v6, machineIp] = await Promise.all([ nameserversFor(domain), withTimeout(dns.resolve4(domain), [] as string[]), withTimeout(dns.resolve6(domain), [] as string[]), publicIp(), ]); const resolvedIps = [...v4, ...v6]; const dnsProvider = providerFromNameservers(nameservers); const pointsTo: PointsTo = (() => { if (resolvedIps.length === 0) return 'nowhere'; if (v4.some(isPrivateIp)) return 'private'; if (!machineIp) return 'unknown'; return resolvedIps.includes(machineIp) ? 'here' : 'elsewhere'; })(); return { domain, nameservers: nameservers.map((ns) => ns.toLowerCase().replace(/\.$/, '')), dnsProvider, resolvedIps, pointsTo, maybeProxied: pointsTo === 'elsewhere' && !!dnsProvider && PROXYING_PROVIDERS.has(dnsProvider), machineIp, }; }