Files
pastilhasandClaude Opus 5 07cf3317f8 domains: what you own, who runs its DNS, and the keys
one section, not two. a provider serves many domains so the lists differ, but a
person thinks in DOMAINS — the provider is something the check discovers, and a
key only matters as the answer to "can officer do anything for THIS domain".
two tabs would make somebody hold that join in their head, and the providers
tab would be two rows most of the time. so the key is entered from the row that
needs one and stored against its provider: add it for one cloudflare domain and
every other cloudflare domain turns green at once.

THE CHECK runs before you commit, because its answer is usually the reason you
would or would not add it, and a list of unchecked rows is a list somebody has
to remember to revisit.

it reports five verdicts, not two, and the extra three are the interesting
ones. `private` — atelier.officer.dev resolves to 100.64.x, which is CORRECT
and reads as broken against any public-IP comparison. `unknown` — we could not
establish our own address, so "is that us" has no answer and saying so beats a
confident wrong one. `nowhere` — a name bought ten minutes ago. plus
maybeProxied, because a cloudflare domain resolving to cloudflare is a working
setup rather than a misconfiguration.

NS gives the DNS HOST, not the registrar. officer.dev is godaddy on both counts
here, but a domain bought at godaddy and delegated to cloudflare answers
cloudflare — and that is the one that matters, since its API is what places an
ACME challenge. the UI says so where it would otherwise be read as wrong.

only an apex has NS records, so the lookup walks up to find them. verified
live: platform.officer.dev now answers godaddy instead of nothing.

CREDENTIALS are keyed on the provider, encrypted under this plugin's purpose
key, never returned by any route. scope is narrow|broad and NOT read|write —
DNS-01 works by WRITING a TXT record, so no read-only key can ever issue a
certificate, and offering one would produce a setup that looks configured and
fails at the first renewal sixty days later. the dialog says that out loud.

the form is generated from NPM's own dns-providers template, so the field names
are the provider's and it stays right for providers we have never heard of.
the routes live under /_config, which the manifest already marks ownerOnly: a
DNS key can rewrite every record for a domain, which is strictly more than the
NPM login kept there, and declaring it once beats checking it twice.

tables verified by query rather than by db:push's exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 19:27:06 +00:00

220 lines
9.1 KiB
TypeScript

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 <T>(work: Promise<T>, fallback: T): Promise<T> => {
try {
return await Promise.race([
work,
new Promise<T>((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<string | null> {
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<string[]> {
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<DomainCheck> {
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,
};
}