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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ carries the same masked previews, not the values.
| `anthropic` | `ANTHROPIC_API_KEY` | `generate-names` |
| `perplexity` | `PERPLEXITY_API_KEY` | `ask-web` |
| `elevenlabs` | `ELEVENLABS_API_KEY` | `tts` |
| `porkbun` | `PORKBUN_API_KEY` | `porkbun` |
| `porkbun_secret` | `PORKBUN_SECRET_API_KEY` | `porkbun` |

A key earns a row here by being read by a command in this repository, not by
being a key the team owns. The vault holds more than twice as many; the rest
Expand Down Expand Up @@ -422,6 +424,52 @@ goes through OpenRDAP. Either way `dig` adds records, hosts, reverse lookups and
per-nameserver AXFR attempts. Errors are JSON too — a tool whose output gets
parsed should not change shape when it fails.

### `porkbun`

DNS at Porkbun, without the dashboard.

```sh
porkbun ls example.com # the zone, apex first
porkbun ls example.com --type TXT # one type
porkbun set example.com www CNAME app.up.railway.app # create, or edit in place
porkbun set example.com @ ALIAS app.up.railway.app # apex, via Porkbun's ALIAS
porkbun rm example.com www --type CNAME --yes
porkbun unpark example.com # stop the parking page winning
porkbun domains # everything on the account
```

Write the host the way you would say it. `@`, an empty value and the bare domain
all mean the apex; `www` and `www.example.com` are the same record. Getting this
wrong is how you end up with `www.example.com.example.com`, so all four forms are
accepted and normalised.

`set` is an upsert: it creates the record, or edits the existing one **in place**,
keeping its id. The obvious alternative — delete then create — has a window where
the name does not resolve at all. When the value already matches it reports
`unchanged` and sends no write. If several records share a name and type (two TXT
values, a set of A records) it refuses rather than guessing which to overwrite.

`unpark` is the one worth knowing about. A domain you bought and left alone answers
with an `ALIAS` at the apex and a wildcard `CNAME`, both pointing at a
`*.porkbun.com` host — and **those records belong to a URL forwarding rule** rather
than standing on their own. So adding your own ALIAS beside them changes nothing:
the forward keeps winning, the new host never sees a request, and it looks exactly
like a broken deploy rather than a DNS problem. `unpark` deletes the forward, which
takes its records with it, then removes anything that survived. `--dry-run` prints
the plan first.

It is deliberately narrow about what counts as parking: only `ALIAS` and `CNAME`
records pointing at `porkbun.com`. The `MX` records at `fwd1.porkbun.com` are
Porkbun's *email forwarding* and the `NS` records are the zone's delegation —
sweeping either up would break mail or take the domain off the internet.

Two Porkbun-specific traps the errors call out by name. Every call is a `POST`
with the credentials in the body, and **`status` is a field rather than the HTTP
code**: a bad key, an unknown domain and a malformed record all return `200 OK`
with `{"status":"ERROR"}`, so checking the response code reports success for all
three. And API access is **off per domain** until you switch it on in that
domain's settings — a key that pings fine still gets `Invalid domain` until you do.

### `blog-post`

Publishes to the plain-HTML blog at `~/public_html/blog`. That blog has no build
Expand Down
308 changes: 308 additions & 0 deletions bin/porkbun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
#!/usr/bin/env -S npx --yes tsx
/**
* porkbun — read and change DNS at Porkbun without the dashboard.
*
* porkbun ls example.com
* porkbun set example.com www CNAME app.up.railway.app
* porkbun unpark example.com
*
* `unpark` is the reason this exists. A domain bought and left alone answers
* with an ALIAS and a wildcard CNAME pointing at Porkbun's parking host, and
* those records belong to a **URL forwarding rule** rather than standing on
* their own. Adding your own ALIAS next to them does nothing at all — the
* forward keeps winning, the new host never sees a request, and the failure
* looks exactly like a broken deploy. Deleting the forward removes all of it in
* one call. That cost an afternoon once; it is one command now.
*/

import { UsageError, integer, parseArgs } from '../src/args.ts';
import { resolveCredentials } from '../src/credentials.ts';
import { isMain } from '../src/is-main.ts';
import {
MIN_TTL,
PorkbunError,
type RecordInput,
createRecord,
credentialsFrom,
deleteForward,
deleteRecord,
formatForwards,
formatRecords,
fqdn,
hostLabel,
listDomains,
listForwards,
listRecords,
matchRecords,
ping,
planUnpark,
porkbunCaller,
setRecord,
sortRecords,
} from '../src/porkbun.ts';

const USAGE = `Usage:
porkbun ping
porkbun domains
porkbun ls <domain> [--type TYPE] [--name HOST] [--json]
porkbun set <domain> <host> <type> <content> [--ttl N] [--prio N]
porkbun add <domain> <host> <type> <content> [--ttl N] [--prio N]
porkbun rm <domain> (<id> | <host> --type TYPE) [--yes]
porkbun forwards <domain> [--json]
porkbun unpark <domain> [--dry-run] [--yes]

Commands:
ping check the credentials and show the IP Porkbun sees
domains every domain on the account
ls list DNS records
set create the record, or edit it in place if it already exists
add always create, even if one of that name and type is there
rm delete by record id, or by host + --type
forwards list URL forwarding rules
unpark remove URL forwarding and the parking records it owns

The host is written as you would say it: \`@\` or the bare domain for the apex,
\`www\` or \`www.example.com\` for a subdomain. Both forms mean the same record.

Options:
--type TYPE record type (A, AAAA, ALIAS, CNAME, MX, TXT, ...)
--name HOST filter \`ls\` to one host
--ttl N TTL in seconds, minimum ${MIN_TTL} (default ${MIN_TTL})
--prio N priority, for MX and SRV
--json raw JSON instead of a table
--dry-run for unpark: print what would be deleted, delete nothing
--yes skip the confirmation prompt
-h, --help show this help

Credentials come from PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY, via
\`cli-tools config pull\` or the environment. Porkbun also requires API access
to be switched on per domain, in the domain's settings — a key that pings fine
still gets "Invalid domain" until that is on.
`;

function fail(message: string, code = 2): never {
process.stderr.write(`porkbun: ${message}\n`);
process.exit(code);
}

/** A yes/no on stdin. Non-interactive callers must pass --yes rather than hang. */
async function confirm(question: string): Promise<boolean> {
if (!process.stdin.isTTY) {
throw new UsageError('not a terminal — pass --yes to confirm non-interactively');
}
process.stderr.write(`${question} [y/N] `);
const answer = await new Promise<string>((resolve) => {
process.stdin.setEncoding('utf8');
process.stdin.once('data', (chunk) => resolve(String(chunk)));
});
return /^y(es)?$/i.test(answer.trim());
}

if (isMain(import.meta.url)) {
try {
const parsed = parseArgs(process.argv.slice(2), {
boolean: ['--json', '--dry-run', '--yes', '-h', '--help'],
string: ['--type', '--name', '--ttl', '--prio'],
});

if (parsed.flags.has('-h') || parsed.flags.has('--help') || parsed.positional.length === 0) {
process.stdout.write(USAGE);
process.exit(0);
}

const [command, ...rest] = parsed.positional;
const json = parsed.flags.has('--json');
const assumeYes = parsed.flags.has('--yes');

const call = porkbunCaller(credentialsFrom(resolveCredentials(process.env)));

const needDomain = (): string => {
const domain = rest[0];
if (!domain) throw new UsageError(`${command} needs a domain`);
return domain.trim().toLowerCase().replace(/\.$/, '');
};

const recordInput = (host: string, type: string, content: string): RecordInput => {
// Checked here rather than through `integer`'s own range, whose message
// ("must be between 600 and 9007199254740991") names a bound nobody set
// and does not say the floor is Porkbun's rather than ours.
const ttl = integer(parsed.values, '--ttl', MIN_TTL);
if (ttl < MIN_TTL) {
throw new UsageError(`--ttl must be at least ${MIN_TTL}; Porkbun rejects anything lower`);
}
return {
host,
type,
content,
ttl,
...(parsed.values.has('--prio')
? { prio: integer(parsed.values, '--prio', 0, { max: 65_535 }) }
: {}),
};
};

switch (command) {
case 'ping': {
process.stdout.write(`ok, Porkbun sees you at ${await ping(call)}\n`);
break;
}

case 'domains': {
const domains = await listDomains(call);
process.stdout.write(json ? `${JSON.stringify(domains, null, 2)}\n` : `${domains.join('\n')}\n`);
break;
}

case 'ls':
case 'list': {
const domain = needDomain();
let records = await listRecords(call, domain);

const host = parsed.values.get('--name');
const type = parsed.values.get('--type');
if (host !== undefined) records = matchRecords(records, domain, host, type);
else if (type) records = records.filter((r) => r.type === type.toUpperCase());

process.stdout.write(
json
? `${JSON.stringify(sortRecords(records, domain), null, 2)}\n`
: `${formatRecords(records, domain)}\n`,
);
break;
}

case 'set': {
const domain = needDomain();
const [, host, type, content] = rest;
if (!host || !type || !content) throw new UsageError('set needs <host> <type> <content>');

const outcome = await setRecord(call, domain, recordInput(host, type, content));
process.stdout.write(
`${outcome.action} ${type.toUpperCase()} ${fqdn(domain, hostLabel(domain, host))}` +
` -> ${content} (id ${outcome.id})\n`,
);
break;
}

case 'add': {
const domain = needDomain();
const [, host, type, content] = rest;
if (!host || !type || !content) throw new UsageError('add needs <host> <type> <content>');

const id = await createRecord(call, domain, recordInput(host, type, content));
process.stdout.write(
`created ${type.toUpperCase()} ${fqdn(domain, hostLabel(domain, host))} -> ${content} (id ${id})\n`,
);
break;
}

case 'rm':
case 'delete': {
const domain = needDomain();
const target = rest[1];
if (!target) throw new UsageError('rm needs a record id, or a host with --type');

// A bare number is an id; anything else is a host, and a host without a
// type could match several records of different types at once.
let doomed: { id: string; label: string }[];
if (/^\d+$/.test(target)) {
doomed = [{ id: target, label: `record ${target}` }];
} else {
const type = parsed.values.get('--type');
if (!type) throw new UsageError('deleting by host needs --type, to say which record');
const matches = matchRecords(await listRecords(call, domain), domain, target, type);
if (matches.length === 0) {
fail(`no ${type.toUpperCase()} record for ${fqdn(domain, hostLabel(domain, target))}`, 1);
}
doomed = matches.map((record) => ({
id: record.id,
label: `${record.type} ${record.name} -> ${record.content}`,
}));
}

if (!assumeYes) {
for (const item of doomed) process.stderr.write(` ${item.label}\n`);
if (!(await confirm(`delete ${doomed.length} record(s)?`))) {
process.stderr.write('cancelled\n');
process.exit(1);
}
}

for (const item of doomed) {
await deleteRecord(call, domain, item.id);
process.stdout.write(`deleted ${item.label}\n`);
}
break;
}

case 'forwards': {
const domain = needDomain();
const forwards = await listForwards(call, domain);
process.stdout.write(
json ? `${JSON.stringify(forwards, null, 2)}\n` : `${formatForwards(forwards, domain)}\n`,
);
break;
}

case 'unpark': {
const domain = needDomain();
const plan = planUnpark(await listRecords(call, domain), await listForwards(call, domain));

if (plan.empty) {
process.stdout.write(`${domain} is not parked — nothing to remove\n`);
break;
}

for (const forward of plan.forwards) {
process.stderr.write(` forward ${forward.id}: ${fqdn(domain, forward.subdomain)} -> ${forward.location}\n`);
}
for (const record of plan.records) {
process.stderr.write(` record ${record.id}: ${record.type} ${record.name} -> ${record.content}\n`);
}

if (parsed.flags.has('--dry-run')) {
process.stdout.write('--dry-run: nothing deleted\n');
break;
}
if (!assumeYes && !(await confirm(`remove parking from ${domain}?`))) {
process.stderr.write('cancelled\n');
process.exit(1);
}

for (const forward of plan.forwards) {
await deleteForward(call, domain, forward.id);
process.stdout.write(`deleted forward ${forward.id}\n`);
}

// Deleting a forward takes its ALIAS and wildcard CNAME with it, so the
// plan's record ids are usually already gone by now and deleting them
// by id would report "Invalid record ID" for something that worked.
// Re-read and remove only what actually survived.
const survivors = (await listRecords(call, domain)).filter((record) =>
plan.records.some((planned) => planned.id === record.id),
);
for (const record of survivors) {
await deleteRecord(call, domain, record.id);
process.stdout.write(`deleted record ${record.id} (${record.type} ${record.content})\n`);
}

const removed = plan.records.length - survivors.length;
if (removed > 0) {
process.stdout.write(`${removed} parking record(s) went with the forward\n`);
}
process.stdout.write(`${domain} un-parked\n`);
break;
}

default:
throw new UsageError(`unknown command: ${command}`);
}
} catch (error) {
if (error instanceof UsageError) {
process.stderr.write(`${USAGE}\n`);
fail(error.message);
}
if (error instanceof PorkbunError) fail(error.message, 1);
fail(error instanceof Error ? error.message : String(error), 1);
}
}
8 changes: 7 additions & 1 deletion src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
anthropic: 'ANTHROPIC_API_KEY',
perplexity: 'PERPLEXITY_API_KEY',
elevenlabs: 'ELEVENLABS_API_KEY',
porkbun: 'PORKBUN_API_KEY',
porkbun_secret: 'PORKBUN_SECRET_API_KEY',
};

export type Source = 'env' | 'file' | 'unset';
Expand All @@ -54,10 +56,14 @@

/** Resolve a friendly name or an env var name to the env var name, or null. */
export function keyVariable(name: string): string | null {
// Hyphens and underscores are the same separator here. The single-word keys
// never needed this; `porkbun-secret-api-key` and `porkbun_secret_api_key`
// are the same key and both have to land on the same entry.
const key = String(name ?? '')
.trim()
.toLowerCase()
.replace(/[-_]?(api[-_]?)?key$/, '');
.replace(/-/g, '_')
.replace(/_?(api_?)?key$/, '');
if (Object.hasOwn(KNOWN_KEYS, key)) return KNOWN_KEYS[key]!;

const upper = String(name ?? '')
Expand Down
Loading
Loading