UNPKG

dnsz

Version:

Generic DNS zone file parser and stringifier

324 lines (323 loc) 9.91 kB
//#region index.ts 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 = { input, separator, stack, prev: () => string[i - 1], next: () => string[i + 1] }; const block = () => state.block = stack[stack.length - 1]; const peek = () => string[i + 1]; const next = () => string[++i]; const append = (value) => { state.value = value; if (value) state.block.stash[state.block.stash.length - 1] += value; }; const closeIndex = (value, startIdx) => { let idx = string.indexOf(value, startIdx); if (idx > -1 && string[idx - 1] === "\\") idx = closeIndex(value, 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(" "); } const MAX_TTL = 2147483647; function clampTTL(value) { return Math.min(Math.max(0, value), MAX_TTL); } function parseTTL(ttl, def) { if (typeof ttl === "number") return clampTTL(ttl); if (def && !ttl) return clampTTL(def); if (/s$/i.test(ttl)) ttl = Number.parseInt(ttl); else if (/m$/i.test(ttl)) ttl = Number.parseInt(ttl) * 60; else if (/h$/i.test(ttl)) ttl = Number.parseInt(ttl) * 60 * 60; else if (/d$/i.test(ttl)) ttl = Number.parseInt(ttl) * 60 * 60 * 24; else if (/w$/i.test(ttl)) ttl = Number.parseInt(ttl) * 60 * 60 * 24 * 7; else ttl = Number.parseInt(ttl); return clampTTL(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(); return [parts.join("; "), comment]; } } /** Parse a string of a DNS zone file and returns a `data` object. */ function parseZone(str, { replaceOrigin = null, crlf = false, defaultTTL = 60, defaultClass = "IN", dots = false } = {}) { const data = {}; const rawLines = str.split(/\r?\n/); const trimmedRawLines = rawLines.map((l) => l.trim()); let lines = trimmedRawLines.map((text, i) => ({ text, inherited: /^\s/.test(rawLines[i]) })).filter(({ text }) => Boolean(text) && !text.startsWith(";")); const newline = crlf ? "\r\n" : "\n"; const combinedLines = []; let i = 0; while (i < lines.length) { const { text: line, inherited } = lines[i]; if (line.includes("(") && !line.includes(")")) { const [firstLineContent] = splitContentAndComment(line); let combined = firstLineContent || ""; let foundClosing = false; i++; while (i < lines.length) { const [cleanedContent] = splitContentAndComment(lines[i].text); const cleanedLine = (cleanedContent || "").trim(); if (cleanedLine) combined += ` ${cleanedLine}`; i++; if (cleanedLine.includes(")")) { foundClosing = true; break; } } if (foundClosing) { const openIdx = combined.indexOf("("); const closeIdx = combined.lastIndexOf(")"); combined = (combined.substring(0, openIdx) + combined.substring(openIdx + 1, closeIdx) + combined.substring(closeIdx + 1)).replace(/\s+/g, " ").trim(); } else combined = combined.replace("(", "").replace(/\s+/g, " ").trim(); combinedLines.push({ text: combined, inherited }); } else { combinedLines.push({ text: line, inherited }); i++; } } lines = combinedLines; const headerLines = []; let valid = false; for (const [index, line] of trimmedRawLines.entries()) if (line.startsWith(";;")) headerLines.push(line.substring(2).trim()); else { const prev = trimmedRawLines[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 = []; let prevName = ""; let prevClass = defaultClass; for (const { text: line, inherited } of lines) { const parsedOrigin = (/\$ORIGIN\s+(\S+)/.exec(line) || [])[1]; if (parsedOrigin) data.origin = normalize(parsedOrigin); const parsedTtl = (/\$TTL\s+(\S+)/.exec(line) || [])[1]; if (line.startsWith("$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 = prevClass; let [content, comment] = splitContentAndComment(contentAndComment); if (!name) name = ""; if (!cls || !type || !content) continue; type = type.toUpperCase(); cls = cls.toUpperCase(); content = (content || "").trim(); if (dots && Object.keys(nameLike).includes(type)) content = addDots(content, nameLike[type]); const isAbsolute = name.endsWith("."); let resolvedName; if (inherited && prevName) resolvedName = prevName; else if ((!name || name === "@") && data.origin) resolvedName = data.origin; else if (name && name !== "@" && !isAbsolute && data.origin) resolvedName = `${normalize(name)}.${data.origin}`; else resolvedName = normalize(name); prevName = resolvedName; prevClass = cls; data.records.push({ name: resolvedName, ttl: parseTTL(ttl, data.ttl !== void 0 ? data.ttl : defaultTTL), class: cls, type, content, comment: (comment || "").trim() || null }); } if (replaceOrigin) data.origin = replaceOrigin; return data; } /** Parse a `data` object and return a string with the zone file contents. */ 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}`; } //#endregion export { parseZone, stringifyZone }; //# sourceMappingURL=index.js.map