diff --git a/README.md b/README.md index 21f6ef9..e95b5e1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/bin/porkbun.ts b/bin/porkbun.ts new file mode 100755 index 0000000..bdb099c --- /dev/null +++ b/bin/porkbun.ts @@ -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 [--type TYPE] [--name HOST] [--json] + porkbun set [--ttl N] [--prio N] + porkbun add [--ttl N] [--prio N] + porkbun rm ( | --type TYPE) [--yes] + porkbun forwards [--json] + porkbun unpark [--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 { + 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((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 '); + + 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 '); + + 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); + } +} diff --git a/src/credentials.ts b/src/credentials.ts index ca3ac6e..89ded9e 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -32,6 +32,8 @@ export const KNOWN_KEYS: Record = { 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'; @@ -54,10 +56,14 @@ export function credentialsPath(env: NodeJS.ProcessEnv = process.env): string { /** 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 ?? '') diff --git a/src/porkbun.ts b/src/porkbun.ts new file mode 100644 index 0000000..c850ea3 --- /dev/null +++ b/src/porkbun.ts @@ -0,0 +1,421 @@ +/** + * Porkbun DNS, from the command line. + * + * Porkbun hosts most of the zones here, and its API is the only way to change a + * record without clicking through the dashboard. The web UI is fine for one + * record; it is not fine for "point this apex and www at a new host", which is + * four edits that have to land together. + * + * Two things about this API shape the code below. + * + * First, **every call is a POST**, including the reads, and the credentials + * travel in the JSON body rather than a header. So there is no GET to paste into + * a browser and no `curl -H` that works; a caller has to build the body. That is + * `porkbunCaller`. + * + * Second, **`status` is a field, not the HTTP code**. A refused key, an unknown + * domain and a malformed record all come back `200 OK` with + * `{"status":"ERROR","message":"..."}`. Checking `response.ok` therefore reports + * success for every one of those, which is why {@link callPorkbun} treats the + * body's own `status` as the verdict and surfaces `message` verbatim. + */ + +export const API_BASE = 'https://api.porkbun.com/api/json/v3'; + +/** Porkbun rejects anything lower, and silently on some endpoints. */ +export const MIN_TTL = 600; + +export const DEFAULT_TIMEOUT_MS = 20_000; + +/** Record types the API accepts. ALIAS is Porkbun's apex-CNAME equivalent. */ +export const RECORD_TYPES = [ + 'A', + 'AAAA', + 'ALIAS', + 'CAA', + 'CNAME', + 'HTTPS', + 'MX', + 'NS', + 'SRV', + 'SVCB', + 'TLSA', + 'TXT', +] as const; + +export type RecordType = (typeof RECORD_TYPES)[number]; + +export interface Credentials { + apikey: string; + secretapikey: string; +} + +export interface DnsRecord { + id: string; + /** Fully qualified, as Porkbun returns it: `www.example.com`, not `www`. */ + name: string; + type: string; + content: string; + ttl: string; + prio: string | null; + notes?: string; +} + +export interface UrlForward { + id: string; + /** Empty string for the apex. */ + subdomain: string; + location: string; + type: string; + includePath: string; + wildcard: string; +} + +export class PorkbunError extends Error { + constructor(message: string) { + super(message); + this.name = 'PorkbunError'; + } +} + +/** Issue one API call and return its body. Injected so tests never go to the network. */ +export type Caller = (path: string, body?: Record) => Promise>; + +/** + * Turn a response body into either its data or an error. + * + * Exported because this is the part worth testing: the failure mode is a 200 + * that means "no", and getting it wrong makes a broken call look like it worked. + */ +export function unwrap(body: unknown, path: string): Record { + if (!body || typeof body !== 'object') { + throw new PorkbunError(`${path}: expected a JSON object, got ${typeof body}`); + } + const record = body as Record; + if (record.status === 'SUCCESS') return record; + + const message = typeof record.message === 'string' ? record.message : JSON.stringify(record); + throw new PorkbunError(`${path}: ${message}`); +} + +export function porkbunCaller( + credentials: Credentials, + timeoutMs: number = DEFAULT_TIMEOUT_MS, + fetcher: typeof fetch = fetch, +): Caller { + return async (path, body = {}) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetcher(`${API_BASE}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...credentials, ...body }), + signal: controller.signal, + }); + + // A non-JSON body here is Porkbun's edge (a 502 page, a rate-limit + // notice), not the API. Say which, rather than throwing a bare + // "Unexpected token < in JSON". + const text = await response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new PorkbunError( + `${path}: HTTP ${response.status}, and the body was not JSON: ${text.slice(0, 200)}`, + ); + } + return unwrap(parsed, path); + } finally { + clearTimeout(timer); + } + }; +} + +/** + * Credentials out of the environment, or a message naming both variables. + * + * Both are required and they are easy to half-set — the key is the obvious one + * and the secret is the one people forget — so a missing secret says so instead + * of failing later as `Invalid API key`. + */ +export function credentialsFrom(env: Record): Credentials { + const apikey = env.PORKBUN_API_KEY; + const secretapikey = env.PORKBUN_SECRET_API_KEY; + + const missing = [ + apikey ? null : 'PORKBUN_API_KEY', + secretapikey ? null : 'PORKBUN_SECRET_API_KEY', + ].filter(Boolean); + + if (missing.length > 0) { + throw new PorkbunError( + `no Porkbun credentials — missing ${missing.join(' and ')}. ` + + 'Run `cli-tools config pull` to take them from the team vault, ' + + 'or export them.', + ); + } + return { apikey: apikey!, secretapikey: secretapikey! }; +} + +/** + * The host label Porkbun wants, given whatever the user typed. + * + * The API's `name` is the label *relative to the zone* and the apex is the + * empty string — but nobody types an empty string, and every DNS UI in + * existence spells the apex `@`. Accepting `@`, an empty value, the bare + * label and the full name all mean the same thing removes the single most + * common way to create `www.example.com.example.com`. + */ +export function hostLabel(domain: string, host: string): string { + const zone = domain.trim().toLowerCase().replace(/\.$/, ''); + const raw = String(host ?? '') + .trim() + .toLowerCase() + .replace(/\.$/, ''); + + if (raw === '' || raw === '@' || raw === zone) return ''; + if (raw.endsWith(`.${zone}`)) return raw.slice(0, -(zone.length + 1)); + return raw; +} + +/** The inverse, for display: `''` → `example.com`, `www` → `www.example.com`. */ +export function fqdn(domain: string, label: string): string { + const zone = domain.trim().toLowerCase().replace(/\.$/, ''); + return label ? `${label}.${zone}` : zone; +} + +/** + * Is this record part of Porkbun's parking, rather than something you meant? + * + * A parked domain answers with an `ALIAS` at the apex and a `CNAME` on `*`, + * both pointing at a `*.porkbun.com` host. They are not independent records: + * they are how a **URL forwarding rule** is implemented, which is why adding an + * ALIAS "alongside" them changes nothing and why deleting the forward takes all + * of them with it. + * + * Deliberately narrow. The `MX` records at `fwd1.porkbun.com` are Porkbun's + * *email forwarding* and the `NS` records are the zone's own delegation — + * treating either as parking would break mail and take the domain off the + * internet, so only ALIAS and CNAME count. + */ +export function isParkingRecord(record: DnsRecord): boolean { + if (record.type !== 'ALIAS' && record.type !== 'CNAME') return false; + const content = record.content.trim().toLowerCase().replace(/\.$/, ''); + return content === 'porkbun.com' || content.endsWith('.porkbun.com'); +} + +export interface UnparkPlan { + forwards: UrlForward[]; + records: DnsRecord[]; + /** Nothing to do: the domain was never parked, or already un-parked. */ + empty: boolean; +} + +/** + * What un-parking would remove. + * + * Built as a plan so `--dry-run` and the real thing agree by construction, and + * so the caller can print it before destroying anything. + */ +export function planUnpark(records: readonly DnsRecord[], forwards: readonly UrlForward[]): UnparkPlan { + const parking = records.filter(isParkingRecord); + return { + forwards: [...forwards], + records: parking, + empty: forwards.length === 0 && parking.length === 0, + }; +} + +/** Sort for display: apex first, then by name, then type. Stable and readable. */ +export function sortRecords(records: readonly DnsRecord[], domain: string): DnsRecord[] { + return [...records].sort((a, b) => { + const labelA = hostLabel(domain, a.name); + const labelB = hostLabel(domain, b.name); + if (labelA !== labelB) { + if (labelA === '') return -1; + if (labelB === '') return 1; + return labelA.localeCompare(labelB); + } + if (a.type !== b.type) return a.type.localeCompare(b.type); + return a.content.localeCompare(b.content); + }); +} + +/** + * A fixed-width table. + * + * Content is truncated rather than wrapped: a DKIM TXT record is 400 characters + * and wrapping one turns a 12-row zone into three screens. `--json` is there + * for the whole value. + */ +export function formatRecords(records: readonly DnsRecord[], domain: string, width = 60): string { + if (records.length === 0) return 'no records'; + + const rows = sortRecords(records, domain).map((record) => ({ + id: record.id, + type: record.type, + name: fqdn(domain, hostLabel(domain, record.name)), + content: record.content.length > width ? `${record.content.slice(0, width - 1)}…` : record.content, + ttl: record.ttl, + prio: record.prio && record.prio !== '0' ? record.prio : '', + })); + + const header = { id: 'ID', type: 'TYPE', name: 'NAME', content: 'CONTENT', ttl: 'TTL', prio: 'PRIO' }; + const all = [header, ...rows]; + const widthOf = (key: keyof typeof header): number => + Math.max(...all.map((row) => row[key].length)); + + const line = (row: typeof header): string => + [ + row.id.padEnd(widthOf('id')), + row.type.padEnd(widthOf('type')), + row.name.padEnd(widthOf('name')), + row.content.padEnd(widthOf('content')), + row.ttl.padStart(widthOf('ttl')), + row.prio.padStart(widthOf('prio')), + ] + .join(' ') + .trimEnd(); + + return [line(header), ...rows.map(line)].join('\n'); +} + +export function formatForwards(forwards: readonly UrlForward[], domain: string): string { + if (forwards.length === 0) return 'no URL forwarding'; + return forwards + .map( + (forward) => + `${forward.id} ${fqdn(domain, forward.subdomain)} -> ${forward.location}` + + ` (${forward.type}${forward.wildcard === 'yes' ? ', wildcard' : ''}` + + `${forward.includePath === 'yes' ? ', includes path' : ''})`, + ) + .join('\n'); +} + +/* ------------------------------------------------------------------------- * + * Operations + * ------------------------------------------------------------------------- */ + +export async function ping(call: Caller): Promise { + const body = await call('/ping'); + return typeof body.yourIp === 'string' ? body.yourIp : 'unknown'; +} + +export async function listDomains(call: Caller): Promise { + const body = await call('/domain/listAll'); + const domains = Array.isArray(body.domains) ? body.domains : []; + return domains + .map((entry) => (entry && typeof entry === 'object' ? (entry as { domain?: unknown }).domain : null)) + .filter((name): name is string => typeof name === 'string') + .sort(); +} + +export async function listRecords(call: Caller, domain: string): Promise { + const body = await call(`/dns/retrieve/${domain}`); + return Array.isArray(body.records) ? (body.records as DnsRecord[]) : []; +} + +export async function listForwards(call: Caller, domain: string): Promise { + const body = await call(`/domain/getUrlForwarding/${domain}`); + return Array.isArray(body.forwards) ? (body.forwards as UrlForward[]) : []; +} + +export interface RecordInput { + host: string; + type: string; + content: string; + ttl?: number; + prio?: number; +} + +function recordBody(domain: string, input: RecordInput): Record { + const body: Record = { + name: hostLabel(domain, input.host), + type: input.type.toUpperCase(), + content: input.content, + ttl: String(Math.max(MIN_TTL, input.ttl ?? MIN_TTL)), + }; + if (input.prio !== undefined) body.prio = String(input.prio); + return body; +} + +export async function createRecord(call: Caller, domain: string, input: RecordInput): Promise { + const body = await call(`/dns/create/${domain}`, recordBody(domain, input)); + return String(body.id ?? ''); +} + +export async function editRecord( + call: Caller, + domain: string, + id: string, + input: RecordInput, +): Promise { + await call(`/dns/edit/${domain}/${id}`, recordBody(domain, input)); +} + +export async function deleteRecord(call: Caller, domain: string, id: string): Promise { + await call(`/dns/delete/${domain}/${id}`); +} + +export async function deleteForward(call: Caller, domain: string, id: string): Promise { + await call(`/domain/deleteUrlForward/${domain}/${id}`); +} + +/** Records matching a host (and optionally a type), for `get`, `set` and `delete`. */ +export function matchRecords( + records: readonly DnsRecord[], + domain: string, + host: string, + type?: string, +): DnsRecord[] { + const label = hostLabel(domain, host); + const wanted = type?.toUpperCase(); + return records.filter( + (record) => hostLabel(domain, record.name) === label && (!wanted || record.type === wanted), + ); +} + +export type SetOutcome = { action: 'created' | 'updated' | 'unchanged'; id: string }; + +/** + * Upsert one record. + * + * `set` exists because the obvious two-step — delete, then create — has a + * window where the name does not resolve at all, and because it needs the + * record id, which means a list call the user did not ask for. Editing in place + * keeps the id and the TTL clock. + * + * More than one record of the same name and type (a legitimate thing: two TXT + * values, several A records) is refused rather than guessed at. Picking one to + * overwrite would silently drop the other. + */ +export async function setRecord( + call: Caller, + domain: string, + input: RecordInput, +): Promise { + const existing = matchRecords(await listRecords(call, domain), domain, input.host, input.type); + + if (existing.length > 1) { + throw new PorkbunError( + `${existing.length} ${input.type.toUpperCase()} records already exist for ` + + `${fqdn(domain, hostLabel(domain, input.host))} — edit one by id, ` + + 'or delete them first. Refusing to guess which to replace.', + ); + } + + const current = existing[0]; + if (!current) { + return { action: 'created', id: await createRecord(call, domain, input) }; + } + + const ttl = String(Math.max(MIN_TTL, input.ttl ?? MIN_TTL)); + if (current.content === input.content && current.ttl === ttl) { + return { action: 'unchanged', id: current.id }; + } + + await editRecord(call, domain, current.id, input); + return { action: 'updated', id: current.id }; +} diff --git a/src/registry.ts b/src/registry.ts index 7bb34c1..5510996 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -41,6 +41,7 @@ const SUMMARIES: Record = { 'gh-prs': 'Every open PR across the owners you name', 'gh-prs-fix-all': 'Repair the open scan PRs that are broken because of us', 'gh-prs-merge': 'Squash-merge the PRs that are genuinely ready', + porkbun: 'Read and change DNS at Porkbun, and un-park a domain', tcfeed: 'Find repositories worth scanning, scan them, print a shortlist', tts: 'Read text aloud and keep the audio', vid: 'Inspect, thumbnail, clip and shrink video, through ffmpeg', diff --git a/test/porkbun.test.ts b/test/porkbun.test.ts new file mode 100644 index 0000000..453d711 --- /dev/null +++ b/test/porkbun.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from 'vitest'; + +import { + MIN_TTL, + PorkbunError, + type Caller, + type DnsRecord, + type UrlForward, + createRecord, + credentialsFrom, + formatForwards, + formatRecords, + fqdn, + hostLabel, + isParkingRecord, + listDomains, + listRecords, + matchRecords, + planUnpark, + porkbunCaller, + setRecord, + sortRecords, + unwrap, +} from '../src/porkbun.ts'; + +function record(partial: Partial & { name: string; type: string; content: string }): DnsRecord { + return { id: '1', ttl: '600', prio: null, ...partial }; +} + +/** A caller that records what it was asked and replays canned bodies. */ +function scripted(responses: Record>): { + call: Caller; + seen: { path: string; body: Record }[]; +} { + const seen: { path: string; body: Record }[] = []; + const call: Caller = async (path, body = {}) => { + seen.push({ path, body }); + const response = responses[path]; + if (!response) throw new Error(`unscripted call: ${path}`); + return response; + }; + return { call, seen }; +} + +describe('unwrap', () => { + it('returns the body on SUCCESS', () => { + expect(unwrap({ status: 'SUCCESS', id: 7 }, '/x')).toEqual({ status: 'SUCCESS', id: 7 }); + }); + + // The whole point: Porkbun says "no" with HTTP 200, so the body is the verdict. + it('throws on a 200 that carries an ERROR status', () => { + expect(() => unwrap({ status: 'ERROR', message: 'Invalid API key.' }, '/dns/retrieve/x')).toThrow( + /Invalid API key/, + ); + }); + + it('names the endpoint in the error', () => { + expect(() => unwrap({ status: 'ERROR', message: 'nope' }, '/dns/create/e.com')).toThrow( + /\/dns\/create\/e\.com/, + ); + }); + + it('rejects a non-object body rather than reading fields off it', () => { + expect(() => unwrap('502', '/ping')).toThrow(PorkbunError); + }); +}); + +describe('credentialsFrom', () => { + it('takes both keys from the environment', () => { + expect(credentialsFrom({ PORKBUN_API_KEY: 'k', PORKBUN_SECRET_API_KEY: 's' })).toEqual({ + apikey: 'k', + secretapikey: 's', + }); + }); + + // Half-set is the common mistake, so the message has to name the missing half. + it('names the secret when only the key is set', () => { + expect(() => credentialsFrom({ PORKBUN_API_KEY: 'k' })).toThrow(/PORKBUN_SECRET_API_KEY/); + }); + + it('names both when neither is set', () => { + expect(() => credentialsFrom({})).toThrow(/PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY/); + }); +}); + +describe('hostLabel', () => { + it('treats @, empty and the bare domain as the apex', () => { + expect(hostLabel('example.com', '@')).toBe(''); + expect(hostLabel('example.com', '')).toBe(''); + expect(hostLabel('example.com', 'example.com')).toBe(''); + }); + + it('accepts a bare label or the full name', () => { + expect(hostLabel('example.com', 'www')).toBe('www'); + expect(hostLabel('example.com', 'www.example.com')).toBe('www'); + }); + + it('keeps deeper labels intact', () => { + expect(hostLabel('example.com', '_railway-verify.www.example.com')).toBe('_railway-verify.www'); + expect(hostLabel('example.com', '_railway-verify.www')).toBe('_railway-verify.www'); + }); + + it('is case- and trailing-dot-insensitive', () => { + expect(hostLabel('Example.com', 'WWW.Example.com.')).toBe('www'); + }); + + // The bug this prevents: www.example.com.example.com + it('does not double the zone when given a full name', () => { + expect(fqdn('example.com', hostLabel('example.com', 'www.example.com'))).toBe('www.example.com'); + }); +}); + +describe('isParkingRecord', () => { + it('spots the apex ALIAS and the wildcard CNAME', () => { + expect(isParkingRecord(record({ name: 'e.com', type: 'ALIAS', content: 'uixie.porkbun.com' }))).toBe(true); + expect(isParkingRecord(record({ name: '*.e.com', type: 'CNAME', content: 'uixie.porkbun.com' }))).toBe(true); + }); + + // Deleting these would break mail or delegation, so they must never match. + it('leaves MX email forwarding and NS delegation alone', () => { + expect(isParkingRecord(record({ name: 'e.com', type: 'MX', content: 'fwd1.porkbun.com' }))).toBe(false); + expect(isParkingRecord(record({ name: 'e.com', type: 'NS', content: 'salvador.porkbun.com' }))).toBe(false); + }); + + it('leaves a CNAME to somewhere else alone', () => { + expect(isParkingRecord(record({ name: 'www.e.com', type: 'CNAME', content: 'app.up.railway.app' }))).toBe( + false, + ); + }); + + it('is not fooled by a lookalike suffix', () => { + expect(isParkingRecord(record({ name: 'e.com', type: 'CNAME', content: 'notporkbun.com' }))).toBe(false); + }); +}); + +describe('planUnpark', () => { + const parked: DnsRecord[] = [ + record({ id: '1', name: 'e.com', type: 'ALIAS', content: 'uixie.porkbun.com' }), + record({ id: '2', name: '*.e.com', type: 'CNAME', content: 'uixie.porkbun.com' }), + record({ id: '3', name: 'e.com', type: 'MX', content: 'fwd1.porkbun.com' }), + ]; + const forwards: UrlForward[] = [ + { id: '9', subdomain: '', location: 'http://e.l.ink', type: 'temporary', includePath: 'yes', wildcard: 'yes' }, + ]; + + it('collects the forward and only the parking records', () => { + const plan = planUnpark(parked, forwards); + expect(plan.forwards).toHaveLength(1); + expect(plan.records.map((r) => r.id)).toEqual(['1', '2']); + expect(plan.empty).toBe(false); + }); + + it('is empty for a domain that is already in use', () => { + const live = [record({ id: '1', name: 'www.e.com', type: 'CNAME', content: 'app.up.railway.app' })]; + expect(planUnpark(live, []).empty).toBe(true); + }); +}); + +describe('matchRecords', () => { + const records = [ + record({ id: '1', name: 'e.com', type: 'ALIAS', content: 'a' }), + record({ id: '2', name: 'www.e.com', type: 'CNAME', content: 'b' }), + record({ id: '3', name: 'www.e.com', type: 'TXT', content: 'c' }), + ]; + + it('matches the apex by @', () => { + expect(matchRecords(records, 'e.com', '@').map((r) => r.id)).toEqual(['1']); + }); + + it('matches a host across types, then narrows by type', () => { + expect(matchRecords(records, 'e.com', 'www').map((r) => r.id)).toEqual(['2', '3']); + expect(matchRecords(records, 'e.com', 'www', 'txt').map((r) => r.id)).toEqual(['3']); + }); +}); + +describe('formatting', () => { + it('puts the apex first and renders names fully qualified', () => { + const table = formatRecords( + [ + record({ id: '2', name: 'www.e.com', type: 'CNAME', content: 'b' }), + record({ id: '1', name: 'e.com', type: 'ALIAS', content: 'a' }), + ], + 'e.com', + ); + const [, first, second] = table.split('\n'); + expect(first).toMatch(/e\.com/); + expect(first).toMatch(/ALIAS/); + expect(second).toMatch(/www\.e\.com/); + }); + + it('truncates a long value instead of wrapping the table', () => { + const table = formatRecords( + [record({ name: 'e.com', type: 'TXT', content: 'x'.repeat(500) })], + 'e.com', + 20, + ); + expect(table).toContain('…'); + expect(table.split('\n').every((line) => line.length < 120)).toBe(true); + }); + + it('says so when there is nothing', () => { + expect(formatRecords([], 'e.com')).toBe('no records'); + expect(formatForwards([], 'e.com')).toBe('no URL forwarding'); + }); + + it('flags a wildcard forward, which is the one that swallows everything', () => { + const text = formatForwards( + [{ id: '9', subdomain: '', location: 'http://x', type: 'temporary', includePath: 'yes', wildcard: 'yes' }], + 'e.com', + ); + expect(text).toMatch(/wildcard/); + }); + + it('sorts stably by name then type', () => { + const sorted = sortRecords( + [ + record({ id: '3', name: 'b.e.com', type: 'A', content: 'x' }), + record({ id: '2', name: 'a.e.com', type: 'TXT', content: 'x' }), + record({ id: '1', name: 'a.e.com', type: 'A', content: 'x' }), + ], + 'e.com', + ); + expect(sorted.map((r) => r.id)).toEqual(['1', '2', '3']); + }); +}); + +describe('operations', () => { + it('reads the record list', async () => { + const { call } = scripted({ + '/dns/retrieve/e.com': { status: 'SUCCESS', records: [record({ name: 'e.com', type: 'A', content: '1.2.3.4' })] }, + }); + expect(await listRecords(call, 'e.com')).toHaveLength(1); + }); + + it('returns an empty list rather than throwing when the field is absent', async () => { + const { call } = scripted({ '/dns/retrieve/e.com': { status: 'SUCCESS' } }); + expect(await listRecords(call, 'e.com')).toEqual([]); + }); + + it('sorts the domain list', async () => { + const { call } = scripted({ + '/domain/listAll': { status: 'SUCCESS', domains: [{ domain: 'b.com' }, { domain: 'a.com' }] }, + }); + expect(await listDomains(call)).toEqual(['a.com', 'b.com']); + }); + + it('sends the apex as an empty name and floors the TTL', async () => { + const { call, seen } = scripted({ '/dns/create/e.com': { status: 'SUCCESS', id: 42 } }); + await createRecord(call, 'e.com', { host: '@', type: 'alias', content: 'x.up.railway.app', ttl: 30 }); + + expect(seen[0]?.body).toMatchObject({ name: '', type: 'ALIAS', ttl: String(MIN_TTL) }); + }); +}); + +describe('setRecord', () => { + const existing = record({ id: '5', name: 'www.e.com', type: 'CNAME', content: 'old.example', ttl: '600' }); + + it('creates when nothing is there', async () => { + const { call, seen } = scripted({ + '/dns/retrieve/e.com': { status: 'SUCCESS', records: [] }, + '/dns/create/e.com': { status: 'SUCCESS', id: 11 }, + }); + expect(await setRecord(call, 'e.com', { host: 'www', type: 'CNAME', content: 'new.example' })).toEqual({ + action: 'created', + id: '11', + }); + expect(seen.map((s) => s.path)).toContain('/dns/create/e.com'); + }); + + // Editing in place keeps the id, and avoids the delete-then-create window + // where the name does not resolve at all. + it('edits in place when one already exists', async () => { + const { call, seen } = scripted({ + '/dns/retrieve/e.com': { status: 'SUCCESS', records: [existing] }, + '/dns/edit/e.com/5': { status: 'SUCCESS' }, + }); + expect(await setRecord(call, 'e.com', { host: 'www', type: 'CNAME', content: 'new.example' })).toEqual({ + action: 'updated', + id: '5', + }); + expect(seen.map((s) => s.path)).not.toContain('/dns/create/e.com'); + }); + + it('does nothing when the value already matches', async () => { + const { call, seen } = scripted({ + '/dns/retrieve/e.com': { status: 'SUCCESS', records: [existing] }, + }); + expect(await setRecord(call, 'e.com', { host: 'www', type: 'CNAME', content: 'old.example' })).toEqual({ + action: 'unchanged', + id: '5', + }); + expect(seen).toHaveLength(1); + }); + + // Two TXT values on one name is legitimate; silently replacing one is not. + it('refuses to guess which of several to replace', async () => { + const { call } = scripted({ + '/dns/retrieve/e.com': { + status: 'SUCCESS', + records: [ + record({ id: '1', name: 'e.com', type: 'TXT', content: 'one' }), + record({ id: '2', name: 'e.com', type: 'TXT', content: 'two' }), + ], + }, + }); + await expect(setRecord(call, 'e.com', { host: '@', type: 'TXT', content: 'three' })).rejects.toThrow( + /Refusing to guess/, + ); + }); +}); + +describe('porkbunCaller', () => { + it('posts credentials in the body, not a header', async () => { + let captured: { url: string; init: RequestInit } | null = null; + const fetcher = (async (url: string, init: RequestInit) => { + captured = { url, init }; + return { status: 200, text: async () => JSON.stringify({ status: 'SUCCESS', yourIp: '1.1.1.1' }) }; + }) as unknown as typeof fetch; + + const call = porkbunCaller({ apikey: 'k', secretapikey: 's' }, 1000, fetcher); + await call('/ping'); + + expect(captured!.url).toBe('https://api.porkbun.com/api/json/v3/ping'); + expect(captured!.init.method).toBe('POST'); + expect(JSON.parse(String(captured!.init.body))).toMatchObject({ apikey: 'k', secretapikey: 's' }); + }); + + it('reports a non-JSON body as the edge failing, not as bad JSON', async () => { + const fetcher = (async () => ({ status: 502, text: async () => 'bad gateway' })) as unknown as typeof fetch; + const call = porkbunCaller({ apikey: 'k', secretapikey: 's' }, 1000, fetcher); + await expect(call('/ping')).rejects.toThrow(/HTTP 502.*not JSON/s); + }); +});