Published source
Download source ZIP
CH-J Server Manager
Browse directories and files for a specific application release.
Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
1
"use strict";2
const dns = require("node:dns");3
const dgram = require("node:dgram");4
const net = require("node:net");5
const crypto = require("node:crypto");6
const { hostname, DiagnosticError, boundedSignal, checkAbort, now, mapLimit, errorResult } = require("./common");7
const TYPES = { A: 1, NS: 2, CNAME: 5, SOA: 6, PTR: 12, MX: 15, TXT: 16, AAAA: 28, SRV: 33, DS: 43, DNSKEY: 48, CAA: 257 };8
const PROVIDERS = { cloudflare: ["1.1.1.1", "2606:4700:4700::1111"], google: ["8.8.8.8", "2001:4860:4860::8888"], quad9: ["9.9.9.9", "2620:fe::fe"] };9
function readName(buffer, position) {10
let cursor = position, end, labels = [], seen = new Set();11
for (let count = 0; count < 128; count++) {12
if (cursor >= buffer.length || seen.has(cursor)) throw new DiagnosticError("DNS_MALFORMED");13
seen.add(cursor); const length = buffer[cursor++];14
if ((length & 0xc0) === 0xc0) { if (cursor >= buffer.length) throw new DiagnosticError("DNS_MALFORMED"); end ??= cursor + 1; cursor = ((length & 0x3f) << 8) | buffer[cursor]; continue; }15
if (length & 0xc0 || length > 63 || cursor + length > buffer.length) throw new DiagnosticError("DNS_MALFORMED");16
if (!length) return { name: labels.join("."), end: end ?? cursor };17
labels.push(buffer.subarray(cursor, cursor + length).toString("ascii")); cursor += length;18
}19
throw new DiagnosticError("DNS_MALFORMED");20
}21
function parseResponse(buffer, id, type) {22
if (buffer.length < 12 || buffer.readUInt16BE(0) !== id || !(buffer[2] & 0x80)) throw new DiagnosticError("DNS_MALFORMED");23
const code = buffer[3] & 15; const status = { 0: "success", 2: "servfail", 3: "nxdomain", 5: "refused" }[code] || "error";24
let cursor = 12;25
const questions = buffer.readUInt16BE(4), total = buffer.readUInt16BE(6) + buffer.readUInt16BE(8) + buffer.readUInt16BE(10);26
if (questions !== 1 || total > 1024) throw new DiagnosticError("DNS_MALFORMED");27
const question = readName(buffer, cursor); cursor = question.end;28
if (cursor + 4 > buffer.length || buffer.readUInt16BE(cursor) !== TYPES[type] || buffer.readUInt16BE(cursor + 2) !== 1) throw new DiagnosticError("DNS_MALFORMED");29
cursor += 4; const records = [];30
for (let index = 0; index < total; index++) {31
const owner = readName(buffer, cursor); cursor = owner.end;32
if (cursor + 10 > buffer.length) throw new DiagnosticError("DNS_MALFORMED");33
const number = buffer.readUInt16BE(cursor), cls = buffer.readUInt16BE(cursor + 2), ttl = buffer.readUInt32BE(cursor + 4), length = buffer.readUInt16BE(cursor + 8);34
cursor += 10; const end = cursor + length;35
if (end > buffer.length) throw new DiagnosticError("DNS_MALFORMED");36
const recordType = Object.keys(TYPES).find((key) => TYPES[key] === number);37
let value;38
if (number === 1 && length === 4) value = [...buffer.subarray(cursor, end)].join(".");39
else if (number === 28 && length === 16) value = Array.from({ length: 8 }, (_, n) => buffer.readUInt16BE(cursor + n * 2).toString(16)).join(":");40
else if ([2, 5, 12].includes(number)) value = readName(buffer, cursor).name;41
else if (number === 15 && length >= 3) value = { priority: buffer.readUInt16BE(cursor), exchange: readName(buffer, cursor + 2).name };42
else if (number === 33 && length >= 7) value = { priority: buffer.readUInt16BE(cursor), weight: buffer.readUInt16BE(cursor + 2), port: buffer.readUInt16BE(cursor + 4), name: readName(buffer, cursor + 6).name };43
else if (number === 16) { value = []; let p = cursor; while (p < end) { const size = buffer[p++]; if (p + size > end) throw new DiagnosticError("DNS_MALFORMED"); value.push(buffer.subarray(p, p + size).toString("utf8")); p += size; } }44
else if (number === 6) { const first = readName(buffer, cursor), second = readName(buffer, first.end); if (second.end + 20 > end) throw new DiagnosticError("DNS_MALFORMED"); value = { nsname: first.name, hostmaster: second.name }; ["serial", "refresh", "retry", "expire", "minttl"].forEach((key, n) => { value[key] = buffer.readUInt32BE(second.end + n * 4); }); }45
else if (number === 43 && length >= 4) value = { keyTag: buffer.readUInt16BE(cursor), algorithm: buffer[cursor + 2], digestType: buffer[cursor + 3], digest: buffer.subarray(cursor + 4, end).toString("hex") };46
else if (number === 48 && length >= 4) value = { flags: buffer.readUInt16BE(cursor), protocol: buffer[cursor + 2], algorithm: buffer[cursor + 3], publicKey: buffer.subarray(cursor + 4, end).toString("base64") };47
else if (number === 257 && length >= 2 && cursor + 2 + buffer[cursor + 1] <= end) value = { flags: buffer[cursor], tag: buffer.subarray(cursor + 2, cursor + 2 + buffer[cursor + 1]).toString(), value: buffer.subarray(cursor + 2 + buffer[cursor + 1], end).toString() };48
if (recordType && cls === 1 && value !== undefined) records.push({ name: owner.name, type: recordType, ttl, value, section: index < buffer.readUInt16BE(6) ? "answer" : "authority/additional" });49
cursor = end;50
}51
return { questionName: question.name, status: status === "success" && !records.some((r) => r.type === type && r.section === "answer") ? "nodata" : status, records, truncated: Boolean(buffer[2] & 2), dnssec: "not-validated", resolverAdFlag: Boolean(buffer[3] & 32) };52
}53
function reverseName(address) {54
if (net.isIP(address) === 4) return address.split(".").reverse().join(".") + ".in-addr.arpa";55
if (net.isIP(address) !== 6) throw new DiagnosticError("INVALID_IP");56
// URL canonicalization expands embedded IPv4 to two hexadecimal groups.57
const value = new URL(`http://[${address}]/`).hostname.slice(1, -1), halves = value.split("::"), left = halves[0] ? halves[0].split(":") : [], right = halves[1] ? halves[1].split(":") : [];58
return [...left, ...Array(8 - left.length - right.length).fill("0"), ...right].map((part) => part.padStart(4, "0")).join("").split("").reverse().join(".") + ".ip6.arpa";59
}60
function serverAddress(value) {61
const v6 = String(value).match(/^\[([^\]]+)\](?::(\d+))?$/);62
if (v6) return { host: v6[1], port: Number(v6[2] || 53) };63
if (net.isIP(value)) return { host: value, port: 53 };64
const v4 = String(value).match(/^([\d.]+):(\d+)$/);65
if (v4 && net.isIP(v4[1]) === 4) return { host: v4[1], port: Number(v4[2]) };66
throw new DiagnosticError("INVALID_DNS_SERVER");67
}68
function exchange(packet, server, signal, tcp = false) {69
checkAbort(signal);70
checkAbort(signal);71
return new Promise((resolve, reject) => {72
const address = serverAddress(server), socket = tcp ? net.createConnection({ host: address.host, port: address.port }) : dgram.createSocket(net.isIP(address.host) === 6 ? "udp6" : "udp4");73
let done = false, data = Buffer.alloc(0);74
const finish = (error, response) => { if (done) return; done = true; signal?.removeEventListener("abort", abort); if (tcp) socket.destroy(); else { try { socket.close(); } catch {} } error ? reject(error) : resolve(response); };75
const abort = () => finish(signal.reason || new DiagnosticError("CANCELLED"));76
signal?.addEventListener("abort", abort, { once: true }); socket.once("error", finish);77
if (tcp) {78
socket.once("connect", () => { const size = Buffer.alloc(2); size.writeUInt16BE(packet.length); socket.write(Buffer.concat([size, packet])); });79
socket.on("data", (chunk) => { data = Buffer.concat([data, chunk]); if (data.length > 65537) return finish(new DiagnosticError("DNS_TOO_LARGE")); if (data.length >= 2 && data.length >= data.readUInt16BE(0) + 2) finish(null, data.subarray(2, data.readUInt16BE(0) + 2)); });80
socket.once("end", () => finish(new DiagnosticError("DNS_INCOMPLETE")));81
} else {82
socket.once("message", (response) => finish(null, response));83
socket.connect(address.port, address.host, () => socket.send(packet, (error) => { if (error) finish(error); }));84
}85
});86
}87
class DnsDiagnostics {88
constructor({ lookup = dns.lookup } = {}) { this.lookup = lookup; }89
systemLookup(host, options, signal) {90
checkAbort(signal);91
const started = now(), limit = boundedSignal(signal, options.timeoutMs);92
return new Promise((resolve, reject) => {93
let done = false;94
const finish = (error, addresses) => {95
if (done) return; done = true; limit.signal.removeEventListener("abort", abort); limit.close();96
error ? reject(error) : resolve(addresses.map((a) => ({ ...a, dnsMs: now() - started, method: "OS resolver (hosts file / system DNS); TTL unavailable" })));97
};98
const abort = () => finish(limit.signal.reason || new DiagnosticError("CANCELLED"));99
limit.signal.addEventListener("abort", abort, { once: true });100
try { this.lookup(host, { all: true, verbatim: true, family: options.mode === "ipv4" ? 4 : options.mode === "ipv6" ? 6 : 0 }, finish); }101
catch (error) { finish(error); }102
});103
}104
servers(options) { return options.resolver === "custom" ? [options.customDns] : PROVIDERS[options.resolver] || dns.getServers(); }105
async query(name, type, options, signal) {106
const started = now(), queryName = hostname(name), id = crypto.randomBytes(2).readUInt16BE();107
const header = Buffer.alloc(12); header.writeUInt16BE(id); header.writeUInt16BE(0x0100, 2); header.writeUInt16BE(1, 4);108
const question = Buffer.concat([...queryName.split(".").map((part) => Buffer.concat([Buffer.from([Buffer.byteLength(part)]), Buffer.from(part)])), Buffer.from([0, TYPES[type] >> 8, TYPES[type] & 255, 0, 1])]);109
const packet = Buffer.concat([header, question]), limit = boundedSignal(signal, options.timeoutMs);110
let failure;111
try {112
for (const server of this.servers(options)) {113
try {114
let response = await exchange(packet, server, limit.signal);115
if (response[2] & 2) response = await exchange(packet, server, limit.signal, true);116
const result = parseResponse(response, id, type);117
if (result.questionName && result.questionName !== queryName) throw new DiagnosticError("DNS_MALFORMED");118
return { name: queryName, type, server, durationMs: now() - started, ...result };119
} catch (error) { failure = error; if (limit.signal.aborted) break; }120
}121
throw failure || new DiagnosticError("DNS_UNAVAILABLE");122
} finally { limit.close(); }123
}124
async resolve(host, options, signal) {125
checkAbort(signal);126
if (net.isIP(host)) return [{ address: host, family: net.isIP(host), dnsMs: 0 }];127
if (options.resolver === "system") { const addresses = await this.systemLookup(host, options, signal); if (!addresses.length) throw new DiagnosticError("DNS_NO_ADDRESS"); return addresses; }128
const types = options.mode === "ipv4" ? ["A"] : options.mode === "ipv6" ? ["AAAA"] : ["A", "AAAA"];129
const results = await Promise.all(types.map(async (type) => {130
let name = host, duration = 0, seen = new Set();131
for (let i = 0; i < 8; i++) {132
if (seen.has(name)) throw new DiagnosticError("DNS_CNAME_LOOP"); seen.add(name);133
const result = await this.query(name, type, options, signal); duration += result.durationMs;134
const addresses = result.records.filter((r) => r.type === type && r.section === "answer");135
if (addresses.length) return addresses.map((r) => ({ address: r.value, family: type === "A" ? 4 : 6, dnsMs: duration, ttl: r.ttl }));136
const cname = result.records.find((r) => r.type === "CNAME" && r.section === "answer");137
if (!cname) return [];138
name = cname.value;139
}140
throw new DiagnosticError("DNS_CNAME_LIMIT");141
}).map((promise) => promise.catch((error) => { if (signal?.aborted) throw error; return []; })));142
const addresses = results.flat();143
if (!addresses.length) throw new DiagnosticError("DNS_NO_ADDRESS");144
return addresses;145
}146
async run(host, options, signal, progress) {147
const name = net.isIP(host) ? reverseName(host) : host;148
const types = net.isIP(host) ? ["PTR"] : Object.keys(TYPES);149
const queries = await mapLimit(types, 3, async (type) => {150
checkAbort(signal);151
let result; try { result = await this.query(name, type, options, signal); } catch (error) { result = { type, name, ...errorResult(error) }; }152
progress?.({ kind: "dns-record", ...result }); return result;153
});154
const cnames = queries.flatMap((q) => q.records || []).filter((r) => r.type === "CNAME" && r.section === "answer");155
const seen = new Set([host]); let next = cnames.find((r) => r.name === host)?.value, cnameError;156
for (let index = 0; next && index < 8; index++) {157
checkAbort(signal);158
if (seen.has(next)) { cnameError = "DNS_CNAME_LOOP"; break; }159
seen.add(next);160
try {161
const query = await this.query(next, "CNAME", options, signal);162
const record = query.records.find((r) => r.type === "CNAME" && r.section === "answer" && r.name === next);163
if (!record) { next = null; break; }164
cnames.push(record); next = record.value;165
} catch (error) { if (signal.aborted) throw error; cnameError = error.code; break; }166
}167
if (next && !cnameError) cnameError = "DNS_CNAME_LIMIT";168
const consistency = [];169
if (!net.isIP(host)) {170
for (const type of ["A", "AAAA"]) {171
checkAbort(signal);172
const first = queries.find((q) => q.type === type);173
try {174
const second = await this.query(host, type, options, signal);175
const values = (q) => (q.records || []).filter((r) => [type, "CNAME"].includes(r.type) && r.section === "answer").map((r) => JSON.stringify([r.name, r.type, r.value])).sort();176
consistency.push({ type, status: first?.status === second.status && JSON.stringify(values(first)) === JSON.stringify(values(second)) ? "consistent" : "changed", first: values(first), second: values(second) });177
} catch (error) { if (signal.aborted) throw error; consistency.push({ type, ...errorResult(error) }); }178
}179
} else {180
for (const record of (queries[0]?.records || []).filter((r) => r.type === "PTR").slice(0, 4)) {181
try {182
const answers = await this.resolve(record.value, { ...options, mode: net.isIP(host) === 4 ? "ipv4" : "ipv6" }, signal);183
const canonical = (address) => net.isIP(address) === 6 ? new URL(`http://[${address}]/`).hostname : address;184
consistency.push({ type: "forward-confirmed-PTR", name: record.value, status: answers.some((a) => canonical(a.address) === canonical(host)) ? "consistent" : "changed" });185
} catch (error) { if (signal.aborted) throw error; consistency.push({ type: "forward-confirmed-PTR", name: record.value, ...errorResult(error) }); }186
}187
}188
return { status: queries.some((q) => q.status === "success") ? "success" : "warning", queries,189
cnameChain: [...new Set(cnames.map((r) => `${r.name} → ${r.value}`))], cnameError, consistency,190
dnssec: "not-validated", note: "Repeated answers / forward-confirmed PTR from the selected resolver. Changes can reflect load balancing; this is not authoritative consistency or DNSSEC validation." };191
}192
}193
module.exports = { TYPES, PROVIDERS, DnsDiagnostics, parseResponse, readName, reverseName };SHA-256: 41fc390764513e013f94aa827f051433aeea02bf7b07b289983f7eb8dcbe2179
Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0