Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-parrots-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/openapi-client": patch
---

fix: let legitimate non-JSON 200 responses pass the proxy-interception guard
5 changes: 5 additions & 0 deletions .changeset/twelve-peaches-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

feat(dns): add experimental `bunny dns` commands for managing DNS zones and records
55 changes: 55 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>, or --save → <domain>.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
Expand Down Expand Up @@ -794,6 +823,32 @@ bunny
│ ├── update <id> [--name] [--username] [--password]
│ │ Update registry name and/or rotate credentials
│ └── remove <id> 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 <path>, or --save → <domain>.zone)
│ └── zone (aliases: zones; hidden: domain, domains)
│ ├── list List all DNS zones (alias: ls)
│ ├── add <domain> 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
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,78 @@ bunny registries add --name "GitHub" --username myorg
bunny registries remove <registry-id>
```

### `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 `<value> <priority>`, `SRV` takes `<priority> <weight> <port> <target>`, and `CAA` takes a single quoted `'<flags> <tag> "<value>"'` string. `PullZone` and `Script` records take no positional value — pass `--pull-zone <id>` or `--script <id>` 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 `<domain>.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.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -33,6 +34,7 @@ const commands: CommandModule[] = [
const experimentalCommands: CommandModule[] = [
appsNamespace,
registriesNamespace,
dnsNamespace,
];

let instance = yargs(hideBin(process.argv))
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/commands/dns/api.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createCoreClient>;
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<DnsZoneModel[]> {
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<DnsZoneModel> {
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<DnsZoneModel> {
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.',
);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return fetchZone(client, match.Id);
}
10 changes: 10 additions & 0 deletions packages/cli/src/commands/dns/index.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
102 changes: 102 additions & 0 deletions packages/cli/src/commands/dns/interactive.ts
Original file line number Diff line number Diff line change
@@ -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<DnsZoneModel> {
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 <domain>".',
);
}

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<DnsRecordModel> {
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;
}
Loading