dnsz
Version:
Generic DNS zone file parser and stringifier
324 lines (323 loc) • 9.91 kB
JavaScript
//#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