From ab92a48b5e0e2703a79ece57804d16f6168dac47 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 30 Jun 2026 10:25:16 +0100 Subject: [PATCH 1/3] feat(dns): live delegation checks, registrar-aware setup, and record presets --- .changeset/spicy-otters-share.md | 5 + AGENTS.md | 19 +- packages/cli/src/commands/dns/record/add.ts | 20 ++ packages/cli/src/commands/dns/record/index.ts | 2 + .../cli/src/commands/dns/record/preset.ts | 201 ++++++++++++ .../src/commands/dns/record/presets.test.ts | 85 +++++ .../cli/src/commands/dns/record/presets.ts | 292 ++++++++++++++++++ packages/cli/src/commands/dns/zone/add.ts | 39 +++ packages/cli/src/commands/dns/zone/list.ts | 40 ++- .../cli/src/commands/dns/zone/nameservers.ts | 69 ++--- packages/cli/src/core/dns-nameservers.ts | 77 +++++ packages/cli/src/core/format.ts | 6 +- .../cli/src/core/hostnames/bunny-dns.test.ts | 66 ++-- packages/cli/src/core/hostnames/bunny-dns.ts | 9 +- packages/cli/src/core/registrar.test.ts | 46 +++ packages/cli/src/core/registrar.ts | 48 +++ 16 files changed, 948 insertions(+), 76 deletions(-) create mode 100644 .changeset/spicy-otters-share.md create mode 100644 packages/cli/src/commands/dns/record/preset.ts create mode 100644 packages/cli/src/commands/dns/record/presets.test.ts create mode 100644 packages/cli/src/commands/dns/record/presets.ts create mode 100644 packages/cli/src/core/dns-nameservers.ts create mode 100644 packages/cli/src/core/registrar.test.ts create mode 100644 packages/cli/src/core/registrar.ts diff --git a/.changeset/spicy-otters-share.md b/.changeset/spicy-otters-share.md new file mode 100644 index 0000000..069b0e3 --- /dev/null +++ b/.changeset/spicy-otters-share.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +feat(dns): verify registrar delegation with a live nameserver lookup (instead of the API's NameserversDetected flag, which defaults to true on a fresh zone) across `dns zones ns`, `dns zones list`, and pull-zone setup; `dns zones add` and `ns` now give registrar-aware setup steps (registrar named via RDAP) and skip them when the domain is already delegated; add `dns records preset` with email, verification, and security presets (Google Workspace, Microsoft 365, Zoho, Mailgun, Resend, Proton, Bluesky, DMARC, CAA, no-email) plus a preset option in the `records add` wizard; color table heads bunny orange diff --git a/AGENTS.md b/AGENTS.md index e2a4d05..ac2d511 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,7 @@ bunny-cli/ │ │ ├── client-options.ts # clientOptions() helper — builds ClientOptions from ResolvedConfig │ │ ├── define-command.ts # Command factory (see "Command Pattern" below) │ │ ├── define-namespace.ts # Namespace/group factory for subcommand trees +│ │ ├── dns-nameservers.ts # BUNNY_NAMESERVERS + expectedNameservers(zone) + checkDelegation()/checkDelegations(): live NS lookup of a domain's registrar delegation (ground truth, not bunny's NameserversDetected flag which defaults true on a fresh zone); checkDelegations is bounded-concurrency for the zone list │ │ ├── dns-record-types.ts # Canonical DNS record-type name⇄integer map (RECORD_TYPES) + recordTypeLabel(); shared by commands/dns + core/hostnames │ │ ├── errors.ts # Re-exports UserError/ApiError from @bunny.net/openapi-client + ConfigError │ │ ├── format.ts # Shared table/key-value rendering (text, table, csv, markdown) @@ -177,6 +178,8 @@ bunny-cli/ │ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver │ │ ├── logger.ts # Chalk-based structured logger │ │ ├── manifest.ts # .bunny/ context file resolution (load, save, resolveManifestId) +│ │ ├── registrar.ts # detectRegistrar()/parseRegistrar(): best-effort registrar name via RDAP (used by dns zones add to name the registrar in next-steps) +│ │ ├── registrar.test.ts # Tests for parseRegistrar RDAP parsing + legal-suffix tidying │ │ ├── stats.ts # Shared stats rendering: sumChart(), renderBarChart(), formatBucketLabel() (UTC date labels), BAR_WIDTH (used by dns/zone/stats + scripts/stats) │ │ ├── stats.test.ts # Tests for stats helpers │ │ ├── types.ts # GlobalArgs, OutputFormat, and shared type definitions @@ -287,21 +290,24 @@ bunny-cli/ │ │ │ ├── record/ # `dns records` — entries within a zone (canonical: records; aliases: record, rec) │ │ │ │ ├── index.ts # defineNamespace("records", ...) │ │ │ │ ├── list.ts # List records in a zone (alias: ls) -│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script). Interactively, A/AAAA/CNAME/TXT offer static vs script-computed (Scriptable DNS) via pickOrCreateDnsScript: pick or create+seed a DNS script and write a SCRIPT record +│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script). Interactive wizard first offers "single record" vs a preset (pickAndApplyPreset); A/AAAA/CNAME/TXT offer static vs script-computed (Scriptable DNS) via pickOrCreateDnsScript: pick or create+seed a DNS script and write a SCRIPT record │ │ │ │ ├── update.ts # Update a record (alias: edit; prompts to pick zone+record when omitted) │ │ │ │ ├── remove.ts # Remove a record (alias: rm; prompts to pick zone+record when omitted) +│ │ │ │ ├── preset.ts # `records preset [name] [domain]` (`list` lists): pick/apply a preset, gather params, summarize, confirm, bulk-write. Exports pickAndApplyPreset reused by add.ts +│ │ │ │ ├── presets.ts # Preset catalog (data + pure build fns): google-workspace, microsoft365/outlook, zoho, mailgun, resend, proton, bluesky, dmarc, caa, no-email. findPreset(id|alias) +│ │ │ │ ├── presets.test.ts # Tests for the pure preset build fns + alias resolution │ │ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted) │ │ │ │ └── export.ts # Export records as a BIND zone file (stdout, --file , or --save → .zone) │ │ │ └── zone/ # `dns zones` — the zone itself (canonical: zones; aliases: zone; hidden: domain, domains) │ │ │ ├── index.ts # defineNamespace("zones", ...) + dnsZoneHiddenAliases (domain/domains) -│ │ │ ├── list.ts # List all DNS zones (alias: ls) -│ │ │ ├── add.ts # Create a DNS zone +│ │ │ ├── list.ts # List all DNS zones (alias: ls); Nameservers column from a live per-zone NS lookup, not bunny's NameserversDetected flag +│ │ │ ├── add.ts # Create a DNS zone, then print the bunny nameservers to set (naming the registrar via core/registrar.ts when RDAP resolves it) │ │ │ ├── link.ts # Link this directory to a zone → .bunny/dns.json (arg, else pick interactively) │ │ │ ├── unlink.ts # Remove .bunny/dns.json (alias-free; --force skips confirm) │ │ │ ├── show.ts # Show zone details (nameservers, SOA, DNSSEC, logging, record count) │ │ │ ├── remove.ts # Delete a DNS zone and its records (alias: rm) │ │ │ ├── stats.ts # Show DNS query statistics (TotalQueriesServed, by-type bar chart in text mode) -│ │ │ ├── nameservers.ts # Show registrar nameservers (alias: ns; custom if set, else kiki/coco.bunny.net) +│ │ │ ├── nameservers.ts # Check delegation (alias: ns): live NS lookup; on success a confirmation, otherwise the same "update your nameservers at [registrar]" guidance as zone add │ │ │ ├── dnssec/ │ │ │ │ ├── index.ts # defineNamespace("dnssec", ...) │ │ │ │ ├── enable.ts # Enable DNSSEC, print DS record for the registrar @@ -882,17 +888,18 @@ bunny │ │ ├── update [domain] [id] [--name] [--value] [--type] [--ttl] [--priority] [--weight] [--port] [--flags] [--tag] [--comment] [--disabled] [--pull-zone] [--script] │ │ │ Update a DNS record (alias: edit; prompts to pick zone+record when omitted) │ │ ├── remove [domain] [id] [--force] Remove a DNS record (alias: rm; prompts to pick zone+record when omitted) +│ │ ├── preset [name] [domain] Apply a preset record set (`preset list` lists; email providers, verification, security) │ │ ├── import [domain] [file] Import records from a BIND zone file (prompts for zone/file when omitted) │ │ └── export [domain] [--file] [--save] Export a zone as a BIND zone file (stdout, --file , or --save → .zone) │ └── zones (canonical; aliases: zone; hidden: domain, domains) │ ├── list List all DNS zones (alias: ls) -│ ├── add Create a DNS zone +│ ├── add Create a DNS zone (then prints the bunny nameservers to set, naming the registrar via RDAP when detectable) │ ├── link [domain] Link this directory to a zone → .bunny/dns.json (pick interactively when omitted) │ ├── unlink [--force] Remove .bunny/dns.json, unlinking this directory │ ├── show [domain] Show zone details (nameservers, SOA, DNSSEC, logging, record count) │ ├── remove [domain] [--force] Delete a DNS zone and its records (alias: rm) │ ├── stats [domain] [--from] [--to] Show DNS query statistics for a zone (defaults to last 30 days; text mode renders a bar chart) -│ ├── nameservers [domain] (alias: ns) Show the nameservers to set at the registrar (custom if enabled, else bunny.net defaults) +│ ├── nameservers [domain] (alias: ns) Live-check whether the registrar delegates to bunny.net; confirm on success, else show the nameservers to set at the named registrar │ ├── dnssec │ │ ├── enable [domain] Enable DNSSEC and print the DS record for the registrar │ │ └── disable [domain] [--force] Disable DNSSEC diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts index 5c8c9c3..e5d7b8e 100644 --- a/packages/cli/src/commands/dns/record/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -20,6 +20,7 @@ import { } from "../record-types.ts"; import type { AnswerKind } from "../scripts/constants.ts"; import { pickOrCreateDnsScript } from "../scripts/interactive.ts"; +import { pickAndApplyPreset } from "./preset.ts"; type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; type RecordLinks = Pick; @@ -268,6 +269,25 @@ export const dnsAddCommand = defineCommand({ let record: AddDnsRecordModel; if (interactive) { + // Offer a ready-made preset before falling back to building a single record by hand. + const { mode } = await prompts({ + type: "select", + name: "mode", + message: "What would you like to add?", + choices: [ + { title: "A single record", value: "manual" }, + { + title: "A preset (email providers, verification, security)", + value: "preset", + }, + ], + }); + if (mode === undefined) throw new UserError("A choice is required."); + if (mode === "preset") { + await pickAndApplyPreset({ client, zone, output }); + return; + } + const { typeValue } = await prompts({ type: "select", name: "typeValue", diff --git a/packages/cli/src/commands/dns/record/index.ts b/packages/cli/src/commands/dns/record/index.ts index 34d1b5c..15fb51e 100644 --- a/packages/cli/src/commands/dns/record/index.ts +++ b/packages/cli/src/commands/dns/record/index.ts @@ -3,6 +3,7 @@ import { dnsAddCommand } from "./add.ts"; import { dnsExportCommand } from "./export.ts"; import { dnsImportCommand } from "./import.ts"; import { dnsRecordListCommand } from "./list.ts"; +import { dnsPresetCommand } from "./preset.ts"; import { dnsRemoveCommand } from "./remove.ts"; import { dnsUpdateCommand } from "./update.ts"; @@ -14,6 +15,7 @@ export const dnsRecordNamespace = defineNamespace( dnsAddCommand, dnsUpdateCommand, dnsRemoveCommand, + dnsPresetCommand, dnsImportCommand, dnsExportCommand, ], diff --git a/packages/cli/src/commands/dns/record/preset.ts b/packages/cli/src/commands/dns/record/preset.ts new file mode 100644 index 0000000..e858a74 --- /dev/null +++ b/packages/cli/src/commands/dns/record/preset.ts @@ -0,0 +1,201 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import prompts from "prompts"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import type { CoreClient, DnsZoneModel } from "../api.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; +import { recordName, recordTypeLabel } from "../record-types.ts"; +import { type DnsPreset, findPreset, PRESETS } from "./presets.ts"; + +interface PresetArgs { + name?: string; + domain?: string; +} + +/** Prompt for each parameter a preset needs; optional blanks are dropped. */ +async function gatherParams( + preset: DnsPreset, +): Promise> { + const params: Record = {}; + for (const param of preset.params) { + const { value } = await prompts({ + type: "text", + name: "value", + message: param.optional + ? `${param.message} (blank to skip)` + : param.message, + initial: param.initial, + }); + const trimmed = (value ?? "").trim(); + if (!trimmed) { + if (!param.optional) throw new UserError(`${param.message} is required.`); + continue; + } + params[param.key] = trimmed; + } + return params; +} + +/** Expand a preset, show what it will add, confirm, then write each record. */ +export async function applyPreset(opts: { + client: CoreClient; + zone: DnsZoneModel; + preset: DnsPreset; + output: string; +}): Promise { + const { client, zone, preset, output } = opts; + const params = await gatherParams(preset); + const records = preset.build({ domain: zone.Domain ?? "", params }); + + if (records.length === 0) { + throw new UserError("This preset produced no records to add."); + } + + if (output === "json") { + logger.log(JSON.stringify(records, null, 2)); + return; + } + + logger.log(`This will add ${records.length} record(s) to ${zone.Domain}:`); + logger.log(""); + logger.log( + formatTable( + ["Type", "Name", "Value", "Priority"], + records.map((r) => [ + recordTypeLabel(r.Type), + recordName(r.Name), + r.Value ?? "", + r.Priority != null ? String(r.Priority) : "", + ]), + "text", + ), + ); + logger.log(""); + + if ( + !(await confirm(`Add these ${records.length} record(s)?`, { + initial: true, + })) + ) { + logger.info("Cancelled."); + return; + } + + const spin = spinner(`Applying ${preset.title}...`); + spin.start(); + try { + for (const record of records) { + await client.PUT("/dnszone/{zoneId}/records", { + params: { path: { zoneId: zone.Id as number } }, + body: record, + }); + } + } finally { + spin.stop(); + } + + logger.success( + `Applied ${preset.title} to ${zone.Domain}: ${records.length} record(s) added.`, + ); +} + +/** Interactive picker grouped by category; returns undefined if the user aborts. */ +export async function pickPreset(): Promise { + const { id } = await prompts({ + type: "select", + name: "id", + message: "Choose a preset:", + choices: PRESETS.map((t) => ({ + title: `${t.title} ${t.category}`, + description: t.description, + value: t.id, + })), + }); + return id ? findPreset(id) : undefined; +} + +/** Pick a preset (when none named) and apply it to an already-resolved zone. */ +export async function pickAndApplyPreset(opts: { + client: CoreClient; + zone: DnsZoneModel; + output: string; + name?: string; +}): Promise { + const { client, zone, output, name } = opts; + const preset = name ? findPreset(name) : await pickPreset(); + if (!preset) { + if (name) { + throw new UserError( + `Unknown preset "${name}".`, + `Run "bunny dns records preset" to choose from the list.`, + ); + } + return; + } + await applyPreset({ client, zone, preset, output }); +} + +export const dnsPresetCommand = defineCommand({ + command: "preset [name] [domain]", + describe: + "Add a preset set of DNS records (email providers, verification, ...).", + examples: [ + ["$0 dns records preset", "Pick a preset interactively"], + [ + "$0 dns records preset google-workspace example.com", + "Apply a named preset", + ], + ["$0 dns records preset list", "List available presets"], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + describe: "Preset id (omit to pick interactively, or 'list' to list)", + }) + .positional("domain", { type: "string", describe: "Domain or zone ID" }), + + handler: async ({ name, domain, profile, output, verbose, apiKey }) => { + if (name === "list") { + if (output === "json") { + logger.log( + JSON.stringify( + PRESETS.map(({ id, title, category, description }) => ({ + id, + title, + category, + description, + })), + null, + 2, + ), + ); + return; + } + logger.log( + formatTable( + ["Preset", "Category", "Description"], + PRESETS.map((t) => [t.id, t.category, t.description]), + output, + ), + ); + return; + } + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); + + await pickAndApplyPreset({ client, zone, output, name }); + }, +}); diff --git a/packages/cli/src/commands/dns/record/presets.test.ts b/packages/cli/src/commands/dns/record/presets.test.ts new file mode 100644 index 0000000..d9fdc12 --- /dev/null +++ b/packages/cli/src/commands/dns/record/presets.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { RECORD_TYPES } from "../../../core/dns-record-types.ts"; +import { findPreset, PRESETS } from "./presets.ts"; + +const build = ( + id: string, + params: Record = {}, + domain = "example.com", +) => { + const t = findPreset(id); + if (!t) throw new Error(`missing preset ${id}`); + return t.build({ domain, params }); +}; + +describe("DNS presets", () => { + test("every preset has a unique id and at least one record from defaults", () => { + const ids = PRESETS.map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + for (const t of PRESETS) { + const initial = Object.fromEntries( + t.params + .filter((p) => p.initial) + .map((p) => [p.key, p.initial as string]), + ); + // Presets with a required param legitimately need it; supply a stub. + for (const p of t.params) + if (!p.optional && !initial[p.key]) initial[p.key] = "stub"; + expect( + t.build({ domain: "example.com", params: initial }).length, + ).toBeGreaterThan(0); + } + }); + + test("google-workspace: MX + SPF, DKIM/DMARC only when provided", () => { + const base = build("google-workspace"); + expect(base).toHaveLength(2); + expect(base[0]).toMatchObject({ + Type: RECORD_TYPES.MX, + Name: "", + Value: "smtp.google.com", + Priority: 1, + }); + expect(base[1]?.Value).toBe("v=spf1 include:_spf.google.com ~all"); + + const full = build("google-workspace", { + dkim: "v=DKIM1; p=abc", + dmarc: "dmarc@example.com", + }); + expect(full).toHaveLength(4); + expect(full.find((r) => r.Name === "google._domainkey")?.Value).toBe( + "v=DKIM1; p=abc", + ); + expect(full.find((r) => r.Name === "_dmarc")?.Value).toContain( + "rua=mailto:dmarc@example.com", + ); + }); + + test("microsoft365 derives the MX host from the domain", () => { + const recs = build("microsoft365", {}, "acme.co.uk"); + expect(recs[0]?.Value).toBe("acme-co-uk.mail.protection.outlook.com"); + }); + + test("bluesky writes _atproto at apex or under the handle subdomain", () => { + expect(build("bluesky", { did: "did:plc:xyz" })[0]).toMatchObject({ + Type: RECORD_TYPES.TXT, + Name: "_atproto", + Value: "did=did:plc:xyz", + }); + expect( + build("bluesky", { did: "did:plc:xyz", handle: "alice" })[0]?.Name, + ).toBe("_atproto.alice"); + }); + + test("no-email uses a null MX and a reject DMARC", () => { + const recs = build("no-email"); + expect(recs.find((r) => r.Type === RECORD_TYPES.MX)?.Value).toBe("."); + expect(recs.find((r) => r.Name === "_dmarc")?.Value).toContain("p=reject"); + }); + + test("aliases resolve to the same preset", () => { + expect(findPreset("outlook")?.id).toBe("microsoft365"); + expect(findPreset("gmail")?.id).toBe("google-workspace"); + expect(findPreset("nope")).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/dns/record/presets.ts b/packages/cli/src/commands/dns/record/presets.ts new file mode 100644 index 0000000..62f7de6 --- /dev/null +++ b/packages/cli/src/commands/dns/record/presets.ts @@ -0,0 +1,292 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { RECORD_TYPES } from "../../../core/dns-record-types.ts"; + +type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; + +/** A value the preset needs from the user (account-specific keys, selectors, codes). */ +export interface PresetParam { + key: string; + message: string; + initial?: string; + optional?: boolean; +} + +export interface PresetContext { + domain: string; + params: Record; +} + +export interface DnsPreset { + id: string; + title: string; + category: "Email" | "Email security" | "Verification"; + description: string; + aliases?: string[]; + params: PresetParam[]; + build: (ctx: PresetContext) => AddDnsRecordModel[]; +} + +/** "@" / "" both mean the zone apex, which the API represents as an empty name. */ +const at = (name: string) => (name === "@" ? "" : name); + +const mx = ( + name: string, + value: string, + priority: number, +): AddDnsRecordModel => ({ + Type: RECORD_TYPES.MX, + Name: at(name), + Value: value, + Priority: priority, +}); +const txt = (name: string, value: string): AddDnsRecordModel => ({ + Type: RECORD_TYPES.TXT, + Name: at(name), + Value: value, +}); +const cname = (name: string, value: string): AddDnsRecordModel => ({ + Type: RECORD_TYPES.CNAME, + Name: at(name), + Value: value, +}); +const caa = (name: string, tag: string, value: string): AddDnsRecordModel => ({ + Type: RECORD_TYPES.CAA, + Name: at(name), + Flags: 0, + Tag: tag, + Value: value, +}); + +/** Assemble a DMARC policy string without trailing separators. */ +function dmarc(policy: string, report?: string): string { + const parts = ["v=DMARC1", `p=${policy}`]; + if (report) parts.push(`rua=mailto:${report}`); + return parts.join("; "); +} + +export const PRESETS: DnsPreset[] = [ + { + id: "google-workspace", + title: "Google Workspace", + category: "Email", + description: "Gmail-hosted email: MX, SPF, optional DKIM and DMARC.", + aliases: ["gmail", "gsuite"], + params: [ + { + key: "dkim", + message: "Google DKIM TXT value (Admin console)", + optional: true, + }, + { key: "dmarc", message: "DMARC report email", optional: true }, + ], + build: ({ params }) => { + const records = [ + mx("@", "smtp.google.com", 1), + txt("@", "v=spf1 include:_spf.google.com ~all"), + ]; + if (params.dkim) records.push(txt("google._domainkey", params.dkim)); + if (params.dmarc) + records.push(txt("_dmarc", dmarc("none", params.dmarc))); + return records; + }, + }, + { + id: "microsoft365", + title: "Microsoft 365 (Outlook)", + category: "Email", + description: "Microsoft 365 / Outlook: MX, SPF, autodiscover.", + aliases: ["outlook", "office365", "o365"], + params: [{ key: "dmarc", message: "DMARC report email", optional: true }], + build: ({ domain, params }) => { + const mxHost = `${domain.replace(/\./g, "-")}.mail.protection.outlook.com`; + const records = [ + mx("@", mxHost, 0), + txt("@", "v=spf1 include:spf.protection.outlook.com -all"), + cname("autodiscover", "autodiscover.outlook.com"), + ]; + if (params.dmarc) + records.push(txt("_dmarc", dmarc("none", params.dmarc))); + return records; + }, + }, + { + id: "zoho", + title: "Zoho Mail", + category: "Email", + description: "Zoho-hosted email: MX, SPF, optional verification and DKIM.", + params: [ + { + key: "region", + message: "Zoho region TLD (com, eu, in, ...)", + initial: "com", + }, + { key: "verify", message: "zoho-verification code", optional: true }, + { key: "dkimSelector", message: "DKIM selector", initial: "zmail" }, + { key: "dkim", message: "DKIM TXT value", optional: true }, + ], + build: ({ params }) => { + const region = params.region || "com"; + const records = [ + mx("@", `mx.zoho.${region}`, 10), + mx("@", `mx2.zoho.${region}`, 20), + mx("@", `mx3.zoho.${region}`, 50), + txt("@", `v=spf1 include:zoho.${region} ~all`), + ]; + if (params.verify) + records.push(txt("@", `zoho-verification=${params.verify}`)); + if (params.dkim) + records.push( + txt(`${params.dkimSelector || "zmail"}._domainkey`, params.dkim), + ); + return records; + }, + }, + { + id: "mailgun", + title: "Mailgun", + category: "Email", + description: + "Mailgun sending domain: SPF, DKIM, receiving MX, tracking CNAME.", + params: [ + { key: "sub", message: "Sending subdomain", initial: "mg" }, + { key: "dkimSelector", message: "DKIM selector", initial: "mx" }, + { key: "dkim", message: "DKIM TXT value", optional: true }, + ], + build: ({ params }) => { + const sub = params.sub || "mg"; + const selector = params.dkimSelector || "mx"; + const records = [ + txt(sub, "v=spf1 include:mailgun.org ~all"), + mx(sub, "mxa.mailgun.org", 10), + mx(sub, "mxb.mailgun.org", 10), + cname(`email.${sub}`, "mailgun.org"), + ]; + if (params.dkim) + records.push(txt(`${selector}._domainkey.${sub}`, params.dkim)); + return records; + }, + }, + { + id: "resend", + title: "Resend", + category: "Email", + description: "Resend sending domain: MX, SPF, DKIM.", + params: [ + { key: "sub", message: "Sending subdomain", initial: "send" }, + { key: "region", message: "Resend/SES region", initial: "us-east-1" }, + { key: "dkim", message: "resend._domainkey TXT value", optional: true }, + ], + build: ({ params }) => { + const sub = params.sub || "send"; + const region = params.region || "us-east-1"; + const records = [ + mx(sub, `feedback-smtp.${region}.amazonses.com`, 10), + txt(sub, "v=spf1 include:amazonses.com ~all"), + ]; + if (params.dkim) records.push(txt("resend._domainkey", params.dkim)); + return records; + }, + }, + { + id: "proton", + title: "Proton Mail", + category: "Email", + description: "Proton Mail: verification, MX, SPF, optional DKIM CNAMEs.", + aliases: ["protonmail"], + params: [ + { + key: "verify", + message: "protonmail-verification code", + optional: true, + }, + { key: "dkim1", message: "DKIM CNAME target 1", optional: true }, + { key: "dkim2", message: "DKIM CNAME target 2", optional: true }, + { key: "dkim3", message: "DKIM CNAME target 3", optional: true }, + ], + build: ({ params }) => { + const records: AddDnsRecordModel[] = []; + if (params.verify) + records.push(txt("@", `protonmail-verification=${params.verify}`)); + records.push(mx("@", "mail.protonmail.ch", 10)); + records.push(mx("@", "mailsec.protonmail.ch", 20)); + records.push(txt("@", "v=spf1 include:_spf.protonmail.ch ~all")); + if (params.dkim1) + records.push(cname("protonmail._domainkey", params.dkim1)); + if (params.dkim2) + records.push(cname("protonmail2._domainkey", params.dkim2)); + if (params.dkim3) + records.push(cname("protonmail3._domainkey", params.dkim3)); + return records; + }, + }, + { + id: "bluesky", + title: "Bluesky handle", + category: "Verification", + description: + "Verify a domain as a Bluesky handle via an _atproto TXT record.", + params: [ + { key: "did", message: "Your DID (did:plc:...)" }, + { + key: "handle", + message: "Handle subdomain (blank for the apex)", + optional: true, + }, + ], + build: ({ params }) => { + const name = params.handle ? `_atproto.${params.handle}` : "_atproto"; + return [txt(name, `did=${params.did}`)]; + }, + }, + { + id: "dmarc", + title: "DMARC policy", + category: "Email security", + description: + "A DMARC record at _dmarc with a chosen policy and report address.", + params: [ + { + key: "policy", + message: "Policy (none, quarantine, reject)", + initial: "none", + }, + { key: "report", message: "DMARC report email", optional: true }, + ], + build: ({ params }) => [ + txt("_dmarc", dmarc(params.policy || "none", params.report)), + ], + }, + { + id: "caa", + title: "CAA (restrict certificate issuance)", + category: "Email security", + description: "Limit which CAs may issue certificates for this domain.", + params: [ + { key: "ca", message: "Allowed CA domain", initial: "letsencrypt.org" }, + ], + build: ({ params }) => [caa("@", "issue", params.ca || "letsencrypt.org")], + }, + { + id: "no-email", + title: "No email (anti-spoofing)", + category: "Email security", + description: + "Null MX, SPF -all, and a reject DMARC for a domain that never sends mail.", + params: [], + build: () => [ + mx("@", ".", 0), + txt("@", "v=spf1 -all"), + txt("_dmarc", dmarc("reject")), + ], + }, +]; + +/** Find a preset by id or alias, case-insensitively. */ +export function findPreset(name: string): DnsPreset | undefined { + const needle = name.trim().toLowerCase(); + return PRESETS.find( + (t) => + t.id === needle || + (t.aliases ?? []).some((a) => a.toLowerCase() === needle), + ); +} diff --git a/packages/cli/src/commands/dns/zone/add.ts b/packages/cli/src/commands/dns/zone/add.ts index b409aa0..a77e98c 100644 --- a/packages/cli/src/commands/dns/zone/add.ts +++ b/packages/cli/src/commands/dns/zone/add.ts @@ -2,7 +2,13 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; +import { + BUNNY_NAMESERVERS, + checkDelegation, + expectedNameservers, +} from "../../../core/dns-nameservers.ts"; import { logger } from "../../../core/logger.ts"; +import { detectRegistrar } from "../../../core/registrar.ts"; import { spinner } from "../../../core/ui.ts"; import type { DnsZoneModel } from "../api.ts"; @@ -56,5 +62,38 @@ export const dnsZoneAddCommand = defineCommand({ ? `Created DNS zone ${domain} (ID: ${created.Id}).` : `Created DNS zone ${domain}.`, ); + + // Savvy users often point the registrar at bunny before creating the zone; skip the setup steps when it's already delegated. + const checkSpin = spinner("Checking nameserver delegation..."); + checkSpin.start(); + let delegated: boolean; + try { + const { status } = await checkDelegation( + domain, + expectedNameservers(created ?? {}), + ); + delegated = status === "bunny"; + } finally { + checkSpin.stop(); + } + + logger.log(""); + if (delegated) { + logger.success( + "Nameservers already point to bunny.net: no changes needed.", + ); + return; + } + + const registrar = await detectRegistrar(domain); + logger.log( + `Now update your nameservers at ${registrar ?? "your domain registrar"} to:`, + ); + logger.log(""); + for (const ns of BUNNY_NAMESERVERS) logger.log(` ${ns}`); + logger.log(""); + logger.dim( + `Propagation can take up to 48 hours. Verify with:\n bunny dns zones ns ${domain}`, + ); }, }); diff --git a/packages/cli/src/commands/dns/zone/list.ts b/packages/cli/src/commands/dns/zone/list.ts index 102cbe8..db9a2fd 100644 --- a/packages/cli/src/commands/dns/zone/list.ts +++ b/packages/cli/src/commands/dns/zone/list.ts @@ -2,11 +2,22 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; +import { + checkDelegations, + type DelegationStatus, + expectedNameservers, +} from "../../../core/dns-nameservers.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; import { spinner } from "../../../core/ui.ts"; import { type DnsZoneModel, fetchZones } from "../api.ts"; +const DELEGATION_LABEL: Record = { + bunny: "Detected", + other: "Pending", + unknown: "Unknown", +}; + export const dnsZoneListCommand = defineCommand({ command: "list", aliases: ["ls"], @@ -29,8 +40,31 @@ export const dnsZoneListCommand = defineCommand({ spin.stop(); } + // bunny's NameserversDetected defaults to true on a fresh zone; resolve the real delegation live. + let delegation: DelegationStatus[] = []; + if (zones.length > 0) { + const checkSpin = spinner("Checking nameserver delegation..."); + checkSpin.start(); + try { + const checks = await checkDelegations( + zones.map((z) => ({ + domain: z.Domain ?? "", + expected: expectedNameservers(z), + })), + ); + delegation = checks.map((c) => c.status); + } finally { + checkSpin.stop(); + } + } + if (output === "json") { - logger.log(JSON.stringify(zones, null, 2)); + // Overwrite the unreliable API flag with the live result so scripts read the true value. + const corrected = zones.map((z, i) => ({ + ...z, + NameserversDetected: delegation[i] === "bunny", + })); + logger.log(JSON.stringify(corrected, null, 2)); return; } @@ -42,12 +76,12 @@ export const dnsZoneListCommand = defineCommand({ logger.log( formatTable( ["ID", "Domain", "Records", "DNSSEC", "Nameservers"], - zones.map((z) => [ + zones.map((z, i) => [ String(z.Id ?? ""), z.Domain ?? "", String((z.Records ?? []).length), z.DnsSecEnabled ? "Yes" : "No", - z.NameserversDetected ? "Detected" : "Pending", + DELEGATION_LABEL[delegation[i] ?? "unknown"], ]), output, ), diff --git a/packages/cli/src/commands/dns/zone/nameservers.ts b/packages/cli/src/commands/dns/zone/nameservers.ts index 43df5fa..89621bb 100644 --- a/packages/cli/src/commands/dns/zone/nameservers.ts +++ b/packages/cli/src/commands/dns/zone/nameservers.ts @@ -2,23 +2,24 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; -import { formatKeyValue, formatTable } from "../../../core/format.ts"; +import { + checkDelegation, + expectedNameservers, +} from "../../../core/dns-nameservers.ts"; import { logger } from "../../../core/logger.ts"; +import { detectRegistrar } from "../../../core/registrar.ts"; import { resolveZoneInteractive } from "../interactive.ts"; interface NameserversArgs { domain?: string; } -// bunny.net delegates every zone to the same two anycast nameservers. -const BUNNY_DEFAULT_NAMESERVERS = ["kiki.bunny.net", "coco.bunny.net"]; - export const dnsNameserversCommand = defineCommand({ command: "nameservers [domain]", aliases: ["ns"], - describe: "Show the nameservers to set at your registrar for a zone.", + describe: "Check whether a zone is delegated to bunny.net.", examples: [ - ["$0 dns zones nameservers example.com", "Show the zone's nameservers"], + ["$0 dns zones nameservers example.com", "Check the zone's delegation"], ["$0 dns zones ns example.com --output json", "JSON output"], ], @@ -36,21 +37,28 @@ export const dnsNameserversCommand = defineCommand({ output, offerLink: true, }); + const zoneDomain = zone.Domain ?? ""; const custom = zone.CustomNameserversEnabled === true && Boolean(zone.Nameserver1 || zone.Nameserver2); - const nameservers = custom - ? [zone.Nameserver1, zone.Nameserver2].filter((ns): ns is string => - Boolean(ns), - ) - : BUNNY_DEFAULT_NAMESERVERS; - const detected = zone.NameserversDetected === true; + const nameservers = [...expectedNameservers(zone)]; + + // Read the live registrar delegation; bunny's NameserversDetected flag defaults to true on a fresh zone. + const { status, resolved } = await checkDelegation(zoneDomain, nameservers); + const detected = status === "bunny"; if (output === "json") { logger.log( JSON.stringify( - { domain: zone.Domain, custom, detected, nameservers }, + { + domain: zoneDomain, + custom, + detected, + status, + resolved, + nameservers, + }, null, 2, ), @@ -58,31 +66,22 @@ export const dnsNameserversCommand = defineCommand({ return; } + if (detected) { + logger.success( + `Nameservers detected and pointing to Bunny DNS for ${zoneDomain}.`, + ); + return; + } + + const registrar = await detectRegistrar(zoneDomain); logger.log( - formatKeyValue( - [ - { key: "Zone", value: zone.Domain ?? "" }, - { key: "Type", value: custom ? "Custom" : "Default (bunny.net)" }, - { key: "Detected at registrar", value: detected ? "Yes" : "No" }, - ], - output, - ), + `Now update your nameservers at ${registrar ?? "your domain registrar"} to:`, ); - logger.log(""); - logger.log( - formatTable( - ["Nameserver"], - nameservers.map((ns) => [ns]), - output, - ), + for (const ns of nameservers) logger.log(` ${ns}`); + logger.log(""); + logger.dim( + `Propagation can take up to 48 hours. Verify with:\n bunny dns zones ns ${zoneDomain}`, ); - - if (!detected) { - logger.log(""); - logger.dim( - `Point ${zone.Domain}'s nameservers at the above to delegate it to bunny.net.`, - ); - } }, }); diff --git a/packages/cli/src/core/dns-nameservers.ts b/packages/cli/src/core/dns-nameservers.ts new file mode 100644 index 0000000..0671361 --- /dev/null +++ b/packages/cli/src/core/dns-nameservers.ts @@ -0,0 +1,77 @@ +import { promises as dns } from "node:dns"; + +/** bunny.net delegates every zone to the same two anycast nameservers. */ +export const BUNNY_NAMESERVERS = ["kiki.bunny.net", "coco.bunny.net"] as const; + +/** Lowercase and drop a trailing dot so nameservers compare cleanly. */ +function normalize(ns: string): string { + return ns.trim().toLowerCase().replace(/\.$/, ""); +} + +/** "bunny" = fully delegated to the expected set; "other" = points elsewhere; "unknown" = couldn't resolve. */ +export type DelegationStatus = "bunny" | "other" | "unknown"; + +export interface DelegationCheck { + status: DelegationStatus; + /** The NS records the parent zone actually returned, normalized and sorted. */ + resolved: string[]; +} + +/** + * Resolve a domain's authoritative NS records and compare them against the + * expected nameservers. This reads the real registrar delegation rather than + * bunny's NameserversDetected flag, which defaults to true on a fresh zone. + */ +export async function checkDelegation( + domain: string, + expected: readonly string[] = BUNNY_NAMESERVERS, +): Promise { + const want = new Set(expected.map(normalize)); + try { + const resolved = (await dns.resolveNs(domain)).map(normalize).sort(); + if (resolved.length === 0) return { status: "unknown", resolved }; + const status = resolved.every((ns) => want.has(ns)) ? "bunny" : "other"; + return { status, resolved }; + } catch { + // NXDOMAIN, SERVFAIL, no NS published yet, or no network: can't tell. + return { status: "unknown", resolved: [] }; + } +} + +/** The nameservers a zone delegates to: its custom pair when enabled, else bunny's defaults. */ +export function expectedNameservers(zone: { + CustomNameserversEnabled?: boolean; + Nameserver1?: string | null; + Nameserver2?: string | null; +}): readonly string[] { + if (zone.CustomNameserversEnabled && (zone.Nameserver1 || zone.Nameserver2)) { + return [zone.Nameserver1, zone.Nameserver2].filter((ns): ns is string => + Boolean(ns), + ); + } + return BUNNY_NAMESERVERS; +} + +/** + * Resolve delegation for many domains at once, bounded so a large account + * doesn't fan out into hundreds of simultaneous DNS queries. Results are + * returned in the same order as the input. + */ +export async function checkDelegations( + items: { domain: string; expected?: readonly string[] }[], + concurrency = 10, +): Promise { + const results = new Array(items.length); + let next = 0; + async function worker() { + while (next < items.length) { + const index = next++; + const item = items[index]; + if (!item) continue; + results[index] = await checkDelegation(item.domain, item.expected); + } + } + const lanes = Math.min(concurrency, items.length) || 1; + await Promise.all(Array.from({ length: lanes }, worker)); + return results; +} diff --git a/packages/cli/src/core/format.ts b/packages/cli/src/core/format.ts index c1bf78e..765f675 100644 --- a/packages/cli/src/core/format.ts +++ b/packages/cli/src/core/format.ts @@ -87,7 +87,11 @@ export function formatTable( const noColorStyle = chalk.level === 0 ? { head: [], border: [] } : {}; if (format === "table") { - const table = new Table({ head: headers, style: noColorStyle }); + // Color heads bunny orange ourselves; cli-table3's own head color is red. + const table = new Table({ + head: headers.map((h) => bunny.bold(h)), + style: { head: [], ...noColorStyle }, + }); for (const row of rows) { table.push(row); } diff --git a/packages/cli/src/core/hostnames/bunny-dns.test.ts b/packages/cli/src/core/hostnames/bunny-dns.test.ts index 51bcdac..674146b 100644 --- a/packages/cli/src/core/hostnames/bunny-dns.test.ts +++ b/packages/cli/src/core/hostnames/bunny-dns.test.ts @@ -1,12 +1,28 @@ -import { describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; import prompts from "prompts"; -import { findBunnyDnsZone, offerBunnyDnsRecord } from "./bunny-dns.ts"; -import type { CoreClient } from "./client.ts"; -import { offerBunnyDnsThenSsl } from "./flow.ts"; - -type Zone = { Id: number; Domain: string; NameserversDetected?: boolean }; +import type { DelegationStatus } from "../dns-nameservers.ts"; + +// Delegation is a live NS lookup; stub it so tests stay hermetic and drive the outcome. +let delegationStatus: DelegationStatus = "bunny"; +mock.module("../dns-nameservers.ts", () => ({ + BUNNY_NAMESERVERS: ["kiki.bunny.net", "coco.bunny.net"], + expectedNameservers: () => ["kiki.bunny.net", "coco.bunny.net"], + checkDelegation: async () => ({ status: delegationStatus, resolved: [] }), +})); + +const { findBunnyDnsZone, offerBunnyDnsRecord } = await import( + "./bunny-dns.ts" +); +const { offerBunnyDnsThenSsl } = await import("./flow.ts"); +type CoreClient = import("./client.ts").CoreClient; + +type Zone = { Id: number; Domain: string }; type Rec = { Id?: number; Type?: number; Name?: string; Value?: string }; +beforeEach(() => { + delegationStatus = "bunny"; +}); + /** A core client stubbed to serve a fixed set of zones and per-zone records. */ function fakeClient( zones: Zone[], @@ -19,13 +35,7 @@ function fakeClient( } if (path === "/dnszone/{id}") { const id = opts.params.path?.id as number; - const zone = zones.find((z) => z.Id === id); - return { - data: { - Records: recordsByZone[id] ?? [], - NameserversDetected: zone?.NameserversDetected, - }, - }; + return { data: { Records: recordsByZone[id] ?? [] } }; } throw new Error(`unexpected GET ${path}`); }, @@ -86,20 +96,18 @@ describe("findBunnyDnsZone", () => { expect(match?.existing).toBeNull(); }); - test("reports delegated:true only when nameservers are detected", async () => { - const delegated = fakeClient([ - { Id: 7, Domain: "example.com", NameserversDetected: true }, - ]); + test("reports delegated:true only when the registrar delegates to bunny", async () => { + const client = fakeClient([{ Id: 7, Domain: "example.com" }]); + + delegationStatus = "bunny"; expect( - (await findBunnyDnsZone(delegated, "shop.example.com"))?.delegated, + (await findBunnyDnsZone(client, "shop.example.com"))?.delegated, ).toBe(true); // A zone in the account but not yet delegated at the registrar isn't live. - const undelegated = fakeClient([ - { Id: 7, Domain: "example.com", NameserversDetected: false }, - ]); + delegationStatus = "other"; expect( - (await findBunnyDnsZone(undelegated, "shop.example.com"))?.delegated, + (await findBunnyDnsZone(client, "shop.example.com"))?.delegated, ).toBe(false); }); }); @@ -136,10 +144,9 @@ describe("offerBunnyDnsThenSsl", () => { // Zone detection succeeds, but the matched record has no Id — once the user // confirms the repoint, the failure must propagate, not fall back to manual DNS. prompts.inject([true]); - const client = fakeClient( - [{ Id: 7, Domain: "example.com", NameserversDetected: true }], - { 7: [{ Type: 0, Name: "shop", Value: "1.2.3.4" }] }, - ); + const client = fakeClient([{ Id: 7, Domain: "example.com" }], { + 7: [{ Type: 0, Name: "shop", Value: "1.2.3.4" }], + }); await expect( offerBunnyDnsThenSsl({ @@ -158,21 +165,20 @@ describe("offerBunnyDnsThenSsl", () => { // A PULLZONE record on an undelegated zone never resolves publicly — short-circuit // rather than entering offerDnsWaitAndSsl, which would poll for the full 10 minutes. prompts.inject([true]); + delegationStatus = "other"; let putCalled = false; const client = { GET: async (path: string) => { if (path === "/dnszone") { return { data: { - Items: [ - { Id: 7, Domain: "example.com", NameserversDetected: false }, - ], + Items: [{ Id: 7, Domain: "example.com" }], HasMoreItems: false, }, }; } if (path === "/dnszone/{id}") { - return { data: { Records: [], NameserversDetected: false } }; + return { data: { Records: [] } }; } throw new Error(`unexpected GET ${path}`); }, diff --git a/packages/cli/src/core/hostnames/bunny-dns.ts b/packages/cli/src/core/hostnames/bunny-dns.ts index 8e42229..ab02d36 100644 --- a/packages/cli/src/core/hostnames/bunny-dns.ts +++ b/packages/cli/src/core/hostnames/bunny-dns.ts @@ -1,4 +1,5 @@ import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { checkDelegation, expectedNameservers } from "../dns-nameservers.ts"; import { RECORD_TYPES, recordTypeLabel } from "../dns-record-types.ts"; import { UserError } from "../errors.ts"; import { logger } from "../logger.ts"; @@ -72,12 +73,18 @@ export async function findBunnyDnsZone( (data?.Records ?? []).find((r) => normalize(r.Name ?? "") === recordName) ?? null; + // Resolve the live registrar delegation; NameserversDetected defaults to true on a fresh zone. + const { status } = await checkDelegation( + best.Domain ?? domain, + expectedNameservers(data ?? {}), + ); + return { zoneId: best.Id, zoneDomain: best.Domain ?? domain, recordName, existing, - delegated: data?.NameserversDetected === true, + delegated: status === "bunny", }; } diff --git a/packages/cli/src/core/registrar.test.ts b/packages/cli/src/core/registrar.test.ts new file mode 100644 index 0000000..ab468fc --- /dev/null +++ b/packages/cli/src/core/registrar.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { parseRegistrar } from "./registrar.ts"; + +describe("parseRegistrar", () => { + const vcard = (fn: string) => ({ + roles: ["registrar"], + vcardArray: [ + "vcard", + [ + ["version", {}, "text", "4.0"], + ["fn", {}, "text", fn], + ], + ], + }); + + test("reads the registrar entity's display name", () => { + expect(parseRegistrar({ entities: [vcard("Namecheap, Inc.")] })).toBe( + "Namecheap", + ); + }); + + test("strips assorted legal suffixes", () => { + expect(parseRegistrar({ entities: [vcard("NameCheap Inc.")] })).toBe( + "NameCheap", + ); + expect(parseRegistrar({ entities: [vcard("GoDaddy.com, LLC")] })).toBe( + "GoDaddy.com", + ); + }); + + test("ignores entities that aren't the registrar", () => { + const data = { + entities: [ + { + roles: ["technical"], + vcardArray: ["vcard", [["fn", {}, "text", "Tech Co."]]], + }, + ], + }; + expect(parseRegistrar(data)).toBeNull(); + }); + + test("returns null when there are no entities", () => { + expect(parseRegistrar({})).toBeNull(); + }); +}); diff --git a/packages/cli/src/core/registrar.ts b/packages/cli/src/core/registrar.ts new file mode 100644 index 0000000..43d46fe --- /dev/null +++ b/packages/cli/src/core/registrar.ts @@ -0,0 +1,48 @@ +interface RdapEntity { + roles?: string[]; + vcardArray?: [string, unknown[]]; +} +interface RdapDomain { + entities?: RdapEntity[]; +} + +/** Drop a trailing legal suffix so "Namecheap, Inc." reads as "Namecheap". */ +function tidyName(name: string): string | null { + const cleaned = name + .trim() + .replace(/,?\s+(inc|llc|l\.l\.c|ltd|limited|corp|co)\.?$/i, "") + .trim(); + return cleaned || null; +} + +/** Pull the registrar's display name out of an RDAP domain response. */ +export function parseRegistrar(data: unknown): string | null { + const { entities = [] } = (data ?? {}) as RdapDomain; + const registrar = entities.find((e) => (e.roles ?? []).includes("registrar")); + for (const entry of registrar?.vcardArray?.[1] ?? []) { + if ( + Array.isArray(entry) && + entry[0] === "fn" && + typeof entry[3] === "string" + ) + return tidyName(entry[3]); + } + return null; +} + +/** Best-effort registrar lookup via RDAP; null when it can't be determined. */ +export async function detectRegistrar(domain: string): Promise { + try { + const res = await fetch( + `https://rdap.org/domain/${encodeURIComponent(domain)}`, + { + headers: { Accept: "application/rdap+json" }, + signal: AbortSignal.timeout(4000), + }, + ); + if (!res.ok) return null; + return parseRegistrar(await res.json()); + } catch { + return null; + } +} From b042d05942017970cd88401c6f00d0059af488b2 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 30 Jun 2026 10:32:23 +0100 Subject: [PATCH 2/3] update skil;ls --- README.md | 4 +++ skills/bunny-cli/SKILL.md | 6 +++-- skills/bunny-cli/references/dns.md | 43 ++++++++++++++++++++++++++---- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index eef06f3..d4413e1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ bun ny apps deploy # first run? Imports docker-compose. bun ny apps link # interactive: pick from existing apps on the account bun ny apps link # link a specific app to this directory (writes .bunny/app.json) bun ny apps unlink # remove .bunny/app.json +bun ny dns zones add example.com # create a zone; prints registrar-aware setup steps (skipped if already delegated) +bun ny dns zones nameservers example.com # live-check whether the registrar delegates to bunny +bun ny dns records preset list # list DNS record presets (email providers, verification, security) +bun ny dns records preset google-workspace example.com # apply a preset record set ``` ### Available Scripts diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index 5bc8c60..3d79a4e 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -18,7 +18,7 @@ Config is stored in (first match wins): - `~/.bunnynet.json` - `/etc/bunnynet.json` -**When something goes wrong, check auth first** — run a quick `bunny api GET /user` to verify your key works. If using profiles, confirm the right one is active with `--profile`. +**When something goes wrong, check auth first**: run a quick `bunny api GET /user` to verify your key works. If using profiles, confirm the right one is active with `--profile`. ## Quick Start @@ -42,7 +42,9 @@ bunny scripts list # manage DNS bunny dns zones add example.com +bunny dns zones nameservers example.com # is the registrar delegated to bunny yet? bunny dns records add example.com api A 198.51.100.1 +bunny dns records preset google-workspace example.com # apply a preset record set bunny dns records list example.com ``` @@ -52,7 +54,7 @@ Use this to route to the correct reference file: - **Authenticate or switch profiles** -> `references/auth.md` - **Database management (create, list, show, link, delete, shell, studio, regions, tokens)** -> `references/database.md` -- **DNS (zones, records, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` +- **DNS (zones, delegation checks, records, presets, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` - **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` - **Make raw API requests** -> `references/api.md` - **CLI doesn't have a command for it** -> use `bunny api` as a fallback (see `references/api.md`) diff --git a/skills/bunny-cli/references/dns.md b/skills/bunny-cli/references/dns.md index 8f7f53d..45446fe 100644 --- a/skills/bunny-cli/references/dns.md +++ b/skills/bunny-cli/references/dns.md @@ -21,13 +21,15 @@ When a zone is chosen via the picker, the command offers to link the directory t ## Typical workflows ```bash -# Create a zone, then point your registrar at the printed nameservers +# Create a zone; it prints the nameservers to set and names your registrar. +# Once you have pointed them, check delegation has taken effect: bunny dns zones add example.com bunny dns zones nameservers example.com -# Add records +# Add records, or apply a preset record set (email, verification, security) bunny dns records add example.com api A 198.51.100.1 bunny dns records add example.com '@' MX mail.example.com 10 +bunny dns records preset google-workspace example.com bunny dns records list example.com # Back up and restore a zone as a BIND file @@ -51,16 +53,21 @@ bunny dns scripts attach example.com api --script bunny dns zones add example.com ``` -Prints the bunny.net nameservers to set at your registrar. +After creating the zone it checks the live registrar delegation: + +- If the domain already points at bunny (common when you set the nameservers first), it just confirms, with no further steps. +- Otherwise it prints the nameservers to set, naming your registrar when it can detect it (via RDAP), plus how to verify once you have pointed them. ## `bunny dns zones list` / `show` / `nameservers` ```bash bunny dns zones list # all zones (alias: ls) bunny dns zones show example.com # zone details -bunny dns zones nameservers example.com # nameservers to set at your registrar (alias: ns) +bunny dns zones nameservers example.com # check delegation (alias: ns) ``` +`nameservers` does a live nameserver lookup rather than trusting the API's detection flag (which reads as detected on a brand-new zone). When the registrar already delegates to bunny it confirms; otherwise it shows the nameservers to set at your (named) registrar. `list` shows a `Nameservers` column (`Detected` / `Pending` / `Unknown`) from the same live lookup, and `--output json` overwrites each zone's `NameserversDetected` with the live value. + ## `bunny dns zones remove`: Delete a zone Deletes the zone and all of its records. Confirms unless `--force`. @@ -123,7 +130,7 @@ All record commands operate within a zone (see [Zone resolution](#zone-resolutio ## `bunny dns records add`: Add a record -Runs an interactive wizard when the type is omitted. Value ordering is per-type (see examples). +Runs an interactive wizard when the type is omitted. Value ordering is per-type (see examples). The wizard first asks whether to add a single record or apply a preset (see [`bunny dns records preset`](#bunny-dns-records-preset-apply-a-preset-record-set)). ```bash bunny dns records add example.com api A 198.51.100.1 @@ -133,6 +140,32 @@ bunny dns records add example.com '@' CAA '0 issue "letsencrypt.org"' bunny dns records add # interactive wizard ``` +## `bunny dns records preset`: Apply a preset record set + +Applies a curated set of records for a common provider or task in one step. It prompts for the account-specific values (DKIM keys, verification codes, selectors), shows a summary of every record, and confirms before writing. Account-specific secrets cannot be hardcoded, but the record names, types, priorities, and SPF includes are filled in for you. + +```bash +bunny dns records preset # pick a preset interactively +bunny dns records preset list # list available presets +bunny dns records preset google-workspace example.com # apply a named preset +bunny dns records preset list --output json +``` + +Available presets: + +| Preset | Category | Records | +| ------------------ | -------------- | -------------------------------------------------------------- | +| `google-workspace` | Email | MX, SPF; optional DKIM and DMARC (aliases: `gmail`) | +| `microsoft365` | Email | MX (derived from domain), SPF, autodiscover (alias: `outlook`) | +| `zoho` | Email | MX, SPF; optional verification and DKIM (region-aware) | +| `mailgun` | Email | SPF, MX, tracking CNAME, optional DKIM (sending subdomain) | +| `resend` | Email | MX, SPF, optional DKIM (SES-backed) | +| `proton` | Email | verification, MX, SPF, optional DKIM CNAMEs | +| `bluesky` | Verification | `_atproto` TXT at the apex or a handle subdomain | +| `dmarc` | Email security | a `_dmarc` policy record | +| `caa` | Email security | restrict which CAs may issue certificates | +| `no-email` | Email security | null MX, `-all` SPF, reject DMARC (domains that never mail) | + | Flag | Description | | ------------- | ------------------------------------- | | `--ttl` | Time to live in seconds | From 0c35033453a6d6b6e59809f35c35d98d3e0f7f78 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 30 Jun 2026 12:08:40 +0100 Subject: [PATCH 3/3] updates --- AGENTS.md | 6 +- README.md | 1 + .../cli/src/commands/dns/record/preset.ts | 150 +++++++++++----- .../src/commands/dns/record/presets.test.ts | 11 ++ .../cli/src/commands/dns/record/presets.ts | 10 +- packages/cli/src/commands/dns/zone/add.ts | 9 +- packages/cli/src/commands/dns/zone/list.ts | 25 ++- .../cli/src/commands/dns/zone/nameservers.ts | 19 ++ packages/cli/src/core/dns-nameservers.ts | 165 +++++++++++++++++- 9 files changed, 332 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac2d511..fae7560 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ bunny-cli/ │ │ ├── client-options.ts # clientOptions() helper — builds ClientOptions from ResolvedConfig │ │ ├── define-command.ts # Command factory (see "Command Pattern" below) │ │ ├── define-namespace.ts # Namespace/group factory for subcommand trees -│ │ ├── dns-nameservers.ts # BUNNY_NAMESERVERS + expectedNameservers(zone) + checkDelegation()/checkDelegations(): live NS lookup of a domain's registrar delegation (ground truth, not bunny's NameserversDetected flag which defaults true on a fresh zone); checkDelegations is bounded-concurrency for the zone list +│ │ ├── dns-nameservers.ts # BUNNY_NAMESERVERS + expectedNameservers(zone) + checkDelegation()/checkDelegations(): reads the parent zone's NS referral (raw UDP query of the registry, not the recursive answer a child host could spoof; falls back to dns.resolveNs when the referral is unreadable), matches the full expected set both ways, ground truth over bunny's NameserversDetected flag which defaults true on a fresh zone; checkDelegations is bounded-concurrency for the zone list │ │ ├── dns-record-types.ts # Canonical DNS record-type name⇄integer map (RECORD_TYPES) + recordTypeLabel(); shared by commands/dns + core/hostnames │ │ ├── errors.ts # Re-exports UserError/ApiError from @bunny.net/openapi-client + ConfigError │ │ ├── format.ts # Shared table/key-value rendering (text, table, csv, markdown) @@ -293,7 +293,7 @@ bunny-cli/ │ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script). Interactive wizard first offers "single record" vs a preset (pickAndApplyPreset); A/AAAA/CNAME/TXT offer static vs script-computed (Scriptable DNS) via pickOrCreateDnsScript: pick or create+seed a DNS script and write a SCRIPT record │ │ │ │ ├── update.ts # Update a record (alias: edit; prompts to pick zone+record when omitted) │ │ │ │ ├── remove.ts # Remove a record (alias: rm; prompts to pick zone+record when omitted) -│ │ │ │ ├── preset.ts # `records preset [name] [domain]` (`list` lists): pick/apply a preset, gather params, summarize, confirm, bulk-write. Exports pickAndApplyPreset reused by add.ts +│ │ │ │ ├── preset.ts # `records preset [name] [domain]` (`list` lists; `--param key=value` repeatable for non-interactive runs): pick/apply a preset, gather params (flags then prompts in text mode), summarize, confirm, bulk-write. `--output json` writes then serializes the result; mid-sequence failures report how many records landed. Exports pickAndApplyPreset reused by add.ts │ │ │ │ ├── presets.ts # Preset catalog (data + pure build fns): google-workspace, microsoft365/outlook, zoho, mailgun, resend, proton, bluesky, dmarc, caa, no-email. findPreset(id|alias) │ │ │ │ ├── presets.test.ts # Tests for the pure preset build fns + alias resolution │ │ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted) @@ -888,7 +888,7 @@ bunny │ │ ├── update [domain] [id] [--name] [--value] [--type] [--ttl] [--priority] [--weight] [--port] [--flags] [--tag] [--comment] [--disabled] [--pull-zone] [--script] │ │ │ Update a DNS record (alias: edit; prompts to pick zone+record when omitted) │ │ ├── remove [domain] [id] [--force] Remove a DNS record (alias: rm; prompts to pick zone+record when omitted) -│ │ ├── preset [name] [domain] Apply a preset record set (`preset list` lists; email providers, verification, security) +│ │ ├── preset [name] [domain] [--param key=value] Apply a preset record set (`preset list` lists; email providers, verification, security; --param repeatable for non-interactive runs) │ │ ├── import [domain] [file] Import records from a BIND zone file (prompts for zone/file when omitted) │ │ └── export [domain] [--file] [--save] Export a zone as a BIND zone file (stdout, --file , or --save → .zone) │ └── zones (canonical; aliases: zone; hidden: domain, domains) diff --git a/README.md b/README.md index d4413e1..117a6ea 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ bun ny dns zones add example.com # create a zone; prints registrar-aw bun ny dns zones nameservers example.com # live-check whether the registrar delegates to bunny bun ny dns records preset list # list DNS record presets (email providers, verification, security) bun ny dns records preset google-workspace example.com # apply a preset record set +bun ny dns records preset bluesky example.com --param did=did:plc:abc123 # apply a preset non-interactively ``` ### Available Scripts diff --git a/packages/cli/src/commands/dns/record/preset.ts b/packages/cli/src/commands/dns/record/preset.ts index e858a74..a4809d4 100644 --- a/packages/cli/src/commands/dns/record/preset.ts +++ b/packages/cli/src/commands/dns/record/preset.ts @@ -15,14 +15,53 @@ import { type DnsPreset, findPreset, PRESETS } from "./presets.ts"; interface PresetArgs { name?: string; domain?: string; + param?: string[]; } -/** Prompt for each parameter a preset needs; optional blanks are dropped. */ +/** Parse repeated `--param key=value` flags into a lookup, trimming blanks. */ +function parseParamFlags(flags: string[] | undefined): Record { + const provided: Record = {}; + for (const entry of flags ?? []) { + const eq = entry.indexOf("="); + if (eq === -1) { + throw new UserError( + `Invalid --param "${entry}".`, + "Use --param key=value (repeat for multiple values).", + ); + } + const key = entry.slice(0, eq).trim(); + const value = entry.slice(eq + 1).trim(); + if (key) provided[key] = value; + } + return provided; +} + +/** + * Resolve each parameter a preset needs from provided flags, prompting for the + * rest only when interactive. Non-interactive callers must supply required + * values via flags; optional blanks are dropped. + */ async function gatherParams( preset: DnsPreset, + provided: Record, + interactive: boolean, ): Promise> { const params: Record = {}; for (const param of preset.params) { + const supplied = provided[param.key]?.trim(); + if (supplied) { + params[param.key] = supplied; + continue; + } + if (!interactive) { + if (!param.optional) { + throw new UserError( + `Preset "${preset.id}" needs a value for "${param.key}".`, + `Pass --param ${param.key}= (${param.message}).`, + ); + } + continue; + } const { value } = await prompts({ type: "text", name: "value", @@ -41,62 +80,75 @@ async function gatherParams( return params; } -/** Expand a preset, show what it will add, confirm, then write each record. */ +/** Expand a preset, confirm in text mode, then write each record. */ export async function applyPreset(opts: { client: CoreClient; zone: DnsZoneModel; preset: DnsPreset; output: string; + provided?: Record; }): Promise { - const { client, zone, preset, output } = opts; - const params = await gatherParams(preset); + const { client, zone, preset, output, provided = {} } = opts; + // Only plain text drives the interactive prompts; json/csv/... must come fully specified. + const interactive = output === "text"; + const params = await gatherParams(preset, provided, interactive); const records = preset.build({ domain: zone.Domain ?? "", params }); if (records.length === 0) { throw new UserError("This preset produced no records to add."); } - if (output === "json") { - logger.log(JSON.stringify(records, null, 2)); - return; - } + if (interactive) { + logger.log(`This will add ${records.length} record(s) to ${zone.Domain}:`); + logger.log(""); + logger.log( + formatTable( + ["Type", "Name", "Value", "Priority"], + records.map((r) => [ + recordTypeLabel(r.Type), + recordName(r.Name), + r.Value ?? "", + r.Priority != null ? String(r.Priority) : "", + ]), + "text", + ), + ); + logger.log(""); - logger.log(`This will add ${records.length} record(s) to ${zone.Domain}:`); - logger.log(""); - logger.log( - formatTable( - ["Type", "Name", "Value", "Priority"], - records.map((r) => [ - recordTypeLabel(r.Type), - recordName(r.Name), - r.Value ?? "", - r.Priority != null ? String(r.Priority) : "", - ]), - "text", - ), - ); - logger.log(""); - - if ( - !(await confirm(`Add these ${records.length} record(s)?`, { - initial: true, - })) - ) { - logger.info("Cancelled."); - return; + if ( + !(await confirm(`Add these ${records.length} record(s)?`, { + initial: true, + })) + ) { + logger.info("Cancelled."); + return; + } } - const spin = spinner(`Applying ${preset.title}...`); - spin.start(); + const spin = interactive ? spinner(`Applying ${preset.title}...`) : null; + spin?.start(); + let applied = 0; try { for (const record of records) { await client.PUT("/dnszone/{zoneId}/records", { params: { path: { zoneId: zone.Id as number } }, body: record, }); + applied++; } + } catch (err) { + // Records are written one by one with no rollback; tell the user how far it got so they can inspect the zone. + throw new UserError( + `Applied ${applied} of ${records.length} record(s) before failing; ${zone.Domain} may be partially configured.`, + err instanceof Error ? err.message : String(err), + ); } finally { - spin.stop(); + spin?.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(records, null, 2)); + return; } logger.success( @@ -125,8 +177,9 @@ export async function pickAndApplyPreset(opts: { zone: DnsZoneModel; output: string; name?: string; + provided?: Record; }): Promise { - const { client, zone, output, name } = opts; + const { client, zone, output, name, provided } = opts; const preset = name ? findPreset(name) : await pickPreset(); if (!preset) { if (name) { @@ -137,7 +190,7 @@ export async function pickAndApplyPreset(opts: { } return; } - await applyPreset({ client, zone, preset, output }); + await applyPreset({ client, zone, preset, output, provided }); } export const dnsPresetCommand = defineCommand({ @@ -150,6 +203,10 @@ export const dnsPresetCommand = defineCommand({ "$0 dns records preset google-workspace example.com", "Apply a named preset", ], + [ + "$0 dns records preset bluesky example.com --param did=did:plc:abc123", + "Apply a preset non-interactively", + ], ["$0 dns records preset list", "List available presets"], ], @@ -159,9 +216,23 @@ export const dnsPresetCommand = defineCommand({ type: "string", describe: "Preset id (omit to pick interactively, or 'list' to list)", }) - .positional("domain", { type: "string", describe: "Domain or zone ID" }), + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("param", { + type: "string", + array: true, + describe: + "Preset value as key=value (repeatable), e.g. --param did=did:plc:...", + }), - handler: async ({ name, domain, profile, output, verbose, apiKey }) => { + handler: async ({ + name, + domain, + param, + profile, + output, + verbose, + apiKey, + }) => { if (name === "list") { if (output === "json") { logger.log( @@ -196,6 +267,7 @@ export const dnsPresetCommand = defineCommand({ offerLink: true, }); - await pickAndApplyPreset({ client, zone, output, name }); + const provided = parseParamFlags(param); + await pickAndApplyPreset({ client, zone, output, name, provided }); }, }); diff --git a/packages/cli/src/commands/dns/record/presets.test.ts b/packages/cli/src/commands/dns/record/presets.test.ts index d9fdc12..bbf9fac 100644 --- a/packages/cli/src/commands/dns/record/presets.test.ts +++ b/packages/cli/src/commands/dns/record/presets.test.ts @@ -71,6 +71,17 @@ describe("DNS presets", () => { ).toBe("_atproto.alice"); }); + test("resend puts MX, SPF, and DKIM under the sending subdomain", () => { + const recs = build("resend", { dkim: "v=DKIM1; p=xyz" }); + expect(recs.find((r) => r.Type === RECORD_TYPES.MX)?.Name).toBe("send"); + expect(recs.find((r) => r.Name === "resend._domainkey.send")?.Value).toBe( + "v=DKIM1; p=xyz", + ); + + const custom = build("resend", { sub: "mail", dkim: "v=DKIM1; p=xyz" }); + expect(custom.some((r) => r.Name === "resend._domainkey.mail")).toBe(true); + }); + test("no-email uses a null MX and a reject DMARC", () => { const recs = build("no-email"); expect(recs.find((r) => r.Type === RECORD_TYPES.MX)?.Value).toBe("."); diff --git a/packages/cli/src/commands/dns/record/presets.ts b/packages/cli/src/commands/dns/record/presets.ts index 62f7de6..3ad3d8c 100644 --- a/packages/cli/src/commands/dns/record/presets.ts +++ b/packages/cli/src/commands/dns/record/presets.ts @@ -174,7 +174,11 @@ export const PRESETS: DnsPreset[] = [ params: [ { key: "sub", message: "Sending subdomain", initial: "send" }, { key: "region", message: "Resend/SES region", initial: "us-east-1" }, - { key: "dkim", message: "resend._domainkey TXT value", optional: true }, + { + key: "dkim", + message: "resend._domainkey. TXT value", + optional: true, + }, ], build: ({ params }) => { const sub = params.sub || "send"; @@ -183,7 +187,9 @@ export const PRESETS: DnsPreset[] = [ mx(sub, `feedback-smtp.${region}.amazonses.com`, 10), txt(sub, "v=spf1 include:amazonses.com ~all"), ]; - if (params.dkim) records.push(txt("resend._domainkey", params.dkim)); + // Resend expects the DKIM host under the sending subdomain (resend._domainkey.send), not the apex. + if (params.dkim) + records.push(txt(`resend._domainkey.${sub}`, params.dkim)); return records; }, }, diff --git a/packages/cli/src/commands/dns/zone/add.ts b/packages/cli/src/commands/dns/zone/add.ts index a77e98c..e136849 100644 --- a/packages/cli/src/commands/dns/zone/add.ts +++ b/packages/cli/src/commands/dns/zone/add.ts @@ -3,7 +3,6 @@ import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { - BUNNY_NAMESERVERS, checkDelegation, expectedNameservers, } from "../../../core/dns-nameservers.ts"; @@ -66,12 +65,10 @@ export const dnsZoneAddCommand = defineCommand({ // Savvy users often point the registrar at bunny before creating the zone; skip the setup steps when it's already delegated. const checkSpin = spinner("Checking nameserver delegation..."); checkSpin.start(); + const nameservers = expectedNameservers(created ?? {}); let delegated: boolean; try { - const { status } = await checkDelegation( - domain, - expectedNameservers(created ?? {}), - ); + const { status } = await checkDelegation(domain, nameservers); delegated = status === "bunny"; } finally { checkSpin.stop(); @@ -90,7 +87,7 @@ export const dnsZoneAddCommand = defineCommand({ `Now update your nameservers at ${registrar ?? "your domain registrar"} to:`, ); logger.log(""); - for (const ns of BUNNY_NAMESERVERS) logger.log(` ${ns}`); + for (const ns of nameservers) logger.log(` ${ns}`); logger.log(""); logger.dim( `Propagation can take up to 48 hours. Verify with:\n bunny dns zones ns ${domain}`, diff --git a/packages/cli/src/commands/dns/zone/list.ts b/packages/cli/src/commands/dns/zone/list.ts index db9a2fd..693880b 100644 --- a/packages/cli/src/commands/dns/zone/list.ts +++ b/packages/cli/src/commands/dns/zone/list.ts @@ -4,6 +4,7 @@ import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { checkDelegations, + type DelegationCheck, type DelegationStatus, expectedNameservers, } from "../../../core/dns-nameservers.ts"; @@ -41,29 +42,37 @@ export const dnsZoneListCommand = defineCommand({ } // bunny's NameserversDetected defaults to true on a fresh zone; resolve the real delegation live. - let delegation: DelegationStatus[] = []; + let checks: DelegationCheck[] = []; if (zones.length > 0) { const checkSpin = spinner("Checking nameserver delegation..."); checkSpin.start(); try { - const checks = await checkDelegations( + checks = await checkDelegations( zones.map((z) => ({ domain: z.Domain ?? "", expected: expectedNameservers(z), })), ); - delegation = checks.map((c) => c.status); } finally { checkSpin.stop(); } } + const delegation: DelegationStatus[] = checks.map((c) => c.status); if (output === "json") { - // Overwrite the unreliable API flag with the live result so scripts read the true value. - const corrected = zones.map((z, i) => ({ - ...z, - NameserversDetected: delegation[i] === "bunny", - })); + const corrected = zones.map((z, i) => { + const check = checks[i]; + return { + ...z, + // Trust the live result only when conclusive; on "unknown" keep the API flag so a transient resolver failure doesn't flip every zone to pending. + NameserversDetected: + check && check.status !== "unknown" + ? check.status === "bunny" + : z.NameserversDetected, + NameserversDelegation: check?.status ?? "unknown", + NameserversResolved: check?.resolved ?? [], + }; + }); logger.log(JSON.stringify(corrected, null, 2)); return; } diff --git a/packages/cli/src/commands/dns/zone/nameservers.ts b/packages/cli/src/commands/dns/zone/nameservers.ts index 89621bb..29200f4 100644 --- a/packages/cli/src/commands/dns/zone/nameservers.ts +++ b/packages/cli/src/commands/dns/zone/nameservers.ts @@ -6,6 +6,7 @@ import { checkDelegation, expectedNameservers, } from "../../../core/dns-nameservers.ts"; +import { formatKeyValue } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; import { detectRegistrar } from "../../../core/registrar.ts"; import { resolveZoneInteractive } from "../interactive.ts"; @@ -66,6 +67,24 @@ export const dnsNameserversCommand = defineCommand({ return; } + // csv/table/markdown stay machine-readable; only plain text gets the prose guidance. + if (output !== "text") { + logger.log( + formatKeyValue( + [ + { key: "Domain", value: zoneDomain }, + { key: "Custom", value: custom ? "Yes" : "No" }, + { key: "Detected", value: detected ? "Yes" : "No" }, + { key: "Status", value: status }, + { key: "Resolved", value: resolved.join(" ") }, + { key: "Nameservers", value: nameservers.join(" ") }, + ], + output, + ), + ); + return; + } + if (detected) { logger.success( `Nameservers detected and pointing to Bunny DNS for ${zoneDomain}.`, diff --git a/packages/cli/src/core/dns-nameservers.ts b/packages/cli/src/core/dns-nameservers.ts index 0671361..effe087 100644 --- a/packages/cli/src/core/dns-nameservers.ts +++ b/packages/cli/src/core/dns-nameservers.ts @@ -1,3 +1,4 @@ +import dgram from "node:dgram"; import { promises as dns } from "node:dns"; /** bunny.net delegates every zone to the same two anycast nameservers. */ @@ -8,6 +9,143 @@ function normalize(ns: string): string { return ns.trim().toLowerCase().replace(/\.$/, ""); } +/** Encode a domain into DNS wire-format labels terminated by a root byte. */ +function encodeName(domain: string): Buffer { + const labels = domain.replace(/\.$/, "").split("."); + const parts = labels.map((label) => { + const bytes = Buffer.from(label, "ascii"); + return Buffer.concat([Buffer.from([bytes.length]), bytes]); + }); + return Buffer.concat([...parts, Buffer.from([0])]); +} + +/** Read a (possibly compressed) name; returns the name and the offset past it. */ +function decodeName(buf: Buffer, start: number): { name: string; end: number } { + const labels: string[] = []; + let pos = start; + let end = start; + let jumped = false; + while (pos < buf.length) { + const len = buf[pos] ?? 0; + if (len === 0) { + if (!jumped) end = pos + 1; + break; + } + if ((len & 0xc0) === 0xc0) { + const pointer = ((len & 0x3f) << 8) | (buf[pos + 1] ?? 0); + if (!jumped) end = pos + 2; + pos = pointer; + jumped = true; + continue; + } + labels.push(buf.toString("ascii", pos + 1, pos + 1 + len)); + pos += 1 + len; + } + return { name: labels.join("."), end }; +} + +/** Build a non-recursive NS query (with an EDNS0 OPT for larger referrals). */ +function buildNsQuery(domain: string): Buffer { + const header = Buffer.alloc(12); + header.writeUInt16BE(Math.floor(Math.random() * 0xffff), 0); + header.writeUInt16BE(1, 4); + header.writeUInt16BE(1, 10); + const question = Buffer.concat([encodeName(domain), Buffer.alloc(4)]); + question.writeUInt16BE(2, question.length - 4); + question.writeUInt16BE(1, question.length - 2); + const opt = Buffer.from([0, 0, 0x29, 0x10, 0, 0, 0, 0, 0, 0, 0]); + return Buffer.concat([header, question, opt]); +} + +/** Pull NS records for `domain` from a DNS message's answer and authority sections. */ +function parseNsReferral(buf: Buffer, domain: string): string[] { + const counts = [6, 8].map((o) => buf.readUInt16BE(o)); + let pos = 12; + const questions = buf.readUInt16BE(4); + for (let i = 0; i < questions; i++) pos = decodeName(buf, pos).end + 4; + const want = normalize(domain); + const out: string[] = []; + const records = (counts[0] ?? 0) + (counts[1] ?? 0); + for (let i = 0; i < records && pos + 10 <= buf.length; i++) { + const owner = decodeName(buf, pos); + pos = owner.end; + const type = buf.readUInt16BE(pos); + const rdLength = buf.readUInt16BE(pos + 8); + const rdStart = pos + 10; + if (type === 2 && normalize(owner.name) === want) { + out.push(decodeName(buf, rdStart).name); + } + pos = rdStart + rdLength; + } + return out; +} + +/** Send an NS query to one authoritative server; null on timeout/error/truncation. */ +function queryNs( + server: string, + domain: string, + timeoutMs = 3000, +): Promise { + return new Promise((resolve) => { + const socket = dgram.createSocket(server.includes(":") ? "udp6" : "udp4"); + let settled = false; + const finish = (value: string[] | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + socket.close(); + } catch {} + resolve(value); + }; + const timer = setTimeout(() => finish(null), timeoutMs); + socket.on("error", () => finish(null)); + socket.on("message", (msg) => { + try { + if (msg.readUInt16BE(2) & 0x0200) return finish(null); + finish(parseNsReferral(msg, domain)); + } catch { + finish(null); + } + }); + socket.send(buildNsQuery(domain), 53, server, (err) => { + if (err) finish(null); + }); + }); +} + +/** + * Read the delegation the parent zone publishes for `domain`, which reflects the + * registrar setup rather than NS the current host might answer for itself. + * Returns null when the parent referral can't be read (caller falls back). + */ +async function resolveParentDelegation( + domain: string, +): Promise { + const labels = domain.replace(/\.$/, "").split("."); + if (labels.length < 2) return null; + const parent = labels.slice(1).join("."); + let parentNs: string[]; + try { + parentNs = await dns.resolveNs(parent); + } catch { + return null; + } + for (const host of parentNs.slice(0, 3)) { + let ips: string[]; + try { + ips = await dns.resolve4(host); + } catch { + continue; + } + for (const ip of ips.slice(0, 2)) { + const ns = await queryNs(ip, domain); + if (ns !== null) return ns.map(normalize); + } + } + return null; +} + /** "bunny" = fully delegated to the expected set; "other" = points elsewhere; "unknown" = couldn't resolve. */ export type DelegationStatus = "bunny" | "other" | "unknown"; @@ -18,9 +156,11 @@ export interface DelegationCheck { } /** - * Resolve a domain's authoritative NS records and compare them against the - * expected nameservers. This reads the real registrar delegation rather than - * bunny's NameserversDetected flag, which defaults to true on a fresh zone. + * Compare a domain's delegation against the expected nameservers. Prefers the + * parent zone's referral (the registrar delegation) over the recursive answer, + * which the current DNS host could otherwise spoof with bunny NS records. This + * is the source of truth, not bunny's NameserversDetected flag (true on a fresh + * zone). */ export async function checkDelegation( domain: string, @@ -28,9 +168,22 @@ export async function checkDelegation( ): Promise { const want = new Set(expected.map(normalize)); try { - const resolved = (await dns.resolveNs(domain)).map(normalize).sort(); - if (resolved.length === 0) return { status: "unknown", resolved }; - const status = resolved.every((ns) => want.has(ns)) ? "bunny" : "other"; + const parent = await resolveParentDelegation(domain); + // Fall back to the recursive answer only when the parent referral is unreadable. + const resolved = ( + parent ?? (await dns.resolveNs(domain)).map(normalize) + ).sort(); + if (resolved.length === 0) { + // Empty parent referral is a definite "not delegated to bunny"; an empty recursive answer is inconclusive. + return { status: parent ? "other" : "unknown", resolved }; + } + // Match the full expected set both ways so a partial pair (only kiki, missing coco) stays pending. + const have = new Set(resolved); + const status = + resolved.every((ns) => want.has(ns)) && + [...want].every((ns) => have.has(ns)) + ? "bunny" + : "other"; return { status, resolved }; } catch { // NXDOMAIN, SERVFAIL, no NS published yet, or no network: can't tell.