diff --git a/.changeset/thirty-parrots-agree.md b/.changeset/thirty-parrots-agree.md new file mode 100644 index 0000000..ccaa762 --- /dev/null +++ b/.changeset/thirty-parrots-agree.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/openapi-client": patch +--- + +fix: let legitimate non-JSON 200 responses pass the proxy-interception guard diff --git a/.changeset/twelve-peaches-remain.md b/.changeset/twelve-peaches-remain.md new file mode 100644 index 0000000..a6f345b --- /dev/null +++ b/.changeset/twelve-peaches-remain.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(dns): add experimental `bunny dns` commands for managing DNS zones and records diff --git a/AGENTS.md b/AGENTS.md index 6ea5301..08bb89b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,6 +256,35 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("tokens", ...) — registers token commands │ │ │ ├── create.ts # Generate an auth token (read-only/full-access, optional expiry) │ │ │ └── invalidate.ts # Invalidate all tokens for a database (with confirmation) +│ │ ├── dns/ # Experimental — hidden from help and landing page +│ │ │ ├── index.ts # defineNamespace("dns", ...) — registers the record + zone groups (+ hidden domain aliases) +│ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone) +│ │ │ ├── interactive.ts # resolveZoneInteractive (zone picker) + resolveRecordInteractive (record picker) fallbacks +│ │ │ ├── record-types.ts # Record type name⇄integer map, parseRecordType, recordTypeLabel, formatRecordValue +│ │ │ ├── record/ # `dns record` — entries within a zone (aliases: records, rec) +│ │ │ │ ├── index.ts # defineNamespace("record", ...) +│ │ │ │ ├── list.ts # List records in a zone (alias: ls) +│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script) +│ │ │ │ ├── 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) +│ │ │ │ ├── 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 zone` — the zone itself (aliases: zones; hidden: domain, domains) +│ │ │ ├── index.ts # defineNamespace("zone", ...) + dnsZoneHiddenAliases (domain/domains) +│ │ │ ├── list.ts # List all DNS zones (alias: ls) +│ │ │ ├── add.ts # Create a DNS zone +│ │ │ ├── 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) +│ │ │ ├── dnssec/ +│ │ │ │ ├── index.ts # defineNamespace("dnssec", ...) +│ │ │ │ ├── enable.ts # Enable DNSSEC, print DS record for the registrar +│ │ │ │ └── disable.ts # Disable DNSSEC (with confirmation) +│ │ │ └── logging/ +│ │ │ ├── index.ts # defineNamespace("logging", ...) +│ │ │ ├── enable.ts # Enable DNS query logging (optional IP anonymization) +│ │ │ └── disable.ts # Disable DNS query logging (with confirmation) │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -794,6 +823,32 @@ bunny │ ├── update [--name] [--username] [--password] │ │ Update registry name and/or rotate credentials │ └── remove Remove registry +├── dns (experimental — hidden from help and landing page) +│ │ Two resource groups: `record` (entries in a zone) and `zone` (the zone itself). +│ │ Every [domain] is optional — omit it to pick a zone interactively (resolveZoneInteractive). +│ ├── record (aliases: records, rec) +│ │ ├── list [domain] (alias: ls) List the records within a zone +│ │ ├── add [domain] [name] [type] [values..] [--ttl] [--comment] [--pull-zone] [--script] +│ │ │ Add a DNS record (interactive wizard when args omitted; MX/SRV/CAA use positional values; PullZone/Script use --pull-zone/--script) +│ │ ├── 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) +│ │ ├── 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) +│ └── zone (aliases: zones; hidden: domain, domains) +│ ├── list List all DNS zones (alias: ls) +│ ├── add Create a DNS zone +│ ├── 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) +│ ├── dnssec +│ │ ├── enable [domain] Enable DNSSEC and print the DS record for the registrar +│ │ └── disable [domain] [--force] Disable DNSSEC +│ └── logging +│ ├── enable [domain] [--anonymize-ip] [--anonymization onedigit|drop] +│ │ Enable DNS query logging +│ └── disable [domain] [--force] Disable DNS query logging ├── db │ ├── create [--name] [--primary] [--replicas] [--storage-region] │ │ Create a new database diff --git a/packages/cli/README.md b/packages/cli/README.md index e30c3aa..112d9ac 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -394,6 +394,78 @@ bunny registries add --name "GitHub" --username myorg bunny registries remove ``` +### `bunny dns` + +> **Experimental** — hidden from `--help` and the landing page while it stabilizes. + +Manage DNS through two resource groups: **`bunny dns record`** (the entries within a zone) and **`bunny dns zone`** (the zone itself — settings, DNSSEC, logging, stats, nameservers). The `[domain]` argument accepts either the zone's domain name or its numeric zone ID, and is optional everywhere — omit it and you'll be prompted to pick a zone. `record update`/`record remove` likewise prompt you to pick a record when the ID is omitted. `record` aliases to `records`/`rec`; `zone` aliases to `zones` (and `domain`/`domains`). + +```bash +# Records — list within a zone +bunny dns record list example.com +bunny dns rec ls example.com + +# Add records (use '@' for the zone apex) +bunny dns record add example.com api A 198.51.100.1 +bunny dns record add example.com '@' MX mail.example.com 10 +bunny dns record add example.com '@' SRV 10 0 389 sip.example.com +bunny dns record add example.com '@' CAA '0 issue "letsencrypt.org"' + +# Link a record to a pull zone or Edge Script +bunny dns record add example.com cdn PullZone --pull-zone 12345 +bunny dns record add example.com fn Script --script 67890 + +# Interactive wizard — omit the record type (or all args) to be prompted +bunny dns record add +bunny dns record add example.com + +# Update / remove a record by its ID +bunny dns record update example.com 123 --value 198.51.100.2 --ttl 3600 +bunny dns record remove example.com 123 + +# Import / export a BIND zone file +bunny dns record import example.com ./zonefile.txt +bunny dns record export example.com # print to stdout +bunny dns record export example.com --file ./my.zone # write to a path +bunny dns record export example.com --save # write to ./example.com.zone + +# Zones — lifecycle +bunny dns zone list +bunny dns zone add example.com +bunny dns zone show example.com +bunny dns zone remove example.com + +# Query statistics (defaults to the last 30 days; text mode draws a bar chart) +bunny dns zone stats example.com +bunny dns zone stats example.com --from 2026-05-01 --to 2026-05-31 + +# Nameservers to set at your registrar (custom if enabled, else bunny.net defaults) +bunny dns zone nameservers example.com +bunny dns zone ns example.com + +# DNSSEC — enable prints the DS record to register at your domain registrar +bunny dns zone dnssec enable example.com +bunny dns zone dnssec disable example.com + +# DNS query logging — enable to start collecting logs (optionally anonymize IPs) +bunny dns zone logging enable example.com +bunny dns zone logging enable example.com --anonymize-ip --anonymization drop +bunny dns zone logging disable example.com +``` + +Positional value ordering for `record add` follows the record type: `A`/`AAAA`/`CNAME`/`TXT`/`NS` take a single value, `MX` takes ` `, `SRV` takes ` `, and `CAA` takes a single quoted `' ""'` string. `PullZone` and `Script` records take no positional value — pass `--pull-zone ` or `--script ` instead. Omit the record type (or all arguments) to run an interactive wizard that prompts for the zone, type, and per-type values. + +| Flag | Commands | Description | +| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `--ttl` | `record add`, `record update` | Time to live in seconds | +| `--comment` | `record add`, `record update` | Optional comment for the record | +| `--pull-zone`, `--script` | `record add`, `record update` | Link a `PullZone` / `Script` record by ID | +| `--name`, `--value`, `--type`, `--priority`, `--weight`, `--port`, `--flags`, `--tag`, `--disabled` | `record update` | Edit individual record fields (see `bunny dns record update --help`) | +| `--file`, `--save` | `record export` | Write to a path, or to `.zone` in the current directory | +| `--from`, `--to` | `zone stats` | Date range (defaults to the last 30 days) | +| `--anonymize-ip`, `--anonymization` | `zone logging enable` | Anonymize client IPs in logs (`onedigit` \| `drop`) | +| `--force` | `record remove`, `zone remove`, `zone dnssec disable`, `zone logging disable` | Skip the confirmation prompt | + ### `bunny scripts` Manage Edge Scripts. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 16e0279..784bbdf 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,6 +8,7 @@ import { authLoginCommand } from "./commands/auth/login.ts"; import { authLogoutCommand } from "./commands/auth/logout.ts"; import { configNamespace } from "./commands/config/index.ts"; import { dbNamespace } from "./commands/db/index.ts"; +import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; import { registriesNamespace } from "./commands/registries/index.ts"; @@ -33,6 +34,7 @@ const commands: CommandModule[] = [ const experimentalCommands: CommandModule[] = [ appsNamespace, registriesNamespace, + dnsNamespace, ]; let instance = yargs(hideBin(process.argv)) diff --git a/packages/cli/src/commands/dns/api.ts b/packages/cli/src/commands/dns/api.ts new file mode 100644 index 0000000..97d59f7 --- /dev/null +++ b/packages/cli/src/commands/dns/api.ts @@ -0,0 +1,64 @@ +import type { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { UserError } from "../../core/errors.ts"; + +export type CoreClient = ReturnType; +export type DnsZoneModel = components["schemas"]["DnsZoneModel"]; +export type DnsRecordModel = components["schemas"]["DnsRecordModel"]; + +/** Fetch all DNS zones on the account, paginated and sorted by domain. */ +export async function fetchZones(client: CoreClient): Promise { + const zones: DnsZoneModel[] = []; + let page = 1; + for (;;) { + const { data } = await client.GET("/dnszone", { + params: { query: { page, perPage: 1000 } }, + }); + zones.push(...(data?.Items ?? [])); + if (!data?.HasMoreItems) break; + page++; + } + return zones.sort((a, b) => (a.Domain ?? "").localeCompare(b.Domain ?? "")); +} + +/** Fetch a single DNS zone (including its records) by ID. */ +export async function fetchZone( + client: CoreClient, + id: number, +): Promise { + const { data } = await client.GET("/dnszone/{id}", { + params: { path: { id } }, + }); + if (!data) throw new UserError(`DNS zone ${id} not found.`); + return data; +} + +/** + * Resolve a zone reference (numeric ID or domain name) to a full zone. + * + * Numeric input is treated as a zone ID; anything else is matched against + * the account's zones by domain name. + */ +export async function resolveZone( + client: CoreClient, + domainOrId: string, +): Promise { + const ref = domainOrId.trim(); + if (!ref) throw new UserError("A domain or zone ID is required."); + + if (/^\d+$/.test(ref)) return fetchZone(client, Number(ref)); + + const { data } = await client.GET("/dnszone", { + params: { query: { search: ref, perPage: 1000 } }, + }); + const match = (data?.Items ?? []).find( + (z) => (z.Domain ?? "").toLowerCase() === ref.toLowerCase(), + ); + if (!match?.Id) { + throw new UserError( + `No DNS zone found for "${domainOrId}".`, + 'Run "bunny dns zone list" to see your zones.', + ); + } + return fetchZone(client, match.Id); +} diff --git a/packages/cli/src/commands/dns/index.ts b/packages/cli/src/commands/dns/index.ts new file mode 100644 index 0000000..131b52a --- /dev/null +++ b/packages/cli/src/commands/dns/index.ts @@ -0,0 +1,10 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { dnsRecordNamespace } from "./record/index.ts"; +import { dnsZoneHiddenAliases, dnsZoneNamespace } from "./zone/index.ts"; + +// Hidden from help while experimental, matching the apps and registries namespaces. +export const dnsNamespace = defineNamespace("dns", false, [ + dnsRecordNamespace, + dnsZoneNamespace, + ...dnsZoneHiddenAliases, +]); diff --git a/packages/cli/src/commands/dns/interactive.ts b/packages/cli/src/commands/dns/interactive.ts new file mode 100644 index 0000000..edb9952 --- /dev/null +++ b/packages/cli/src/commands/dns/interactive.ts @@ -0,0 +1,102 @@ +import prompts from "prompts"; +import { UserError } from "../../core/errors.ts"; +import { spinner } from "../../core/ui.ts"; +import { + type CoreClient, + type DnsRecordModel, + type DnsZoneModel, + fetchZone, + fetchZones, + resolveZone, +} from "./api.ts"; +import { + formatRecordValue, + recordName, + recordTypeLabel, +} from "./record-types.ts"; + +/** + * Resolve a zone by name/ID, or prompt the user to pick one when no + * reference is given. Manages its own spinner so it never spins over a prompt. + */ +export async function resolveZoneInteractive( + client: CoreClient, + ref: string | undefined, +): Promise { + if (ref) { + const spin = spinner("Resolving zone..."); + spin.start(); + try { + return await resolveZone(client, ref); + } finally { + spin.stop(); + } + } + + const spin = spinner("Fetching zones..."); + spin.start(); + let zones: DnsZoneModel[]; + try { + zones = await fetchZones(client); + } finally { + spin.stop(); + } + + if (zones.length === 0) { + throw new UserError( + "No DNS zones found.", + 'Create one with "bunny dns zone add ".', + ); + } + + const { id } = await prompts({ + type: "select", + name: "id", + message: "Zone:", + choices: zones.map((z) => ({ title: z.Domain ?? "", value: z.Id })), + }); + if (id === undefined) throw new UserError("A zone is required."); + + const resolveSpin = spinner("Loading zone..."); + resolveSpin.start(); + try { + return await fetchZone(client, id); + } finally { + resolveSpin.stop(); + } +} + +/** + * Return the record matching `id`, or prompt the user to pick one from the + * zone when no ID is given. + */ +export async function resolveRecordInteractive( + zone: DnsZoneModel, + id: number | undefined, + action: string, +): Promise { + const records = zone.Records ?? []; + + if (id !== undefined) { + const match = records.find((r) => r.Id === id); + if (!match) + throw new UserError(`Record ${id} not found in ${zone.Domain}.`); + return match; + } + + if (records.length === 0) { + throw new UserError(`No records in ${zone.Domain}.`); + } + + const { record } = await prompts({ + type: "select", + name: "record", + message: `Record to ${action}:`, + choices: records.map((r) => ({ + title: `${recordTypeLabel(r.Type)} ${recordName(r.Name)} → ${formatRecordValue(r)}`, + value: r, + })), + }); + if (!record) throw new UserError("A record is required."); + return record; +} diff --git a/packages/cli/src/commands/dns/query-types.ts b/packages/cli/src/commands/dns/query-types.ts new file mode 100644 index 0000000..84f2486 --- /dev/null +++ b/packages/cli/src/commands/dns/query-types.ts @@ -0,0 +1,66 @@ +// IANA DNS resource-record (RR) TYPE codes — the on-the-wire query types +// surfaced by DnsZoneStatisticsModel.QueriesByTypeChart. These are the +// standard registry values, NOT the bunny DnsRecordTypes management enum +// (0–15): https://www.iana.org/assignments/dns-parameters +const DNS_QUERY_TYPES: Record = { + 1: "A", + 2: "NS", + 5: "CNAME", + 6: "SOA", + 12: "PTR", + 13: "HINFO", + 15: "MX", + 16: "TXT", + 17: "RP", + 18: "AFSDB", + 24: "SIG", + 25: "KEY", + 28: "AAAA", + 29: "LOC", + 33: "SRV", + 35: "NAPTR", + 36: "KX", + 37: "CERT", + 39: "DNAME", + 43: "DS", + 44: "SSHFP", + 45: "IPSECKEY", + 46: "RRSIG", + 47: "NSEC", + 48: "DNSKEY", + 49: "DHCID", + 50: "NSEC3", + 51: "NSEC3PARAM", + 52: "TLSA", + 53: "SMIMEA", + 55: "HIP", + 59: "CDS", + 60: "CDNSKEY", + 61: "OPENPGPKEY", + 62: "CSYNC", + 63: "ZONEMD", + 64: "SVCB", + 65: "HTTPS", + 99: "SPF", + 108: "EUI48", + 109: "EUI64", + 249: "TKEY", + 250: "TSIG", + 251: "IXFR", + 252: "AXFR", + 255: "ANY", + 256: "URI", + 257: "CAA", + 32768: "TA", + 32769: "DLV", +}; + +/** + * Human label for an IANA DNS query-type code. Unknown codes fall back to the + * RFC 3597 `TYPEn` presentation (e.g. `TYPE42`). + */ +export function queryTypeLabel(code: string | number): string { + const n = typeof code === "number" ? code : Number.parseInt(code, 10); + if (Number.isNaN(n)) return String(code); + return DNS_QUERY_TYPES[n] ?? `TYPE${n}`; +} diff --git a/packages/cli/src/commands/dns/record-types.test.ts b/packages/cli/src/commands/dns/record-types.test.ts new file mode 100644 index 0000000..fcdbf9c --- /dev/null +++ b/packages/cli/src/commands/dns/record-types.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { + formatRecordValue, + parseRecordType, + RECORD_TYPES, + recordName, + recordTypeLabel, +} from "./record-types.ts"; + +describe("parseRecordType", () => { + test("parses known types case-insensitively", () => { + expect(parseRecordType("A")).toBe(RECORD_TYPES.A); + expect(parseRecordType("cname")).toBe(RECORD_TYPES.CNAME); + expect(parseRecordType(" Mx ")).toBe(RECORD_TYPES.MX); + }); + + test("throws on an unknown type", () => { + expect(() => parseRecordType("BOGUS")).toThrow(/Unknown record type/); + }); +}); + +describe("recordTypeLabel", () => { + test("maps enum values back to names", () => { + expect(recordTypeLabel(RECORD_TYPES.A)).toBe("A"); + expect(recordTypeLabel(RECORD_TYPES.CAA)).toBe("CAA"); + }); + + test("falls back to UNKNOWN", () => { + expect(recordTypeLabel(999)).toBe("UNKNOWN"); + expect(recordTypeLabel(null)).toBe("UNKNOWN"); + }); +}); + +describe("recordName", () => { + test("shows @ for the apex", () => { + expect(recordName("")).toBe("@"); + expect(recordName(null)).toBe("@"); + expect(recordName("api")).toBe("api"); + }); +}); + +describe("formatRecordValue", () => { + test("plain value for A", () => { + expect( + formatRecordValue({ Type: RECORD_TYPES.A, Value: "198.51.100.1" }), + ).toBe("198.51.100.1"); + }); + + test("priority prefix for MX", () => { + expect( + formatRecordValue({ + Type: RECORD_TYPES.MX, + Value: "mail.example.com", + Priority: 10, + }), + ).toBe("10 mail.example.com"); + }); + + test("priority/weight/port for SRV", () => { + expect( + formatRecordValue({ + Type: RECORD_TYPES.SRV, + Value: "sip.example.com", + Priority: 10, + Weight: 0, + Port: 389, + }), + ).toBe("10 0 389 sip.example.com"); + }); + + test("flags/tag/quoted value for CAA", () => { + expect( + formatRecordValue({ + Type: RECORD_TYPES.CAA, + Value: "letsencrypt.org", + Flags: 0, + Tag: "issue", + }), + ).toBe('0 issue "letsencrypt.org"'); + }); +}); diff --git a/packages/cli/src/commands/dns/record-types.ts b/packages/cli/src/commands/dns/record-types.ts new file mode 100644 index 0000000..bae1a3b --- /dev/null +++ b/packages/cli/src/commands/dns/record-types.ts @@ -0,0 +1,67 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { UserError } from "../../core/errors.ts"; + +export type DnsRecordTypes = components["schemas"]["DnsRecordTypes"]; +export type DnsRecordModel = components["schemas"]["DnsRecordModel"]; + +/** Record type name → bunny.net integer enum value. */ +export const RECORD_TYPES = { + A: 0, + AAAA: 1, + CNAME: 2, + TXT: 3, + MX: 4, + REDIRECT: 5, + FLATTEN: 6, + PULLZONE: 7, + SRV: 8, + CAA: 9, + PTR: 10, + SCRIPT: 11, + NS: 12, + SVCB: 13, + HTTPS: 14, + TLSA: 15, +} as const satisfies Record; + +const TYPE_LABELS: Record = Object.fromEntries( + Object.entries(RECORD_TYPES).map(([name, value]) => [value, name]), +); + +/** Human label for a record type integer, falling back to "UNKNOWN". */ +export function recordTypeLabel(type: number | null | undefined): string { + return TYPE_LABELS[type ?? -1] ?? "UNKNOWN"; +} + +/** Parse a record type name (e.g. "A", "cname") to its enum value, or throw. */ +export function parseRecordType(value: string): DnsRecordTypes { + const key = value.trim().toUpperCase() as keyof typeof RECORD_TYPES; + const parsed = RECORD_TYPES[key]; + if (parsed === undefined) { + throw new UserError( + `Unknown record type "${value}".`, + `Valid types: ${Object.keys(RECORD_TYPES).join(", ")}`, + ); + } + return parsed; +} + +/** Display the record name, showing "@" for the zone apex. */ +export function recordName(name: string | null | undefined): string { + return name && name.length > 0 ? name : "@"; +} + +/** Render a record's value for display, including type-specific fields. */ +export function formatRecordValue(record: DnsRecordModel): string { + const value = record.Value ?? ""; + switch (record.Type) { + case RECORD_TYPES.MX: + return `${record.Priority ?? 0} ${value}`; + case RECORD_TYPES.SRV: + return `${record.Priority ?? 0} ${record.Weight ?? 0} ${record.Port ?? 0} ${value}`; + case RECORD_TYPES.CAA: + return `${record.Flags ?? 0} ${record.Tag ?? ""} "${value}"`; + default: + return value; + } +} diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts new file mode 100644 index 0000000..d931eba --- /dev/null +++ b/packages/cli/src/commands/dns/record/add.ts @@ -0,0 +1,329 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +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 { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; +import { + type DnsRecordTypes, + parseRecordType, + RECORD_TYPES, + recordName, + recordTypeLabel, +} from "../record-types.ts"; + +type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; +type RecordLinks = Pick; + +interface AddArgs { + domain?: string; + name?: string; + type?: string; + values?: string[]; + ttl?: number; + comment?: string; + "pull-zone"?: number; + script?: number; +} + +/** Build the request body from positional values, honouring per-type grammar. */ +function buildRecord( + type: DnsRecordTypes, + name: string, + values: string[], + links: RecordLinks, +): AddDnsRecordModel { + const record: AddDnsRecordModel = { + Type: type, + Name: name === "@" ? "" : name, + }; + + if (type === RECORD_TYPES.PULLZONE) { + if (links.PullZoneId == null) + throw new UserError("PullZone records require --pull-zone ."); + record.PullZoneId = links.PullZoneId; + return record; + } + + if (type === RECORD_TYPES.SCRIPT) { + if (links.ScriptId == null) + throw new UserError("Script records require --script ."); + record.ScriptId = links.ScriptId; + return record; + } + + if (type === RECORD_TYPES.MX) { + const [value, priority] = values; + if (!value) throw new UserError("MX records require ."); + record.Value = value; + record.Priority = Number(priority ?? 0); + return record; + } + + if (type === RECORD_TYPES.SRV) { + const [priority, weight, port, target] = values; + if (!target) + throw new UserError( + "SRV records require .", + ); + record.Priority = Number(priority ?? 0); + record.Weight = Number(weight ?? 0); + record.Port = Number(port ?? 0); + record.Value = target; + return record; + } + + if (type === RECORD_TYPES.CAA) { + let flags: string | undefined; + let tag: string | undefined; + let value: string | undefined; + if (values.length === 1) { + const match = values[0]?.match(/^(\d+)\s+(\S+)\s+"?([^"]*)"?$/); + if (!match) + throw new UserError( + "CAA value must be like: '0 issue \"example.com\"'.", + ); + [, flags, tag, value] = match; + } else { + [flags, tag, value] = values; + } + if (!value) + throw new UserError("CAA records require ."); + record.Flags = Number(flags ?? 0); + record.Tag = tag; + record.Value = value; + return record; + } + + const [value] = values; + if (!value) throw new UserError("A record value is required."); + record.Value = value; + return record; +} + +/** Throw if the user aborted a prompt (Esc / Ctrl-C yields undefined). */ +function required(value: T | undefined, label: string): T { + if (value === undefined || value === "") { + throw new UserError(`${label} is required.`); + } + return value; +} + +/** Interactively gather a record when positional args were omitted. */ +async function promptRecord( + type: DnsRecordTypes, + name: string, +): Promise { + const record: AddDnsRecordModel = { + Type: type, + Name: name === "@" ? "" : name, + }; + + if (type === RECORD_TYPES.PULLZONE) { + const { id } = await prompts({ + type: "number", + name: "id", + message: "Pull zone ID:", + }); + record.PullZoneId = required(id, "Pull zone ID"); + return record; + } + + if (type === RECORD_TYPES.SCRIPT) { + const { id } = await prompts({ + type: "number", + name: "id", + message: "Script ID:", + }); + record.ScriptId = required(id, "Script ID"); + return record; + } + + if (type === RECORD_TYPES.MX) { + const { value, priority } = await prompts([ + { type: "text", name: "value", message: "Mail server:" }, + { type: "number", name: "priority", message: "Priority:", initial: 10 }, + ]); + record.Value = required(value, "Mail server"); + record.Priority = priority ?? 10; + return record; + } + + if (type === RECORD_TYPES.SRV) { + const res = await prompts([ + { type: "number", name: "priority", message: "Priority:", initial: 10 }, + { type: "number", name: "weight", message: "Weight:", initial: 0 }, + { type: "number", name: "port", message: "Port:" }, + { type: "text", name: "target", message: "Target:" }, + ]); + record.Priority = res.priority ?? 10; + record.Weight = res.weight ?? 0; + record.Port = required(res.port, "Port"); + record.Value = required(res.target, "Target"); + return record; + } + + if (type === RECORD_TYPES.CAA) { + const res = await prompts([ + { type: "number", name: "flags", message: "Flags:", initial: 0 }, + { + type: "select", + name: "tag", + message: "Tag:", + choices: [ + { title: "issue", value: "issue" }, + { title: "issuewild", value: "issuewild" }, + { title: "iodef", value: "iodef" }, + ], + }, + { type: "text", name: "value", message: "Value:" }, + ]); + record.Flags = res.flags ?? 0; + record.Tag = required(res.tag, "Tag"); + record.Value = required(res.value, "Value"); + return record; + } + + const { value } = await prompts({ + type: "text", + name: "value", + message: "Value:", + }); + record.Value = required(value, "Value"); + return record; +} + +export const dnsAddCommand = defineCommand({ + command: "add [domain] [name] [type] [values..]", + describe: "Add a DNS record to a zone (interactive when args are omitted).", + examples: [ + ["$0 dns record add example.com api A 198.51.100.1", "Add an A record"], + [ + "$0 dns record add example.com '@' MX mail.example.com 10", + "Add an MX record", + ], + [ + "$0 dns record add example.com '@' SRV 10 0 389 sip.example.com", + "Add an SRV record", + ], + [ + "$0 dns record add example.com '@' CAA '0 issue \"letsencrypt.org\"'", + "Add a CAA record", + ], + ["$0 dns record add", "Interactive wizard"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .positional("name", { + type: "string", + describe: "Record name (use '@' for the zone apex)", + }) + .positional("type", { + type: "string", + describe: "Record type (A, AAAA, CNAME, TXT, MX, SRV, CAA, NS, ...)", + }) + .positional("values", { + type: "string", + array: true, + describe: "Record value(s) — see examples for per-type ordering", + }) + .option("ttl", { type: "number", describe: "Time to live in seconds" }) + .option("comment", { + type: "string", + describe: "Optional comment for the record", + }) + .option("pull-zone", { + type: "number", + describe: "Pull zone ID (for PullZone records)", + }) + .option("script", { + type: "number", + describe: "Edge Script ID (for Script records)", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + // Interactive when the record type wasn't given positionally. + const interactive = !args.type; + + // Resolve the target zone (prompt with a picker when no domain given). + const zone = await resolveZoneInteractive(client, args.domain); + + let record: AddDnsRecordModel; + if (interactive) { + const { typeValue } = await prompts({ + type: "select", + name: "typeValue", + message: "Record type:", + choices: Object.values(RECORD_TYPES).map((value) => ({ + title: recordTypeLabel(value), + value, + })), + }); + const type = required(typeValue, "Record type"); + + let name = args.name; + if (name === undefined) { + const res = await prompts({ + type: "text", + name: "name", + message: "Record name ('@' for apex):", + initial: "@", + }); + name = res.name ?? "@"; + } + + record = await promptRecord(type, name ?? "@"); + + if (args.ttl === undefined) { + const { ttl } = await prompts({ + type: "number", + name: "ttl", + message: "TTL (seconds, blank for default):", + }); + if (ttl !== undefined) record.Ttl = ttl; + } + } else { + const type = parseRecordType(args.type as string); + const name = args.name ?? "@"; + const values = (args.values ?? []).map((v) => String(v)); + record = buildRecord(type, name, values, { + PullZoneId: args["pull-zone"], + ScriptId: args.script, + }); + } + + if (args.ttl !== undefined) record.Ttl = args.ttl; + if (args.comment !== undefined) record.Comment = args.comment; + + const spin = spinner("Adding record..."); + spin.start(); + let data: { Id?: number } | undefined; + try { + ({ data } = await client.PUT("/dnszone/{zoneId}/records", { + params: { path: { zoneId: zone.Id as number } }, + body: record, + })); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(data, null, 2)); + return; + } + + logger.success( + `Added ${recordTypeLabel(record.Type as number)} record ${recordName(record.Name)} to ${zone.Domain}${data?.Id != null ? ` (ID: ${data.Id})` : ""}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/record/export.ts b/packages/cli/src/commands/dns/record/export.ts new file mode 100644 index 0000000..5cea03a --- /dev/null +++ b/packages/cli/src/commands/dns/record/export.ts @@ -0,0 +1,80 @@ +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 { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; + +interface ExportArgs { + domain?: string; + file?: string; + save?: boolean; +} + +export const dnsExportCommand = defineCommand({ + command: "export [domain]", + describe: "Export a zone's records as a BIND zone file.", + examples: [ + ["$0 dns record export example.com", "Print the zone file to stdout"], + [ + "$0 dns record export example.com --file ./example.zone", + "Write to a path", + ], + ["$0 dns record export example.com --save", "Write to ./example.com.zone"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("file", { + type: "string", + describe: "Write the zone file to this path instead of stdout", + }) + .option("save", { + type: "boolean", + default: false, + describe: "Write to .zone in the current directory", + }), + + handler: async ({ domain, file, save, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const spin = spinner("Exporting zone..."); + spin.start(); + let data: unknown; + try { + ({ data } = await client.GET("/dnszone/{id}/export", { + params: { path: { id: zone.Id as number } }, + parseAs: "text", + })); + } finally { + spin.stop(); + } + + const zonefile = (data as string) ?? ""; + + const outPath = file ?? (save ? `${zone.Domain}.zone` : undefined); + if (outPath) { + await Bun.write(outPath, zonefile); + if (output === "json") { + logger.log( + JSON.stringify({ domain: zone.Domain, file: outPath }, null, 2), + ); + return; + } + logger.success(`Exported ${zone.Domain} to ${outPath}.`); + return; + } + + if (output === "json") { + logger.log(JSON.stringify({ domain: zone.Domain, zonefile }, null, 2)); + return; + } + + logger.log(zonefile); + }, +}); diff --git a/packages/cli/src/commands/dns/record/import.ts b/packages/cli/src/commands/dns/record/import.ts new file mode 100644 index 0000000..d6c9672 --- /dev/null +++ b/packages/cli/src/commands/dns/record/import.ts @@ -0,0 +1,91 @@ +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 { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; + +interface ImportArgs { + domain?: string; + file?: string; +} + +export const dnsImportCommand = defineCommand({ + command: "import [domain] [file]", + describe: "Import DNS records into a zone from a BIND zone file.", + examples: [ + [ + "$0 dns record import example.com ./zonefile.txt", + "Import records from a zone file", + ], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .positional("file", { + type: "string", + describe: "Path to the BIND zone file", + }), + + handler: async ({ domain, file, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + let path = file; + if (!path) { + const res = await prompts({ + type: "text", + name: "file", + message: "Path to the BIND zone file:", + }); + path = res.file; + } + if (!path) throw new UserError("A zone file path is required."); + + const handle = Bun.file(path); + if (!(await handle.exists())) { + throw new UserError(`File not found: ${path}`); + } + const contents = await handle.text(); + + const spin = spinner("Importing records..."); + spin.start(); + + let data: unknown; + try { + // The import endpoint takes the raw zone file as the request body, + // which the generated spec does not model — pass it through directly. + ({ data } = await client.POST("/dnszone/{zoneId}/import", { + params: { path: { zoneId: zone.Id as number } }, + body: contents as never, + bodySerializer: (body: string) => body, + headers: { "Content-Type": "text/plain" }, + } as never)); + } finally { + spin.stop(); + } + + const result = data as + | { + RecordsSuccessful?: number; + RecordsFailed?: number; + RecordsSkipped?: number; + } + | undefined; + + if (output === "json") { + logger.log(JSON.stringify(result ?? {}, null, 2)); + return; + } + + logger.success( + `Imported into ${zone.Domain}: ${result?.RecordsSuccessful ?? 0} added, ${result?.RecordsSkipped ?? 0} skipped, ${result?.RecordsFailed ?? 0} failed.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/record/index.ts b/packages/cli/src/commands/dns/record/index.ts new file mode 100644 index 0000000..a3cc7bf --- /dev/null +++ b/packages/cli/src/commands/dns/record/index.ts @@ -0,0 +1,21 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { dnsAddCommand } from "./add.ts"; +import { dnsExportCommand } from "./export.ts"; +import { dnsImportCommand } from "./import.ts"; +import { dnsRecordListCommand } from "./list.ts"; +import { dnsRemoveCommand } from "./remove.ts"; +import { dnsUpdateCommand } from "./update.ts"; + +export const dnsRecordNamespace = defineNamespace( + "record", + "Manage the DNS records within a zone.", + [ + dnsRecordListCommand, + dnsAddCommand, + dnsUpdateCommand, + dnsRemoveCommand, + dnsImportCommand, + dnsExportCommand, + ], + ["records", "rec"], +); diff --git a/packages/cli/src/commands/dns/record/list.ts b/packages/cli/src/commands/dns/record/list.ts new file mode 100644 index 0000000..60ff0b2 --- /dev/null +++ b/packages/cli/src/commands/dns/record/list.ts @@ -0,0 +1,67 @@ +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 { formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; +import { + formatRecordValue, + recordName, + recordTypeLabel, +} from "../record-types.ts"; + +interface ListArgs { + domain?: string; +} + +export const dnsRecordListCommand = defineCommand({ + command: "list [domain]", + aliases: ["ls"], + describe: "List the records within a zone.", + examples: [ + ["$0 dns record list example.com", "List records in a zone"], + ["$0 dns record list example.com --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const records = (zone.Records ?? []).sort((a, b) => + recordName(a.Name).localeCompare(recordName(b.Name)), + ); + + if (output === "json") { + logger.log(JSON.stringify(records, null, 2)); + return; + } + + if (records.length === 0) { + logger.info(`No records found in ${zone.Domain}.`); + return; + } + + logger.log( + formatTable( + ["ID", "Name", "Type", "Value", "TTL"], + records.map((r) => [ + String(r.Id ?? ""), + recordName(r.Name), + recordTypeLabel(r.Type), + formatRecordValue(r), + String(r.Ttl ?? ""), + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/dns/record/remove.ts b/packages/cli/src/commands/dns/record/remove.ts new file mode 100644 index 0000000..0811f64 --- /dev/null +++ b/packages/cli/src/commands/dns/record/remove.ts @@ -0,0 +1,83 @@ +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 { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { + resolveRecordInteractive, + resolveZoneInteractive, +} from "../interactive.ts"; +import { + formatRecordValue, + recordName, + recordTypeLabel, +} from "../record-types.ts"; + +interface RemoveArgs { + domain?: string; + id?: number; + force?: boolean; +} + +export const dnsRemoveCommand = defineCommand({ + command: "remove [domain] [id]", + aliases: ["rm"], + describe: "Remove a DNS record from a zone (prompts when args are omitted).", + examples: [ + ["$0 dns record remove example.com 123", "Remove a record by ID"], + ["$0 dns record remove example.com 123 --force", "Skip confirmation"], + ["$0 dns record remove", "Pick a zone and record interactively"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .positional("id", { type: "number", describe: "Record ID" }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ domain, id, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + const record = await resolveRecordInteractive(zone, id, "remove"); + + const label = `${recordTypeLabel(record.Type)} ${recordName(record.Name)} → ${formatRecordValue(record)}`; + const confirmed = await confirm(`Remove ${label}?`, { force }); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const removeSpin = spinner("Removing record..."); + removeSpin.start(); + try { + await client.DELETE("/dnszone/{zoneId}/records/{id}", { + params: { + path: { zoneId: zone.Id as number, id: record.Id as number }, + }, + }); + } finally { + removeSpin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { zoneId: zone.Id, id: record.Id, removed: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Removed record ${record.Id} from ${zone.Domain}.`); + }, +}); diff --git a/packages/cli/src/commands/dns/record/update.ts b/packages/cli/src/commands/dns/record/update.ts new file mode 100644 index 0000000..50abd4b --- /dev/null +++ b/packages/cli/src/commands/dns/record/update.ts @@ -0,0 +1,152 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { + resolveRecordInteractive, + resolveZoneInteractive, +} from "../interactive.ts"; +import { parseRecordType, recordName } from "../record-types.ts"; + +type UpdateDnsRecordModel = components["schemas"]["UpdateDnsRecordModel"]; + +interface UpdateArgs { + domain?: string; + id?: number; + name?: string; + value?: string; + type?: string; + ttl?: number; + priority?: number; + weight?: number; + port?: number; + flags?: number; + tag?: string; + comment?: string; + disabled?: boolean; + "pull-zone"?: number; + script?: number; +} + +export const dnsUpdateCommand = defineCommand({ + command: "update [domain] [id]", + aliases: ["edit"], + describe: "Update an existing DNS record (prompts when args are omitted).", + examples: [ + [ + "$0 dns record update example.com 123 --value 198.51.100.2", + "Change a record value", + ], + ["$0 dns record update example.com 123 --ttl 3600", "Change the TTL"], + ["$0 dns record update example.com 123 --disabled", "Disable a record"], + [ + "$0 dns record update example.com --value 198.51.100.2", + "Pick the record interactively", + ], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .positional("id", { type: "number", describe: "Record ID" }) + .option("name", { + type: "string", + describe: "Record name ('@' for apex)", + }) + .option("value", { type: "string", describe: "Record value" }) + .option("type", { type: "string", describe: "Record type" }) + .option("ttl", { type: "number", describe: "Time to live in seconds" }) + .option("priority", { type: "number", describe: "Priority (MX/SRV)" }) + .option("weight", { type: "number", describe: "Weight (SRV)" }) + .option("port", { type: "number", describe: "Port (SRV)" }) + .option("flags", { type: "number", describe: "Flags (CAA)" }) + .option("tag", { type: "string", describe: "Tag (CAA)" }) + .option("comment", { type: "string", describe: "Comment for the record" }) + .option("disabled", { type: "boolean", describe: "Disable the record" }) + .option("pull-zone", { + type: "number", + describe: "Pull zone ID (for PullZone records)", + }) + .option("script", { + type: "number", + describe: "Edge Script ID (for Script records)", + }), + + handler: async (args) => { + const { domain, id, profile, output, verbose, apiKey } = args; + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + const existing = await resolveRecordInteractive(zone, id, "update"); + const recordId = existing.Id as number; + + // Seed from the existing record so unspecified fields (including advanced settings) are preserved. + const body: UpdateDnsRecordModel = { + Type: existing.Type ?? null, + Ttl: existing.Ttl ?? null, + Value: existing.Value ?? null, + Name: existing.Name ?? null, + Weight: existing.Weight ?? null, + Priority: existing.Priority ?? null, + Flags: existing.Flags ?? null, + Tag: existing.Tag ?? null, + Port: existing.Port ?? null, + Disabled: existing.Disabled ?? null, + Comment: existing.Comment ?? null, + Accelerated: existing.Accelerated ?? null, + MonitorType: existing.MonitorType ?? null, + GeolocationLatitude: existing.GeolocationLatitude ?? null, + GeolocationLongitude: existing.GeolocationLongitude ?? null, + LatencyZone: existing.LatencyZone ?? null, + SmartRoutingType: existing.SmartRoutingType ?? null, + EnviromentalVariables: existing.EnviromentalVariables ?? null, + AutoSslIssuance: existing.AutoSslIssuance ?? null, + }; + + // AcceleratedPullZoneId is the CDN-acceleration pull zone, not a PullZone-type record's link — only seed it when actually accelerated. + if (existing.Accelerated && existing.AcceleratedPullZoneId != null) { + body.PullZoneId = existing.AcceleratedPullZoneId; + } + + if (args.name !== undefined) body.Name = args.name === "@" ? "" : args.name; + if (args.value !== undefined) body.Value = args.value; + if (args.type !== undefined) body.Type = parseRecordType(args.type); + if (args.ttl !== undefined) body.Ttl = args.ttl; + if (args.priority !== undefined) body.Priority = args.priority; + if (args.weight !== undefined) body.Weight = args.weight; + if (args.port !== undefined) body.Port = args.port; + if (args.flags !== undefined) body.Flags = args.flags; + if (args.tag !== undefined) body.Tag = args.tag; + if (args.comment !== undefined) body.Comment = args.comment; + if (args.disabled !== undefined) body.Disabled = args.disabled; + if (args["pull-zone"] !== undefined) body.PullZoneId = args["pull-zone"]; + if (args.script !== undefined) body.ScriptId = args.script; + + const spin = spinner("Updating record..."); + spin.start(); + try { + await client.POST("/dnszone/{zoneId}/records/{id}", { + params: { path: { zoneId: zone.Id as number, id: recordId } }, + body, + }); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify({ zoneId: zone.Id, id: recordId, ...body }, null, 2), + ); + return; + } + + logger.success( + `Updated record ${recordName(body.Name)} (ID: ${recordId}) in ${zone.Domain}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/add.ts b/packages/cli/src/commands/dns/zone/add.ts new file mode 100644 index 0000000..b1c3f46 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/add.ts @@ -0,0 +1,60 @@ +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 { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import type { DnsZoneModel } from "../api.ts"; + +interface ZoneAddArgs { + domain: string; +} + +export const dnsZoneAddCommand = defineCommand({ + command: "add ", + describe: "Create a new DNS zone.", + examples: [["$0 dns zone add example.com", "Create a zone for example.com"]], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain to create the zone for", + demandOption: true, + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const spin = spinner("Creating DNS zone..."); + spin.start(); + + let created: DnsZoneModel | undefined; + try { + await client.POST("/dnszone", { body: { Domain: domain } }); + + // Look the new zone up to report its ID; a lookup failure must not mask the created zone. + try { + const { data } = await client.GET("/dnszone", { + params: { query: { search: domain, perPage: 1000 } }, + }); + created = (data?.Items ?? []).find( + (z) => (z.Domain ?? "").toLowerCase() === domain.toLowerCase(), + ); + } catch {} + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(created ?? { Domain: domain }, null, 2)); + return; + } + + logger.success( + created?.Id + ? `Created DNS zone ${domain} (ID: ${created.Id}).` + : `Created DNS zone ${domain}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/dnssec/disable.ts b/packages/cli/src/commands/dns/zone/dnssec/disable.ts new file mode 100644 index 0000000..1e64b89 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/dnssec/disable.ts @@ -0,0 +1,68 @@ +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 { logger } from "../../../../core/logger.ts"; +import { confirm, spinner } from "../../../../core/ui.ts"; +import { resolveZoneInteractive } from "../../interactive.ts"; + +interface DisableArgs { + domain?: string; + force?: boolean; +} + +export const dnsZoneDnssecDisableCommand = defineCommand({ + command: "disable [domain]", + describe: "Disable DNSSEC for a zone.", + examples: [ + ["$0 dns zone dnssec disable example.com", "Disable DNSSEC"], + ["$0 dns zone dnssec disable example.com --force", "Skip confirmation"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ domain, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const confirmed = await confirm(`Disable DNSSEC for ${zone.Domain}?`, { + force, + }); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const disableSpin = spinner("Disabling DNSSEC..."); + disableSpin.start(); + await client.DELETE("/dnszone/{id}/dnssec", { + params: { path: { id: zone.Id as number } }, + }); + disableSpin.stop(); + + if (output === "json") { + logger.log( + JSON.stringify( + { id: zone.Id, domain: zone.Domain, dnssec: false }, + null, + 2, + ), + ); + return; + } + + logger.warn( + `DNSSEC disabled for ${zone.Domain}. Remove the DS record at your registrar.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/dnssec/enable.ts b/packages/cli/src/commands/dns/zone/dnssec/enable.ts new file mode 100644 index 0000000..aeb6e09 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/dnssec/enable.ts @@ -0,0 +1,69 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { resolveConfig } from "../../../../config/index.ts"; +import { clientOptions } from "../../../../core/client-options.ts"; +import { defineCommand } from "../../../../core/define-command.ts"; +import { formatKeyValue } from "../../../../core/format.ts"; +import { logger } from "../../../../core/logger.ts"; +import { spinner } from "../../../../core/ui.ts"; +import { resolveZoneInteractive } from "../../interactive.ts"; + +interface EnableArgs { + domain?: string; +} + +export const dnsZoneDnssecEnableCommand = defineCommand({ + command: "enable [domain]", + describe: "Enable DNSSEC for a zone and print its DS record.", + examples: [["$0 dns zone dnssec enable example.com", "Enable DNSSEC"]], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const spin = spinner("Enabling DNSSEC..."); + spin.start(); + let data: components["schemas"]["DnsSecDsRecordModel"] | undefined; + try { + ({ data } = await client.POST("/dnszone/{id}/dnssec", { + params: { path: { id: zone.Id as number } }, + })); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(data, null, 2)); + return; + } + + logger.success(`DNSSEC enabled for ${zone.Domain}.`); + if (data?.DsRecord) { + logger.log( + formatKeyValue( + [ + { key: "DS Record", value: data.DsRecord ?? "" }, + { key: "Digest", value: data.Digest ?? "" }, + { key: "Digest Type", value: data.DigestType ?? "" }, + { key: "Algorithm", value: String(data.Algorithm ?? "") }, + { key: "Key Tag", value: String(data.KeyTag ?? "") }, + { key: "Flags", value: String(data.Flags ?? "") }, + { key: "Public Key", value: data.PublicKey ?? "" }, + ], + output, + ), + ); + } + logger.dim( + "Add the DS record above at your domain registrar to complete DNSSEC setup.", + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/dnssec/index.ts b/packages/cli/src/commands/dns/zone/dnssec/index.ts new file mode 100644 index 0000000..67ed696 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/dnssec/index.ts @@ -0,0 +1,9 @@ +import { defineNamespace } from "../../../../core/define-namespace.ts"; +import { dnsZoneDnssecDisableCommand } from "./disable.ts"; +import { dnsZoneDnssecEnableCommand } from "./enable.ts"; + +export const dnsZoneDnssecNamespace = defineNamespace( + "dnssec", + "Manage DNSSEC for a zone.", + [dnsZoneDnssecEnableCommand, dnsZoneDnssecDisableCommand], +); diff --git a/packages/cli/src/commands/dns/zone/index.ts b/packages/cli/src/commands/dns/zone/index.ts new file mode 100644 index 0000000..f3900dd --- /dev/null +++ b/packages/cli/src/commands/dns/zone/index.ts @@ -0,0 +1,33 @@ +import type { CommandModule } from "yargs"; +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { dnsZoneAddCommand } from "./add.ts"; +import { dnsZoneDnssecNamespace } from "./dnssec/index.ts"; +import { dnsZoneListCommand } from "./list.ts"; +import { dnsZoneLoggingNamespace } from "./logging/index.ts"; +import { dnsNameserversCommand } from "./nameservers.ts"; +import { dnsZoneRemoveCommand } from "./remove.ts"; +import { dnsZoneShowCommand } from "./show.ts"; +import { dnsStatsCommand } from "./stats.ts"; + +const subcommands: CommandModule[] = [ + dnsZoneListCommand, + dnsZoneAddCommand, + dnsZoneShowCommand, + dnsZoneRemoveCommand, + dnsStatsCommand, + dnsNameserversCommand, + dnsZoneDnssecNamespace, + dnsZoneLoggingNamespace, +]; + +export const dnsZoneNamespace = defineNamespace( + "zone", + "Manage DNS zones — settings, DNSSEC, logging, stats, nameservers.", + subcommands, + ["zones"], +); + +// Hidden aliases so `bunny dns domain …` works without cluttering help. +export const dnsZoneHiddenAliases: CommandModule[] = ["domain", "domains"].map( + (name) => defineNamespace(name, false, subcommands), +); diff --git a/packages/cli/src/commands/dns/zone/list.ts b/packages/cli/src/commands/dns/zone/list.ts new file mode 100644 index 0000000..1896853 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/list.ts @@ -0,0 +1,56 @@ +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 { formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { type DnsZoneModel, fetchZones } from "../api.ts"; + +export const dnsZoneListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List all DNS zones.", + examples: [ + ["$0 dns zone list", "List all DNS zones"], + ["$0 dns zone list --output json", "JSON output"], + ], + + handler: async ({ profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching DNS zones..."); + spin.start(); + let zones: DnsZoneModel[]; + try { + zones = await fetchZones(client); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(zones, null, 2)); + return; + } + + if (zones.length === 0) { + logger.info("No DNS zones found."); + return; + } + + logger.log( + formatTable( + ["ID", "Domain", "Records", "DNSSEC", "Nameservers"], + zones.map((z) => [ + String(z.Id ?? ""), + z.Domain ?? "", + String((z.Records ?? []).length), + z.DnsSecEnabled ? "Yes" : "No", + z.NameserversDetected ? "Detected" : "Pending", + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/logging/disable.ts b/packages/cli/src/commands/dns/zone/logging/disable.ts new file mode 100644 index 0000000..62dadb8 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/logging/disable.ts @@ -0,0 +1,62 @@ +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 { logger } from "../../../../core/logger.ts"; +import { confirm, spinner } from "../../../../core/ui.ts"; +import { resolveZoneInteractive } from "../../interactive.ts"; + +interface DisableArgs { + domain?: string; + force?: boolean; +} + +export const dnsZoneLoggingDisableCommand = defineCommand({ + command: "disable [domain]", + describe: "Disable DNS query logging for a zone.", + examples: [ + ["$0 dns zone logging disable example.com", "Stop collecting query logs"], + ["$0 dns zone logging disable example.com --force", "Skip confirmation"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ domain, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const confirmed = await confirm( + `Disable DNS query logging for ${zone.Domain}?`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const disableSpin = spinner("Disabling logging..."); + disableSpin.start(); + const { data } = await client.POST("/dnszone/{id}", { + params: { path: { id: zone.Id as number } }, + body: { LoggingEnabled: false }, + }); + disableSpin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(data, null, 2)); + return; + } + + logger.success(`DNS query logging disabled for ${zone.Domain}.`); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/logging/enable.ts b/packages/cli/src/commands/dns/zone/logging/enable.ts new file mode 100644 index 0000000..3a8683b --- /dev/null +++ b/packages/cli/src/commands/dns/zone/logging/enable.ts @@ -0,0 +1,97 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +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 { logger } from "../../../../core/logger.ts"; +import { spinner } from "../../../../core/ui.ts"; +import { resolveZoneInteractive } from "../../interactive.ts"; + +type LogAnonymizationType = components["schemas"]["LogAnonymizationType"]; +type LoggingUpdate = Pick< + components["schemas"]["UpdateDnsZoneModel"], + "LoggingEnabled" | "LoggingIPAnonymizationEnabled" | "LogAnonymizationType" +>; + +interface EnableArgs { + domain?: string; + "anonymize-ip"?: boolean; + anonymization?: string; +} + +// LogAnonymizationType: 0 = OneDigit, 1 = Drop +const ANONYMIZATION: Record = { + onedigit: 0, + drop: 1, +}; + +export const dnsZoneLoggingEnableCommand = defineCommand({ + command: "enable [domain]", + describe: "Enable DNS query logging for a zone.", + examples: [ + ["$0 dns zone logging enable example.com", "Start collecting query logs"], + [ + "$0 dns zone logging enable example.com --anonymize-ip --anonymization drop", + "Enable with IP anonymization", + ], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("anonymize-ip", { + type: "boolean", + describe: "Anonymize client IPs in the logs", + }) + .option("anonymization", { + type: "string", + choices: ["onedigit", "drop"], + describe: "IP anonymization strategy (default: onedigit)", + }), + + handler: async (args) => { + const { domain, profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const body: LoggingUpdate = { LoggingEnabled: true }; + + if (args["anonymize-ip"] !== undefined) { + body.LoggingIPAnonymizationEnabled = args["anonymize-ip"]; + } + if (args.anonymization !== undefined) { + const type = ANONYMIZATION[args.anonymization]; + if (type === undefined) { + throw new UserError("Anonymization must be 'onedigit' or 'drop'."); + } + body.LogAnonymizationType = type; + // Choosing a strategy implies enabling anonymization unless explicitly disabled. + if (args["anonymize-ip"] === undefined) { + body.LoggingIPAnonymizationEnabled = true; + } + } + + const spin = spinner("Enabling logging..."); + spin.start(); + let data: components["schemas"]["DnsZoneModel"] | undefined; + try { + ({ data } = await client.POST("/dnszone/{id}", { + params: { path: { id: zone.Id as number } }, + body, + })); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(data, null, 2)); + return; + } + + logger.success(`DNS query logging enabled for ${zone.Domain}.`); + logger.dim("Logs start collecting now — allow a few minutes for data."); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/logging/index.ts b/packages/cli/src/commands/dns/zone/logging/index.ts new file mode 100644 index 0000000..795feaf --- /dev/null +++ b/packages/cli/src/commands/dns/zone/logging/index.ts @@ -0,0 +1,9 @@ +import { defineNamespace } from "../../../../core/define-namespace.ts"; +import { dnsZoneLoggingDisableCommand } from "./disable.ts"; +import { dnsZoneLoggingEnableCommand } from "./enable.ts"; + +export const dnsZoneLoggingNamespace = defineNamespace( + "logging", + "Manage DNS query logging for a zone.", + [dnsZoneLoggingEnableCommand, dnsZoneLoggingDisableCommand], +); diff --git a/packages/cli/src/commands/dns/zone/nameservers.ts b/packages/cli/src/commands/dns/zone/nameservers.ts new file mode 100644 index 0000000..420cde8 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/nameservers.ts @@ -0,0 +1,85 @@ +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 { logger } from "../../../core/logger.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.", + examples: [ + ["$0 dns zone nameservers example.com", "Show the zone's nameservers"], + ["$0 dns zone ns example.com --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, 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; + + if (output === "json") { + logger.log( + JSON.stringify( + { domain: zone.Domain, custom, detected, nameservers }, + null, + 2, + ), + ); + return; + } + + 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, + ), + ); + + logger.log(""); + logger.log( + formatTable( + ["Nameserver"], + nameservers.map((ns) => [ns]), + output, + ), + ); + + 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/commands/dns/zone/remove.ts b/packages/cli/src/commands/dns/zone/remove.ts new file mode 100644 index 0000000..2bb9aa1 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/remove.ts @@ -0,0 +1,72 @@ +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 { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; + +interface ZoneRemoveArgs { + domain?: string; + force?: boolean; +} + +export const dnsZoneRemoveCommand = defineCommand({ + command: "remove [domain]", + aliases: ["rm"], + describe: "Delete a DNS zone and all of its records.", + examples: [ + ["$0 dns zone remove example.com", "Delete a zone"], + ["$0 dns zone remove example.com --force", "Skip confirmation"], + ["$0 dns zone remove", "Pick a zone interactively"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ domain, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const confirmed = await confirm( + `Delete zone ${zone.Domain} and all ${(zone.Records ?? []).length} record(s)?`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const removeSpin = spinner("Deleting zone..."); + removeSpin.start(); + try { + await client.DELETE("/dnszone/{id}", { + params: { path: { id: zone.Id as number } }, + }); + } finally { + removeSpin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { id: zone.Id, domain: zone.Domain, removed: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Deleted DNS zone ${zone.Domain}.`); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/show.ts b/packages/cli/src/commands/dns/zone/show.ts new file mode 100644 index 0000000..3509f33 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/show.ts @@ -0,0 +1,66 @@ +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 { formatDateTime, formatKeyValue } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; + +interface ShowArgs { + domain?: string; +} + +export const dnsZoneShowCommand = defineCommand({ + command: "show [domain]", + describe: "Show details for a DNS zone.", + examples: [ + ["$0 dns zone show example.com", "Show zone details"], + ["$0 dns zone show example.com --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + if (output === "json") { + logger.log(JSON.stringify(zone, null, 2)); + return; + } + + const nameservers = zone.CustomNameserversEnabled + ? [zone.Nameserver1, zone.Nameserver2].filter(Boolean).join(", ") + : "bunny.net (default)"; + + logger.log( + formatKeyValue( + [ + { key: "ID", value: String(zone.Id ?? "") }, + { key: "Domain", value: zone.Domain ?? "" }, + { key: "Records", value: String((zone.Records ?? []).length) }, + { + key: "Nameservers", + value: zone.NameserversDetected ? "Detected" : "Pending", + }, + { key: "Nameserver config", value: nameservers }, + { key: "SOA email", value: zone.SoaEmail ?? "—" }, + { key: "DNSSEC", value: zone.DnsSecEnabled ? "Enabled" : "Disabled" }, + { + key: "Logging", + value: zone.LoggingEnabled ? "Enabled" : "Disabled", + }, + { key: "Created", value: formatDateTime(zone.DateCreated) }, + { key: "Modified", value: formatDateTime(zone.DateModified) }, + ], + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/stats.ts b/packages/cli/src/commands/dns/zone/stats.ts new file mode 100644 index 0000000..7bc105f --- /dev/null +++ b/packages/cli/src/commands/dns/zone/stats.ts @@ -0,0 +1,133 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import chalk from "chalk"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { bunny } from "../../../core/colors.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatKeyValue, formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; +import { queryTypeLabel } from "../query-types.ts"; + +interface StatsArgs { + domain?: string; + from?: string; + to?: string; +} + +const BAR_WIDTH = 24; + +/** Sum the values of a date-keyed chart map. */ +function sumChart(chart: { [key: string]: number } | null | undefined): number { + return Object.values(chart ?? {}).reduce((acc, n) => acc + n, 0); +} + +/** Render a horizontal bar chart of query counts per record type. */ +function renderTypeChart(byType: [string, number][]): string { + const max = Math.max(...byType.map(([, n]) => n), 1); + const labelWidth = Math.max(...byType.map(([t]) => t.length)); + const numWidth = Math.max( + ...byType.map(([, n]) => n.toLocaleString().length), + ); + return byType + .map(([type, count]) => { + const filled = + count > 0 ? Math.max(1, Math.round((count / max) * BAR_WIDTH)) : 0; + const bar = + bunny("█".repeat(filled)) + chalk.gray("░".repeat(BAR_WIDTH - filled)); + const label = type.padEnd(labelWidth); + const value = count.toLocaleString().padStart(numWidth); + return ` ${label} ${bar} ${value}`; + }) + .join("\n"); +} + +export const dnsStatsCommand = defineCommand({ + command: "stats [domain]", + describe: "Show DNS query statistics for a zone.", + examples: [ + ["$0 dns zone stats example.com", "Statistics for the last 30 days"], + [ + "$0 dns zone stats example.com --from 2026-05-01 --to 2026-05-31", + "Statistics for a date range", + ], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("from", { + type: "string", + describe: "Start date (YYYY-MM-DD); defaults to 30 days ago", + }) + .option("to", { + type: "string", + describe: "End date (YYYY-MM-DD); defaults to today", + }), + + handler: async ({ domain, from, to, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const spin = spinner("Fetching statistics..."); + spin.start(); + const { data } = await client.GET("/dnszone/{id}/statistics", { + params: { + path: { id: zone.Id as number }, + query: { dateFrom: from, dateTo: to }, + }, + }); + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(data ?? {}, null, 2)); + return; + } + + const period = + from || to ? `${from ?? "…"} → ${to ?? "today"}` : "last 30 days"; + logger.log( + formatKeyValue( + [ + { key: "Zone", value: zone.Domain ?? "" }, + { key: "Period", value: period }, + { + key: "Total queries", + value: (data?.TotalQueriesServed ?? 0).toLocaleString(), + }, + { + key: "Normal queries", + value: sumChart(data?.NormalQueriesServedChart).toLocaleString(), + }, + { + key: "Smart queries", + value: sumChart(data?.SmartQueriesServedChart).toLocaleString(), + }, + ], + output, + ), + ); + + const byType = Object.entries(data?.QueriesByTypeChart ?? {}) + .map(([code, count]): [string, number] => [queryTypeLabel(code), count]) + .sort((a, b) => b[1] - a[1]); + if (byType.length > 0) { + logger.log(""); + logger.dim(" Queries by type"); + if (output === "text") { + logger.log(renderTypeChart(byType)); + } else { + logger.log( + formatTable( + ["Type", "Queries"], + byType.map(([type, count]) => [type, String(count)]), + output, + ), + ); + } + } + }, +}); diff --git a/packages/cli/src/core/define-namespace.ts b/packages/cli/src/core/define-namespace.ts index f515eab..ccf9c1a 100644 --- a/packages/cli/src/core/define-namespace.ts +++ b/packages/cli/src/core/define-namespace.ts @@ -4,6 +4,9 @@ import type { Argv, CommandModule } from "yargs"; * Groups subcommands under a parent namespace. Running the namespace * without a subcommand shows help. * + * Pass `describe: false` to hide the namespace from help (e.g. a hidden + * alias). Pass `aliases` to expose alternative names shown in help. + * * @example * ```ts * export const authNamespace = defineNamespace( @@ -15,12 +18,14 @@ import type { Argv, CommandModule } from "yargs"; */ export function defineNamespace( command: string, - describe: string, + describe: string | false, subcommands: CommandModule[], + aliases?: string[], ): CommandModule { let yRef: Argv; return { command, + aliases, describe, builder: (yargs) => { yRef = yargs; diff --git a/packages/openapi-client/src/middleware.test.ts b/packages/openapi-client/src/middleware.test.ts index d85e5c6..4d9773c 100644 --- a/packages/openapi-client/src/middleware.test.ts +++ b/packages/openapi-client/src/middleware.test.ts @@ -11,9 +11,12 @@ function runRequest(options: ClientOptions, request: Request) { function runResponse( options: ClientOptions, response: Response, + parseAs: "json" | "text" = "json", ): Promise { const mw = authMiddleware(options); - return Promise.resolve(mw.onResponse!({ response } as never)); + return Promise.resolve( + mw.onResponse!({ response, options: { parseAs } } as never), + ); } describe("authMiddleware onRequest", () => { @@ -139,4 +142,42 @@ describe("authMiddleware onResponse", () => { ); expect(result).toBeUndefined(); }); + + test("allows an OK text/plain download body when parseAs is text (e.g. DNS zone-file export)", async () => { + const result = await runResponse( + { apiKey: "k" }, + new Response("$ORIGIN example.com.\nwww IN CNAME example.b-cdn.net.", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + "text", + ); + expect(result).toBeUndefined(); + }); + + test("allows an OK application/octet-stream download body when parseAs is text", async () => { + const result = await runResponse( + { apiKey: "k" }, + new Response("binary-ish payload", { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }), + "text", + ); + expect(result).toBeUndefined(); + }); + + test("throws on a non-JSON body when parseAs is json (proxy serving text/plain to a JSON call)", async () => { + const error = (await captureError( + runResponse( + { apiKey: "k" }, + new Response("upstream connect error", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + ), + )) as ApiError; + expect(error).toBeInstanceOf(ApiError); + expect(error.message).toContain("non-JSON"); + }); }); diff --git a/packages/openapi-client/src/middleware.ts b/packages/openapi-client/src/middleware.ts index 90c435f..6b25f20 100644 --- a/packages/openapi-client/src/middleware.ts +++ b/packages/openapi-client/src/middleware.ts @@ -97,7 +97,7 @@ export function authMiddleware(options: ClientOptions): Middleware { return request; }, - async onResponse({ response }) { + async onResponse({ response, options }) { if (debug) { const cloned = response.clone(); debug(`← ${response.status} ${response.statusText}`); @@ -118,14 +118,16 @@ export function authMiddleware(options: ClientOptions): Middleware { } } - // OK responses with a non-JSON body would otherwise crash - // openapi-fetch when it tries to JSON.parse the bytes. Detect - // that here and translate it into a clearer ApiError. This - // commonly happens when a CDN / proxy / captive portal serves an - // HTML error page with a 200 status code. + // openapi-fetch only JSON.parses the body when parseAs is "json" (its + // default). Callers that fetch downloads opt out via parseAs: "text" + // (etc.), so a non-JSON body is expected there and passes through. A + // non-JSON body on a JSON call is almost always a CDN / proxy / captive + // portal serving an HTML error page with a 200 status — surface that as + // a clear ApiError instead of letting openapi-fetch crash on JSON.parse. if (response.ok) { + const parseAs = options?.parseAs ?? "json"; const contentType = response.headers.get("content-type") ?? ""; - if (!looksLikeJson(contentType)) { + if (parseAs === "json" && !looksLikeJson(contentType)) { const text = await response.clone().text(); if (text.trim().length > 0) { const preview = text.length > 200 ? `${text.slice(0, 200)}…` : text;