Created
March 8, 2025 19:29
-
-
Save jamestharpe/2568f47831519a2e74674a4b12761eec to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
/** | |
* Usage: node get-dns.js <domain> | |
* Example: node get-dns.js crittercontrol.com | |
* | |
* This script: | |
* 1. Queries DNS records for the apex domain and a list of possible subdomains. | |
* 2. Ensures the apex domain (@) always has an SOA record, creating a default if none is found. | |
* 3. Filters out invalid record conflicts (CNAME vs. A/AAAA/MX/etc. on the same label). | |
* 4. Generates a zone file with $TTL and $ORIGIN at the top, referencing subdomains as relative labels. | |
* 5. Outputs a summary of how many DNS records and how many domain labels were discovered. | |
*/ | |
const dns = require("dns").promises; | |
const fs = require("fs"); | |
const path = require("path"); | |
// Default TTL (in seconds) | |
const DEFAULT_TTL = 3600; | |
// List of record types to query | |
const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "NS", "TXT", "SOA", "SRV"]; | |
// Expand our subdomain dictionary: | |
const SUBDOMAINS = [ | |
"_domainconnect", | |
"about", | |
"account", | |
"accounts", | |
"admin", | |
"ads", | |
"advertising", | |
"alpha", | |
"analysis", | |
"analytics", | |
"api", | |
"app", | |
"apps", | |
"assets", | |
"au", | |
"auth", | |
"autoconfig", | |
"autodiscover", | |
"backend", | |
"beta", | |
"billing", | |
"blog", | |
"board", | |
"ca", | |
"careers", | |
"cart", | |
"cdn", | |
"chat", | |
"checkout", | |
"ci", | |
"cicd", | |
"cloud", | |
"community", | |
"console", | |
"cpanel", | |
"dashboard", | |
"de", | |
"demo", | |
"dev", | |
"developer", | |
"developers", | |
"development", | |
"devops", | |
"discussion", | |
"docs", | |
"download", | |
"downloads", | |
"en", | |
"es", | |
"events", | |
"extranet", | |
"faq", | |
"feedback", | |
"file", | |
"files", | |
"forum", | |
"forums", | |
"fr", | |
"frontend", | |
"ftp", | |
"get", | |
"git", | |
"github", | |
"gitlab", | |
"go", | |
"gql", | |
"graphql", | |
"help", | |
"helpdesk", | |
"hr", | |
"images", | |
"imap", | |
"img", | |
"internal", | |
"intranet", | |
"investor", | |
"investors", | |
"invoice", | |
"invoices", | |
"jobs", | |
"join", | |
"kb", | |
"labs", | |
"landing", | |
"lists", | |
"live", | |
"login", | |
"m", | |
"mail", | |
"market", | |
"media", | |
"metrics", | |
"mobile", | |
"monitor", | |
"my", | |
"news", | |
"oauth", | |
"opportunities", | |
"panel", | |
"partners", | |
"payment", | |
"payments", | |
"pop", | |
"portal", | |
"pre-production", | |
"preprod", | |
"preprod-env", | |
"press", | |
"private", | |
"promo", | |
"qa", | |
"register", | |
"rss", | |
"sandbox", | |
"secure", | |
"security", | |
"server", | |
"sftp", | |
"shop", | |
"signin", | |
"signup", | |
"slack", | |
"smtp", | |
"sso", | |
"stage", | |
"staging", | |
"static", | |
"statistics", | |
"stats", | |
"status", | |
"store", | |
"supplier", | |
"support", | |
"test", | |
"testing", | |
"tickets", | |
"track", | |
"tracking", | |
"try", | |
"uk", | |
"upload", | |
"uploads", | |
"us", | |
"vendors", | |
"vpn", | |
"webdisk", | |
"webmail", | |
"what", | |
"whm", | |
"who", | |
"why", | |
"www", | |
]; | |
// Retrieve DNS records for a single domain across multiple record types | |
async function getAllRecordsForDomain(domain) { | |
const results = {}; | |
for (const type of RECORD_TYPES) { | |
try { | |
const records = await dns.resolve(domain, type); | |
if (records && records.length > 0) { | |
results[type] = records; | |
} | |
} catch (err) { | |
// Domain doesn't have that record type or there's an error. Ignore. | |
} | |
} | |
return results; | |
} | |
/** | |
* Convert a fully qualified hostname into a "relative" label for the apex domain. | |
* E.g.: | |
* domain = "example.com" | |
* getRelativeName("example.com", "example.com") -> "@" | |
* getRelativeName("mail.example.com", "example.com") -> "mail" | |
*/ | |
function getRelativeName(hostname, apexDomain) { | |
if (hostname === apexDomain) return "@"; | |
const suffix = "." + apexDomain; | |
if (hostname.endsWith(suffix)) { | |
return hostname.slice(0, -suffix.length); | |
} | |
// Fallback: return the entire hostname if it doesn't match exactly | |
return hostname; | |
} | |
/** | |
* Generate a default SOA record object if none is found. | |
* Adjust values (nsname, hostmaster, etc.) as desired. | |
*/ | |
function generateDefaultSoa(domain) { | |
const now = new Date(); | |
// Format an ISO-like date string: YYYYMMDDnn (nn = 2-digit counter) | |
const datePart = now.toISOString().slice(0, 10).replace(/-/g, ""); | |
// Minimal approach: take today's date and add 01 | |
const serial = parseInt(`${datePart}01`, 10); | |
return { | |
nsname: `ns1.${domain}`, // Primary nameserver | |
hostmaster: `hostmaster.${domain}`, // Hostmaster email -> hostmaster@domain | |
serial, | |
refresh: 7200, // 2 hours | |
retry: 3600, // 1 hour | |
expire: 1209600, // 14 days | |
minttl: 3600, // 1 hour | |
}; | |
} | |
(async function main() { | |
if (process.argv.length < 3) { | |
console.error("Usage: node get-dns.js <domain>"); | |
process.exit(1); | |
} | |
const domain = process.argv[2].toLowerCase().trim(); | |
// 1. Gather DNS records for the apex domain | |
const recordsMap = {}; | |
recordsMap[domain] = await getAllRecordsForDomain(domain); | |
// 2. Gather DNS records for subdomains | |
const subdomainList = SUBDOMAINS.map((sub) => `${sub}.${domain}`); | |
for (const sub of subdomainList) { | |
const subRecords = await getAllRecordsForDomain(sub); | |
if (Object.keys(subRecords).length > 0) { | |
recordsMap[sub] = subRecords; | |
} | |
} | |
// 3. If apex domain does not have an SOA record, add a default | |
if (!recordsMap[domain].SOA) { | |
recordsMap[domain].SOA = [generateDefaultSoa(domain)]; | |
} | |
// 4. Remove invalid record conflicts | |
// If a label has a CNAME, we discard other record types for that label | |
for (const [hostname, recordTypes] of Object.entries(recordsMap)) { | |
if (recordTypes.CNAME && recordTypes.CNAME.length > 0) { | |
// Keep the CNAME; discard the rest | |
const cnameRecords = recordTypes.CNAME; | |
Object.keys(recordTypes).forEach((type) => delete recordTypes[type]); | |
recordTypes.CNAME = cnameRecords; | |
} | |
} | |
// 5. Build a single zone file with a single $ORIGIN and $TTL | |
const zoneLines = []; | |
zoneLines.push(`$TTL ${DEFAULT_TTL}`); | |
zoneLines.push(`$ORIGIN ${domain}.`); | |
// We'll collect lines for each label | |
const recordLines = []; | |
for (const [hostname, recordSet] of Object.entries(recordsMap)) { | |
const relativeName = getRelativeName(hostname, domain); | |
for (const [type, values] of Object.entries(recordSet)) { | |
for (const val of values) { | |
switch (type) { | |
case "A": | |
case "AAAA": | |
recordLines.push(`${relativeName} IN ${type} ${val}`); | |
break; | |
case "CNAME": | |
// Must ensure it ends with a dot if referencing a FQDN | |
recordLines.push( | |
`${relativeName} IN CNAME ${val.endsWith(".") ? val : val + "."}` | |
); | |
break; | |
case "MX": | |
// E.g. "mail IN MX priority mailhost." | |
if (typeof val === "object" && val.exchange) { | |
recordLines.push( | |
`${relativeName} IN MX ${val.priority} ${ | |
val.exchange.endsWith(".") ? val.exchange : val.exchange + "." | |
}` | |
); | |
} else { | |
recordLines.push(`${relativeName} IN MX ${val}`); | |
} | |
break; | |
case "NS": | |
recordLines.push( | |
`${relativeName} IN NS ${val.endsWith(".") ? val : val + "."}` | |
); | |
break; | |
case "SOA": | |
// Node returns an object: { nsname, hostmaster, serial, refresh, retry, expire, minttl } | |
if (typeof val === "object") { | |
const { | |
nsname, | |
hostmaster, | |
serial, | |
refresh, | |
retry, | |
expire, | |
minttl, | |
} = val; | |
recordLines.push( | |
`${relativeName} IN SOA ${nsname}. ${hostmaster}. (` + | |
`${serial} ${refresh} ${retry} ${expire} ${minttl})` | |
); | |
} else { | |
recordLines.push(`${relativeName} IN SOA ${val}`); | |
} | |
break; | |
case "SRV": | |
// E.g. "service IN SRV priority weight port target." | |
if (typeof val === "object") { | |
const { priority, weight, port, name } = val; | |
recordLines.push( | |
`${relativeName} IN SRV ${priority} ${weight} ${port} ${ | |
name.endsWith(".") ? name : name + "." | |
}` | |
); | |
} else { | |
recordLines.push(`${relativeName} IN SRV ${val}`); | |
} | |
break; | |
case "TXT": | |
// Node returns arrays of arrays for TXT | |
if (Array.isArray(val)) { | |
const textValue = val.join(""); | |
recordLines.push(`${relativeName} IN TXT "${textValue}"`); | |
} else { | |
recordLines.push(`${relativeName} IN TXT "${val}"`); | |
} | |
break; | |
default: | |
recordLines.push(`${relativeName} IN ${type} ${val}`); | |
} | |
} | |
} | |
} | |
// 6. Sort the records so that '@' is at the top, optionally | |
recordLines.sort((a, b) => { | |
// Put lines starting with '@' first | |
if (a.startsWith("@") && !b.startsWith("@")) return -1; | |
if (!a.startsWith("@") && b.startsWith("@")) return 1; | |
return a.localeCompare(b); | |
}); | |
zoneLines.push(...recordLines); | |
// 7. Write the zone data to a file | |
const zoneFileName = `${domain}.zone`; | |
fs.writeFileSync( | |
path.join(process.cwd(), zoneFileName), | |
zoneLines.join("\n") + "\n" | |
); | |
// 8. Summarize | |
let totalRecords = 0; | |
for (const recordObj of Object.values(recordsMap)) { | |
totalRecords += Object.values(recordObj).reduce( | |
(sum, arr) => sum + arr.length, | |
0 | |
); | |
} | |
const totalHostnames = Object.keys(recordsMap).length; | |
console.log( | |
`Found ${totalRecords} DNS entries across ${totalHostnames} domain(s) for ${domain} and generated ${zoneFileName}` | |
); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment