dnsz
Version:
Generic DNS zone file parser and stringifier
304 lines (303 loc) • 8.58 kB
JavaScript
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
};