#!/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 [, 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();