commit 66f3f35660bb55422fb7c2f3966852288d3e97d3 Author: William Mantly Date: Tue Feb 10 15:11:42 2026 +0000 Initial commit - Porkbun DNS management skill diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..05a703e --- /dev/null +++ b/SKILL.md @@ -0,0 +1,178 @@ +--- +name: porkbun +description: Manage Porkbun DNS records and domains via API v3. Use when Codex needs to create, read, update, or delete DNS records on Porkbun; list domains; configure API access; work with common record types (A, AAAA, CNAME, MX, TXT, etc.). The skill includes a CLI tool `scripts/porkbun-dns.js` for executing DNS operations reliably. +--- + +# Porkbun DNS Management + +Manage DNS records and domains on Porkbun via their REST API v3. + +## Quick Start + +### Set up API credentials + +1. Generate API keys: https://porkbun.com/account/api +2. Save credentials to config file: `~/.config/porkbun/config.json` +```json +{ + "apiKey": "your-api-key", + "secretApiKey": "your-secret-api-key" +} +``` + +Or set environment variables: +```bash +export PORKBUN_API_KEY="your-api-key" +export PORKBUN_SECRET_API_KEY="your-secret-api-key" +``` + +3. Enable API access for each domain: Domain Management → Details → API Access → Enable + +### Test connection + +```bash +node ~/.openclaw/workspace/skills/public/porkbun/scripts/porkbun-dns.js ping +``` + +## Using the CLI Tool + +The `scripts/porkbun-dns.js` script provides a reliable, deterministic way to execute DNS operations. Use it directly for common tasks instead of writing custom code. + +### Common Operations + +#### List domains +```bash +node scripts/porkbun-dns.js list +``` + +#### List DNS records +```bash +node scripts/porkbun-dns.js records example.com +``` + +#### Create records +```bash +# A record +node scripts/porkbun-dns.js create example.com type=A name=www content=1.1.1.1 ttl=600 + +# CNAME +node scripts/porkbun-dns.js create example.com type=CNAME name=docs content=example.com + +# MX record +node scripts/porkbun-dns.js create example.com type=MX name= content="mail.example.com" prio=10 + +# TXT record ( SPF for email) +node scripts/porkbun-dns.js create example.com type=TXT name= content="v=spf1 include:_spf.google.com ~all" +``` + +#### Edit records +```bash +# By ID (get ID from records command) +node scripts/porkbun-dns.js edit example.com 123456 content=2.2.2.2 + +# By type and subdomain (updates all matching records) +node scripts/porkbun-dns.js edit-by example.com A www content=2.2.2.2 +``` + +#### Delete records +```bash +# By ID +node scripts/porkbun-dns.js delete example.com 123456 + +# By type and subdomain +node scripts/porkbun-dns.js delete-by example.com A www +``` + +#### Get specific records +```bash +# All records +node scripts/porkbun-dns.js get example.com + +# Filter by type +node scripts/porkbun-dns.js get example.com A + +# Filter by type and subdomain +node scripts/porkbun-dns.js get example.com A www +``` + +## Record Types + +Supported record types: A, AAAA, CNAME, ALIAS, TXT, NS, MX, SRV, TLSA, CAA, HTTPS, SVCB, SSHFP + +For detailed field requirements and examples, see [references/dns-record-types.md](references/dns-record-types.md). + +## Common Patterns + +### Website Setup + +Create root A record and www CNAME: +```bash +node scripts/porkbun-dns.js create example.com type=A name= content=192.0.2.1 +node scripts/porkbun-dns.js create example.com type=CNAME name=www content=example.com +``` + +### Email Configuration + +Set up MX records for Google Workspace: +```bash +node scripts/porkbun-dns.js create example.com type=MX name= content="aspmx.l.google.com" prio=1 +node scripts/porkbun-dns.js create example.com type=MX name= content="alt1.aspmx.l.google.com" prio=5 +node scripts/porkbun-dns.js create example.com type=MX name= content="alt2.aspmx.l.google.com" prio=5 +node scripts/porkbun-dns.js create example.com type=MX name= content="alt3.aspmx.l.google.com" prio=10 +node scripts/porkbun-dns.js create example.com type=MX name= content="alt4.aspmx.l.google.com" prio=10 +``` + +Add SPF record: +```bash +node scripts/porkbun-dns.js create example.com type=TXT name= content="v=spf1 include:_spf.google.com ~all" +``` + +### Dynamic DNS + +Update home IP address (can be scripted/automated): +```bash +HOME_IP=$(curl -s ifconfig.me) +node scripts/porkbun-dns.js edit-by example.com A home content=$HOME_IP +``` + +### Wildcard DNS + +Create a wildcard record pointing to root: +```bash +node scripts/porkbun-dns.js create example.com type=A name=* content=192.0.2.1 +``` + +## Reference Documentation + +- **[references/dns-record-types.md](references/dns-record-types.md)** - Detailed reference for all DNS record types and field requirements +- **[https://porkbun.com/api/json/v3/documentation](https://porkbun.com/api/json/v3/documentation)** - Full API documentation + +## Troubleshooting + +### "API key not found" +- Verify config file exists at `~/.config/porkbun/config.json` +- Check environment variables: `echo $PORKBUN_API_KEY` +- Ensure API access is enabled for the specific domain + +### "Invalid type passed" +- Record types must be uppercase (e.g., `A`, not `a`) +- See supported types list above + +### HTTP errors +- Verify API keys are valid at https://porkbun.com/account/api +- Check network connectivity +- Confirm API endpoint is `api.porkbun.com` (not `porkbun.com`) + +### TTL errors +- Minimum TTL is 600 seconds (10 minutes) +- Default TTL is 600 seconds +- Common values: 300 (dynamic), 3600 (standard), 86400 (stable) + +## Notes + +- TTL minimum is 600 seconds +- Use "@" for root domain records +- Use "*" for wildcard records +- TXT records with spaces need quotes +- Multiple MX records allowed with different priorities +- API v3 current hostname: `api.porkbun.com` \ No newline at end of file diff --git a/references/dns-record-types.md b/references/dns-record-types.md new file mode 100644 index 0000000..196f660 --- /dev/null +++ b/references/dns-record-types.md @@ -0,0 +1,114 @@ +# DNS Record Types Reference + +Quick reference for common DNS record types and field requirements. + +## Record Types + +### A (Address) +Maps a hostname to an IPv4 address. +- **name**: Hostname (e.g., "www", "@" for root, "*" for wildcard) +- **content**: IPv4 address (e.g., "1.1.1.1") +- **ttl**: Time to live in seconds (min 600, default 600) + +### AAAA (IPv6 Address) +Maps a hostname to an IPv6 address. +- **name**: Hostname +- **content**: IPv6 address (e.g., "2001:0db8:85a3:0000:0000:8a2e:0370:7334") +- **ttl**: Time to live + +### CNAME (Canonical Name) +Creates an alias for another domain name. +- **name**: Alias hostname (e.g., "www") +- **content**: Target domain (e.g., "example.com") +- **ttl**: Time to live + +### MX (Mail Exchange) +Specifies mail servers for the domain. +- **name**: Usually "@" (root domain) +- **content**: Mail server hostname (e.g., "mail.example.com") +- **prio**: Priority (lower = higher preference, e.g., 10, 20) +- **ttl**: Time to live + +### TXT (Text) +Stores text data, commonly used for SPF, DKIM, verification. +- **name**: Hostname +- **content**: Text string (quotes for spaces, e.g., "v=spf1 include:_spf.google.com ~all") +- **ttl**: Time to live + +### NS (Name Server) +Specifies authoritative name servers for the domain. +- **name**: Usually "@" (at delegation points) or subdomain name +- **content**: Nameserver hostname (e.g., "ns1.example.com") +- **ttl**: Time to live + +### ALIAS +Similar to CNAME but works at the root domain level. +- **name**: Usually "@" +- **content**: Target domain or hostname +- **ttl**: Time to live + +### SRV (Service) +Specifies location of services (e.g., LDAP, VoIP). +- **name**: Service name format: `_service._protocol` (e.g., "_sip._tcp") +- **content**: Priority, weight, port, target (e.g., "10 60 5060 sipserver.example.com") +- **prio**: Not used for SRV +- **ttl**: Time to live + +### CAA (Certification Authority Authorization) +Restricts which CAs can issue certificates for your domain. +- **name**: Usually "@" +- **content**: Flags tag value (e.g., "0 issueletsencrypt.org") +- **ttl**: Time to live + +### TLSA (TLS Authentication) +Specifies TLS certificate information for DANE. +- **name**: Port format: `_port._protocol` (e.g., "_443._tcp") +- **content**: Certificate usage, selector, matching type, certificate association data +- **ttl**: Time to live + +### HTTPS / SVCB (Service Binding) +Modern DNS record types for HTTPS and service binding. +- **name**: Hostname +- **content**: Priority, target, service params +- **ttl**: Time to live + +### SSHFP (SSH Fingerprint) +Stores SSH public key fingerprints. +- **name**: Hostname +- **content**: Algorithm, type, fingerprint (hex) +- **ttl**: Time to live + +## Common TTL Values + +- **300** (5 minutes) - Fast-changing records (dynamic DNS) +- **600** (10 minutes) - Default minimum, common for dynamic records +- **3600** (1 hour) - Standard for most records +- **86400** (24 hours) - Stable records with infrequent changes + +## MX Priority Pattern + +Typical MX configuration: +- Priority 10: Primary mail server +- Priority 20: Secondary/failover mail server +- Priority 30: Tertiary backup mail server + +Email delivery tries lowest priority first. + +## Example TXT Records + +### SPF (Sender Policy Framework) +``` +v=spf1 include:_spf.google.com ~all +v=spf1 ip4:192.0.2.0/24 -all +v=spf1 a mx include:sendgrid.net ~all +``` + +### Domain Verification +``` +google-site-verification=your-verification-token +``` + +### DKIM +``` +k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC... +``` \ No newline at end of file diff --git a/scripts/porkbun-dns.js b/scripts/porkbun-dns.js new file mode 100755 index 0000000..482d9fb --- /dev/null +++ b/scripts/porkbun-dns.js @@ -0,0 +1,424 @@ +#!/usr/bin/env node +/** + * Porkbun DNS Management Tool + * Interact with Porkbun API v3 for DNS management + * + * API Docs: https://porkbun.com/api/json/v3/documentation + * + * Usage: + * node porkbun-dns.js [options] + * + * Commands: + * ping Test API connection + * list List all domains + * list-domains Alias for list + * records List all DNS records for a domain + * get [type] [name] Get specific record(s) + * create Create a DNS record + * edit Edit a record by ID + * edit-by [name] Edit by type/subdomain + * delete Delete a record by ID + * delete-by [name] Delete by type/subdomain + * + * Environment (required): + * PORKBUN_API_KEY Your API key + * PORKBUN_SECRET_API_KEY Your secret API key + * + * Or use config file: ~/.config/porkbun/config.json + */ + +'use strict'; + +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { URL } = require('url'); + +class PorkbunAPI { + constructor(apiKey, secretApiKey) { + this.apiKey = apiKey; + this.secretApiKey = secretApiKey; + this.baseUrl = 'https://api.porkbun.com/api/json/v3'; + } + + async _request(endpoint, data = {}) { + return new Promise((resolve, reject) => { + const url = new URL(`${this.baseUrl}${endpoint}`); + const payload = JSON.stringify({ + ...data, + secretapikey: this.secretApiKey, + apikey: this.apiKey, + }); + + const options = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }; + + const protocol = url.protocol === 'https:' ? https : http; + const req = protocol.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => body += chunk); + res.on('end', () => { + try { + const json = JSON.parse(body); + if (res.statusCode !== 200 || json.status !== 'SUCCESS') { + reject(new Error(json.message || `HTTP ${res.statusCode}`)); + } else { + resolve(json); + } + } catch (e) { + reject(new Error(`Invalid JSON response: ${body}`)); + } + }); + }); + + req.on('error', reject); + req.write(payload); + req.end(); + }); + } + + // Ping - Test API connection + async ping() { + return await this._request('/ping'); + } + + // List all domains + async listDomains() { + return await this._request('/domain/listAll', { includeLabels: 'yes' }); + } + + // Get all DNS records for a domain + async getRecords(domain) { + return await this._request(`/dns/retrieve/${domain}`); + } + + // Get DNS records by domain, type, and optionally subdomain + async getRecordsByNameType(domain, type, name = null) { + const endpoint = name + ? `/dns/retrieveByNameType/${domain}/${type}/${name}` + : `/dns/retrieveByNameType/${domain}/${type}`; + return await this._request(endpoint); + } + + // Create a DNS record + async createRecord(domain, record) { + const required = ['type', 'content']; + for (const field of required) { + if (!record[field]) { + throw new Error(`Missing required field: ${field}`); + } + } + + const validTypes = ['A', 'MX', 'CNAME', 'ALIAS', 'TXT', 'NS', 'AAAA', 'SRV', 'TLSA', 'CAA', 'HTTPS', 'SVCB', 'SSHFP']; + if (!validTypes.includes(record.type)) { + throw new Error(`Invalid type: ${record.type}. Valid types: ${validTypes.join(', ')}`); + } + + return await this._request(`/dns/create/${domain}`, record); + } + + // Edit a record by ID + async editRecord(domain, id, record) { + return await this._request(`/dns/edit/${domain}/${id}`, record); + } + + // Edit records by type and subdomain + async editRecordByNameType(domain, type, record, name = null) { + const endpoint = name + ? `/dns/editByNameType/${domain}/${type}/${name}` + : `/dns/editByNameType/${domain}/${type}`; + return await this._request(endpoint, record); + } + + // Delete a record by ID + async deleteRecord(domain, id) { + return await this._request(`/dns/delete/${domain}/${id}`); + } + + // Delete records by type and subdomain + async deleteRecordByNameType(domain, type, name = null) { + const endpoint = name + ? `/dns/deleteByNameType/${domain}/${type}/${name}` + : `/dns/deleteByNameType/${domain}/${type}`; + return await this._request(endpoint); + } +} + +// Load config from file +function loadConfig() { + const configPath = path.join(process.env.HOME, '.config', 'porkbun', 'config.json'); + if (fs.existsSync(configPath)) { + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + return { + apiKey: config.apiKey || process.env.PORKBUN_API_KEY, + secretApiKey: config.secretApiKey || process.env.PORKBUN_SECRET_API_KEY, + }; + } catch (e) { + console.error(`Error reading config file: ${e.message}`); + } + } + return null; +} + +// Initialize API client +function createAPI() { + let apiKey = process.env.PORKBUN_API_KEY; + let secretApiKey = process.env.PORKBUN_SECRET_API_KEY; + + // Try config file if env vars not set + if (!apiKey || !secretApiKey) { + const config = loadConfig(); + if (config) { + apiKey = apiKey || config.apiKey; + secretApiKey = secretApiKey || config.secretApiKey; + } + } + + if (!apiKey || !secretApiKey) { + console.error('Error: PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY must be set'); + console.error(''); + console.error('Either set environment variables or create a config file:'); + console.error(' ~/.config/porkbun/config.json'); + console.error(''); + console.error('Config file format:'); + console.err(JSON.stringify({ + apiKey: 'your-api-key', + secretApiKey: 'your-secret-api-key', + }, null, 2)); + process.exit(1); + } + + return new PorkbunAPI(apiKey, secretApiKey); +} + +// Print helper functions +function printJSON(data) { + console.log(JSON.stringify(data, null, 2)); +} + +function printTable(data) { + if (!data.length) { + console.log('No records found.'); + return; + } + + const columns = ['id', 'name', 'type', 'content', 'ttl', 'prio']; + const widths = {}; + columns.forEach(col => widths[col] = Math.max(col.length, ...data.map(r => String(r[col] || '').length))); + + // Header + console.log(columns.map(col => col.padEnd(widths[col])).join(' | ')); + console.log(columns.map(() => '-'.repeat(40)).join('-+-')); + + // Rows + data.forEach(row => { + console.log( + columns.map(col => { + let val = String(row[col] || ''); + if (col === 'content' && val.length > widths[col]) { + val = val.slice(0, widths[col] - 3) + '...'; + } + return val.padEnd(widths[col]); + }).join(' | ') + ); + }); +} + +// CLI Handler +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') { + console.log(fs.readFileSync(__filename, 'utf8').match(/\/\*\*[\s\S]*?\*\//)[0] + .replace(/\/\*\*|\*\//g, '') + .replace(/^\s*\*\s?/gm, '')); + process.exit(0); + } + + const api = createAPI(); + const command = args[0]; + + try { + switch (command) { + case 'ping': + const ping = await api.ping(); + printJSON(ping); + break; + + case 'list': + case 'list-domains': + const domains = await api.listDomains(); + console.log(`${domains.domains.length} domain(s) found:\n`); + domains.domains.forEach(d => { + console.log(` ${d.domain} (${d.status})`); + console.log(` Expires: ${d.expireDate}`); + console.log(` Auto-renew: ${d.autoRenew ? 'ON' : 'OFF'}`); + if (d.labels && d.labels.length) { + console.log(` Labels: ${d.labels.map(l => l.title).join(', ')}`); + } + }); + break; + + case 'records': { + const domain = args[1]; + if (!domain) { + console.error('Error: domain required'); + console.error('Usage: node porkbun-dns.js records '); + process.exit(1); + } + const records = await api.getRecords(domain); + console.log(`${records.records.length} DNS record(s) for ${domain}:\n`); + printTable(records.records); + break; + } + + case 'get': { + const [_, _cmd, domain, type, name] = args; + if (!domain) { + console.error('Error: domain required'); + console.error('Usage: node porkbun-dns.js get [type] [name]'); + process.exit(1); + } + let result; + if (type) { + result = await api.getRecordsByNameType(domain, type, name); + } else { + result = await api.getRecords(domain); + } + console.log(`${result.records.length} record(s) found:\n`); + printTable(result.records); + break; + } + + case 'create': { + const [_, _cmd, domain, ...recordArgs] = args; + if (!domain) { + console.error('Error: domain required'); + console.error('Usage: node porkbun-dns.js create '); + console.error('Then provide record fields as key=value pairs:'); + console.error(' type=A name=www content=1.1.1.1 ttl=600'); + process.exit(1); + } + + // Parse record arguments + const record = {}; + for (const arg of recordArgs) { + const [key, ...valParts] = arg.split('='); + const value = valParts.join('='); + if (!value) { + console.error(`Error: Invalid argument format: ${arg}`); + console.error('Expected: key=value'); + process.exit(1); + } + record[key] = value; + } + + const result = await api.createRecord(domain, record); + console.log(`✅ Record created successfully!`); + console.log(` ID: ${result.id}`); + break; + } + + case 'edit': { + const [_, _cmd, domain, id, ...recordArgs] = args; + if (!domain || !id) { + console.error('Error: domain and ID required'); + console.error('Usage: node porkbun-dns.js edit '); + console.error('Then provide record fields to update as key=value pairs:'); + console.error(' content=1.1.1.2 ttl=600'); + process.exit(1); + } + + const record = {}; + for (const arg of recordArgs) { + const [key, ...valParts] = arg.split('='); + const value = valParts.join('='); + if (!value) { + console.error(`Error: Invalid argument format: ${arg}`); + process.exit(1); + } + record[key] = value; + } + + await api.editRecord(domain, id, record); + console.log(`✅ Record ${id} updated successfully!`); + break; + } + + case 'edit-by': { + const [_, _cmd, domain, type, ...rest] = args; + if (!domain || !type) { + console.error('Error: domain and type required'); + console.error('Usage: node porkbun-dns.js edit-by [name]'); + console.error('Then provide record fields to update as key=value pairs:'); + console.error(' content=1.1.1.2 ttl=600'); + process.exit(1); + } + + const name = rest[0] && rest[0].includes('=') ? null : rest[0]; + const recordArgs = name ? rest.slice(1) : rest; + + const record = {}; + for (const arg of recordArgs) { + const [key, ...valParts] = arg.split('='); + const value = valParts.join('='); + if (!value) { + console.error(`Error: Invalid argument format: ${arg}`); + process.exit(1); + } + record[key] = value; + } + + await api.editRecordByNameType(domain, type, record, name); + console.log(`✅ Records for ${domain}${name ? `/${name}` : ''} (${type}) updated successfully!`); + break; + } + + case 'delete': { + const [_, _cmd, domain, id] = args; + if (!domain || !id) { + console.error('Error: domain and ID required'); + console.error('Usage: node porkbun-dns.js delete '); + process.exit(1); + } + + await api.deleteRecord(domain, id); + console.log(`✅ Record ${id} deleted successfully!`); + break; + } + + case 'delete-by': { + const [_, _cmd, domain, type, name = null] = args; + if (!domain || !type) { + console.error('Error: domain and type required'); + console.error('Usage: node porkbun-dns.js delete-by [name]'); + process.exit(1); + } + + await api.deleteRecordByNameType(domain, type, name); + console.log(`✅ Records for ${domain}${name ? `/${name}` : ''} (${type}) deleted successfully!`); + break; + } + + default: + console.error(`Unknown command: ${command}`); + console.error('Run: node porkbun-dns.js help'); + process.exit(1); + } + } catch (error) { + console.error(`❌ Error: ${error.message}`); + process.exit(1); + } +} + +main(); \ No newline at end of file