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.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user