UNPKG

dnsz

Version:

Generic DNS zone file parser and stringifier

304 lines (303 loc) 8.58 kB
const nameLike = { ALIAS: [0], ANAME: [0], CNAME: [0], DNAME: [0], MX: [1], NAPTR: [5], NS: [0], NSEC: [0], PTR: [0], RP: [0, 1], RRSIG: [7], SIG: [7], SOA: [0, 1], SRV: [3], TKEY: [0], TSIG: [0] }; function normalize(name) { name = (name || "").toLowerCase(); if (name.endsWith(".") && name.length > 1) { name = name.substring(0, name.length - 1); } return name.replace(/\.{2,}/g, ".").replace(/@\./, "@"); } function splitString(input, { separator = " ", quotes = [] } = {}) { const ast = { type: "root", nodes: [], stash: [""] }; const stack = [ast]; const string = input; let value; let node; let i = -1; const state = {}; const block = () => state.block = stack[stack.length - 1]; const peek = () => string[i + 1]; const next = () => string[++i]; const append = (value2) => { state.value = value2; if (value2) { state.block.stash[state.block.stash.length - 1] += value2; } }; const closeIndex = (value2, startIdx) => { let idx = string.indexOf(value2, startIdx); if (idx > -1 && string[idx - 1] === "\\") { idx = closeIndex(value2, idx + 1); } return idx; }; while (i < string.length - 1) { state.value = value = next(); state.index = i; block(); if (value === "\\") { if (peek() === "\\") { append(value + next()); } else { append(value); append(next()); } continue; } if (quotes.includes(value)) { const pos = i + 1; const idx = closeIndex(value, pos); if (idx > -1) { append(value); append(string.slice(pos, idx)); append(string[idx]); i = idx; continue; } append(value); continue; } if (value === separator && state.block.type === "root") { state.block.stash.push(""); continue; } append(value); } node = stack.pop(); while (node !== ast) { value = node.parent.stash.pop() + node.stash.join("."); node.parent.stash = node.parent.stash.concat(value.split(".")); node = stack.pop(); } return node.stash; } function denormalize(name) { if (!name.endsWith(".") && name.length > 1) { name = `${name}.`; } return name.replace(/\.{2,}/g, ".").replace(/@\./, "@"); } function esc(str) { return str.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&"); } function addDots(content, indexes) { const parts = splitString(content, { quotes: [`"`], separator: " " }).map((s) => s.trim()).filter(Boolean); for (const index of indexes) { if (!parts[index].endsWith(".")) { parts[index] += "."; } } return parts.join(" "); } function parseTTL(ttl, def) { if (typeof ttl === "number") { return ttl; } if (def && !ttl) { return def; } if (/s$/i.test(ttl)) { ttl = parseInt(ttl); } else if (/m$/i.test(ttl)) { ttl = parseInt(ttl) * 60; } else if (/h$/i.test(ttl)) { ttl = parseInt(ttl) * 60 * 60; } else if (/d$/i.test(ttl)) { ttl = parseInt(ttl) * 60 * 60 * 24; } else if (/w$/i.test(ttl)) { ttl = parseInt(ttl) * 60 * 60 * 24 * 7; } else { ttl = parseInt(ttl); } return ttl; } function format(records, type, { origin, newline, sections, dots }) { let str = ``; if (sections && type) { str += `;; ${type} Records${newline}`; } for (const record of records || []) { if (!record) continue; let name = normalize(record.name || ""); if (origin) { if (name === origin) { name = "@"; } else if (name.endsWith(origin)) { name = normalize(name.replace(new RegExp(`${esc(`${origin}.`)}?$`, "gm"), "")); } else { name = normalize(name); } } else { if (name.includes(".")) { name = denormalize(name); } else { name = normalize(name); } } let content = record.content; if (dots && Object.keys(nameLike).includes(record.type)) { const indexes = nameLike[record.type]; content = addDots(content, indexes); } const fields = [ name, record.ttl, record.class, record.type, content ]; if (record.comment) { fields.push(`; ${record.comment}`); } str += `${fields.join(" ")}${newline}`; } return `${str}${sections ? newline : ""}`; } function splitContentAndComment(str) { if (!str) return [null, null]; const splitted = splitString(str, { quotes: [`"`], separator: ";" }); let parts; if (splitted.length > 2) { parts = [splitted[0], splitted.slice(1).join(";")]; } else { parts = splitted; } parts = parts.map((part) => (part || "").trim()).filter(Boolean); if (parts.length <= 2) { return [parts[0] || null, parts[1] || null]; } else { const comment = parts.pop(); const content = parts.join("; "); return [content, comment]; } } function parseZone(str, { replaceOrigin = null, crlf = false, defaultTTL = 60, defaultClass = "IN", dots = false } = {}) { const data = {}; const rawLines = str.split(/\r?\n/).map((l) => l.trim()); const lines = rawLines.filter((l) => Boolean(l) && !l.startsWith(";")); const newline = crlf ? "\r\n" : "\n"; const headerLines = []; let valid; for (const [index, line] of rawLines.entries()) { if (line.startsWith(";;")) { headerLines.push(line.substring(2).trim()); } else { const prev = rawLines[index - 1]; if (line === "" && index > 1 && prev.startsWith(";;")) { valid = true; break; } } } if (valid && headerLines.length) { data.header = headerLines.join(newline); } const reLine = /^([a-z0-9_.\-@*]+)?\s*([0-9]+[smhdw]?)?\s*([a-z]+)?\s+([a-z]+[0-9]*)?\s+(.+)$/i; data.records = []; for (const line of lines) { const parsedOrigin = (/\$ORIGIN\s+(\S+)/.exec(line) || [])[1]; if (parsedOrigin && !data.origin) { data.origin = normalize(parsedOrigin); } const parsedTtl = (/\$TTL\s+(\S+)/.exec(line) || [])[1]; if (line.startsWith("$TTL ") && !data.ttl) { data.ttl = parseTTL(normalize(parsedTtl)); } let [, name, ttl, cls, type, contentAndComment] = reLine.exec(line) || []; if (!ttl && name && /[0-9]+/.test(name)) { ttl = name; name = ""; } if (cls && !type) { type = cls; cls = ""; } if (!cls) { cls = defaultClass; } let [content, comment] = splitContentAndComment(contentAndComment); if (!name) { name = ""; } if (!cls || !type || !content) { continue; } type = type.toUpperCase(); content = (content || "").trim(); if (dots && Object.keys(nameLike).includes(type)) { content = addDots(content, nameLike[type]); } data.records.push({ name: normalize(["", "@"].includes(name) && data.origin ? data.origin : name), ttl: parseTTL(ttl, data.ttl !== void 0 ? data.ttl : defaultTTL), class: cls.toUpperCase(), type, content, comment: (comment || "").trim() || null }); } if (replaceOrigin) { data.origin = replaceOrigin; } return data; } function stringifyZone(data, { crlf = false, sections = true, dots = false } = {}) { const recordsByType = {}; const newline = crlf ? "\r\n" : "\n"; if (sections) { for (const record of data.records) { if (!recordsByType[record.type]) recordsByType[record.type] = []; recordsByType[record.type].push(record); } } let output = ""; if (data.header) { output += `${data.header.split(/\r?\n/).map((l) => l.trim()).map((l) => l ? `;; ${l}` : ";;").join(newline).trim()}${newline}${newline}`; } const vars = []; if (data.origin) vars.push(`$ORIGIN ${denormalize(data.origin)}`); if (data.ttl) vars.push(`$TTL ${data.ttl}`); if (vars.length) output += `${vars.join(newline)}${newline}${newline}`; const origin = normalize(data.origin); if (sections) { if (recordsByType.SOA) { output += format(recordsByType.SOA, "SOA", { origin, newline, sections, dots }); delete recordsByType.SOA; } for (const type of Object.keys(recordsByType).sort()) { output += format(recordsByType[type], type, { origin, newline, sections, dots }); } } else { const recordsSOA = data.records.filter((r) => r.type === "SOA"); const recordsMinusSOA = data.records.filter((r) => r.type !== "SOA"); output += format(recordsSOA, null, { origin, newline, sections, dots }); output += format(recordsMinusSOA, null, { origin, newline, sections, dots }); } return `${output.trim()}${newline}`; } export { parseZone, stringifyZone };