Files
inbuxa-admin/src/features/dns/liveCheck.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

91 lines
3.3 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Is a record live in public DNS yet? Asked from the browser over DNS-over-HTTPS,
* so the answer is what the rest of the internet sees, not what the mail
* server believes it wrote. The resolver sees the names being checked, which
* are public DNS names anyway; the page says which resolver it uses.
*/
import { normalizeValue, type ZoneRecord } from './zone';
export const RESOLVER_NAME = 'Cloudflare public DNS (1.1.1.1)';
const RESOLVER = 'https://cloudflare-dns.com/dns-query';
/** Resource record type numbers, for reading the JSON answer. */
const TYPE_NUMBERS: Record<string, number> = {
A: 1,
CNAME: 5,
MX: 15,
TXT: 16,
AAAA: 28,
SRV: 33,
TLSA: 52,
CAA: 257,
};
export type LiveState = 'live' | 'different' | 'missing' | 'error';
export interface DohRecord {
name: string;
type: number;
data: string;
}
export interface DohResult {
/** 0 is an answer, 3 is NXDOMAIN (the name doesn't exist). */
status: number;
answer: DohRecord[];
authority: DohRecord[];
}
/** One DNS-over-HTTPS question, answered in the resolver's JSON form. */
export async function dohQuery(name: string, type: string, signal?: AbortSignal): Promise<DohResult> {
const url = `${RESOLVER}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`;
const res = await fetch(url, { headers: { Accept: 'application/dns-json' }, signal, cache: 'no-store' });
if (!res.ok) throw new Error(`resolver answered ${res.status}`);
const body = (await res.json()) as { Status: number; Answer?: DohRecord[]; Authority?: DohRecord[] };
return { status: body.Status, answer: body.Answer ?? [], authority: body.Authority ?? [] };
}
async function lookup(name: string, type: string, signal?: AbortSignal): Promise<string[]> {
const { status, answer } = await dohQuery(name, type, signal);
// NXDOMAIN (3) and "no data" both simply mean: not there yet.
if (status !== 0 && status !== 3) throw new Error(`resolver status ${status}`);
const want = TYPE_NUMBERS[type];
return answer.filter((a) => want === undefined || a.type === want).map((a) => a.data);
}
/**
* Check a batch of records. Records sharing a name and type are looked up
* once. A record is `live` when its exact value is published, `different`
* when that name has other values of the type (another provider's SPF, say),
* and `missing` when there is nothing.
*/
export async function checkRecords(records: ZoneRecord[], signal?: AbortSignal): Promise<Map<ZoneRecord, LiveState>> {
const groups = new Map<string, ZoneRecord[]>();
for (const r of records) {
const key = `${r.name.toLowerCase()}|${r.type}`;
groups.set(key, [...(groups.get(key) ?? []), r]);
}
const out = new Map<ZoneRecord, LiveState>();
await Promise.all(
[...groups.values()].map(async (group) => {
const { name, type } = group[0];
try {
const found = (await lookup(name, type, signal)).map((d) => normalizeValue(type, d));
for (const r of group) {
const want = normalizeValue(type, r.value);
out.set(r, found.includes(want) ? 'live' : found.length > 0 ? 'different' : 'missing');
}
} catch {
for (const r of group) out.set(r, 'error');
}
}),
);
return out;
}