@stacksjs/dnsx
Version:
A command-line & library DNS client. Like dig & dog, but for TypeScript.
397 lines (330 loc) • 12.7 kB
TypeScript
import type { DnsOptions, DnsQuery, DnsResponse } from './types';
export declare class DnsClient {
private options: DnsOptions
private static readonly DEFAULT_NAMESERVER = '1.1.1.1'
private static readonly RESOLV_CONF_PATH = '/etc/resolv.conf'
private static readonly WINDOWS_DNS_KEY = 'SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Nameserver'
private validateDomainName(domain: string): void {
if (domain.includes('..')) {
throw new Error(`Invalid domain name: ${domain} (consecutive dots)`)
}
if (domain.startsWith('.') || domain.endsWith('.')) {
throw new Error(`Invalid domain name: ${domain} (starts or ends with dot)`)
}
if (domain.length > 253) {
throw new Error(`Domain name too long: ${domain}`)
}
const labels = domain.split('.')
for (const label of labels) {
if (label.length > 63) {
throw new Error(`Label too long in domain: ${domain}`)
}
if (!/^[a-z0-9-]+$/i.test(label)) {
throw new Error(`Invalid characters in domain label: ${label}`)
}
}
}
private validateRecordType(type: string | number): void {
if (typeof type === 'string') {
const upperType = type.toUpperCase()
if (!(upperType in RecordType)) {
throw new Error(`Invalid record type: ${type}`)
}
}
else if (typeof type === 'number') {
const values = Object.values(RecordType).filter(v => typeof v === 'number')
if (!values.includes(type)) {
throw new Error(`Invalid record type number: ${type}`)
}
}
else {
throw new TypeError('Record type must be string or number')
}
}
constructor(options: DnsOptions) {
this.options = {
transport: {
type: TransportType.UDP,
},
verbose: false,
...options,
}
this.validateOptions()
}
async query(): Promise<DnsResponse[]> {
try {
const transportType = this.determineTransportType()
debugLog('client', `Selected transport type: ${transportType}`, this.options.verbose)
const transport = createTransport(transportType)
if (!transport) {
throw new Error(`Failed to create transport for type: ${transportType}`)
}
const responses: DnsResponse[] = []
const nameserver = await this.resolveNameserver()
debugLog('client', `Resolved nameserver: ${nameserver}`, this.options.verbose)
if (!nameserver) {
throw new Error('Failed to resolve nameserver')
}
const queries = this.buildQueries()
debugLog('client', `Built ${queries.length} queries`, this.options.verbose)
for (const query of queries) {
debugLog('client', `Processing query: ${JSON.stringify({
query,
nameserver,
transportType,
})}`, this.options.verbose)
const request = buildQuery(query, {
txid: this.options.txid,
edns: this.options.edns,
tweaks: this.options.tweaks,
verbose: this.options.verbose,
})
debugLog('client', `Built DNS request: ${JSON.stringify({
hexData: request.toString('hex'),
length: request.length,
txid: request.readUInt16BE(0),
flags: request.readUInt16BE(2).toString(16),
})}`, this.options.verbose)
const maxRetries = this.options.retries || 3
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
debugLog('client', `Attempt ${attempt + 1}/${maxRetries}`, this.options.verbose)
const response = await transport.query(nameserver, request)
debugLog('client', `Received DNS response: ${JSON.stringify({
hexData: response.toString('hex'),
length: response.length,
txid: response.readUInt16BE(0),
flags: response.readUInt16BE(2).toString(16),
})}`, this.options.verbose)
if (response.length < 12) {
throw new Error(`Response too short: ${response.length} bytes`)
}
const responseFlags = response.readUInt16BE(2)
if (!(responseFlags & 0x8000)) {
throw new Error('Response flag not set in DNS message')
}
const parsed = parseResponse(response)
debugLog('client', `Parsed DNS response: ${JSON.stringify({
id: parsed.id,
answerCount: parsed.answers.length,
authorityCount: parsed.authorities.length,
additionalCount: parsed.additionals.length,
})}`, this.options.verbose)
if (transportType === TransportType.UDP && parsed.flags.truncated) {
debugLog('client', 'Response truncated, retrying with TCP', this.options.verbose)
const tcpTransport = createTransport(TransportType.TCP)
const tcpResponse = await tcpTransport.query(nameserver, request)
responses.push(parseResponse(tcpResponse))
}
else {
responses.push(parsed)
}
break
}
catch (err) {
debugLog('client', `Attempt ${attempt + 1} failed: ${(err as Error).message}`, this.options.verbose)
if (attempt === maxRetries - 1) {
debugLog('client', 'All retry attempts failed', this.options.verbose)
throw err
}
const backoffTime = 2 ** attempt * 1000
debugLog('client', `Waiting ${backoffTime}ms before retry`, this.options.verbose)
await new Promise(resolve => setTimeout(resolve, backoffTime))
}
}
}
if (responses.length === 0) {
throw new Error('No responses received')
}
return responses
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
debugLog('client', `Query failed with error: ${errorMessage}`, this.options.verbose)
debugLog('client', `Full error: ${JSON.stringify(err)}`, this.options.verbose)
throw new Error(`DNS query failed: ${errorMessage}`)
}
}
private buildQueries(): DnsQuery[] {
const queries: DnsQuery[] = []
if (!this.options.domains?.length) {
throw new Error('No domains specified')
}
const domains = Array.isArray(this.options.domains)
? this.options.domains
: [this.options.domains]
const types = this.resolveTypes()
const classes = this.resolveClasses()
for (const domain of domains) {
for (const type of types) {
for (const qclass of classes) {
queries.push({ name: domain, type, class: qclass })
}
}
}
debugLog('client', `Built queries: ${JSON.stringify(queries)}`, this.options.verbose)
return queries
}
private resolveTypes(): RecordType[] {
if (!this.options.type) {
return [RecordType.A]
}
const types = Array.isArray(this.options.type)
? this.options.type
: [this.options.type]
return types.map((type) => {
this.validateRecordType(type)
if (typeof type === 'number') {
return type
}
const upperType = type.toUpperCase()
return RecordType[upperType as keyof typeof RecordType]
})
}
private resolveClasses(): QClass[] {
if (!this.options.class)
return [QClass.IN]
const classes = Array.isArray(this.options.class)
? this.options.class
: [this.options.class]
return classes.map((cls) => {
if (typeof cls === 'number')
return cls
const upperClass = cls.toUpperCase()
const qclass = QClass[upperClass as keyof typeof QClass]
if (qclass === undefined) {
throw new Error(`Invalid query class: ${cls}`)
}
return qclass
})
}
private validateNameserver(nameserver: string): boolean {
if (nameserver.startsWith('https:
return true
}
if (nameserver.includes('%')) {
return false
}
const ipv4Regex = /^(?:\d{1,3}\.){3}\d{1,3}$/
if (ipv4Regex.test(nameserver)) {
const parts = nameserver.split('.')
return parts.every((part) => {
const num = Number.parseInt(part, 10)
return num >= 0 && num <= 255
})
}
if (nameserver.includes(':')) {
return false
}
return false
}
private async resolveNameserver(): Promise<string> {
try {
if (this.options.nameserver) {
debugLog('client', `Checking configured nameserver: ${this.options.nameserver}`, this.options.verbose)
if (this.validateNameserver(this.options.nameserver)) {
return this.options.nameserver
}
}
const currentPlatform = platform()
debugLog('client', `Resolving nameserver for platform: ${currentPlatform}`, this.options.verbose)
let nameserver: string
if (currentPlatform === 'win32') {
nameserver = await this.resolveWindowsNameserver()
}
else {
nameserver = await this.resolveUnixNameserver()
}
if (this.validateNameserver(nameserver)) {
return nameserver
}
debugLog('client', `Using default nameserver: ${DnsClient.DEFAULT_NAMESERVER}`, this.options.verbose)
return DnsClient.DEFAULT_NAMESERVER
}
catch (err) {
debugLog('client', `Failed to resolve nameserver: ${(err as Error).message}`, this.options.verbose)
debugLog('client', `Using default nameserver: ${DnsClient.DEFAULT_NAMESERVER}`, this.options.verbose)
return DnsClient.DEFAULT_NAMESERVER
}
}
private async resolveUnixNameserver(): Promise<string> {
try {
const content = await fs.readFile(DnsClient.RESOLV_CONF_PATH, 'utf-8')
const lines = content.split('\n')
const nameservers: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('nameserver')) {
const [, nameserver] = trimmed.split(/\s+/)
if (nameserver && !nameserver.includes('%')) {
nameservers.push(nameserver)
}
}
}
const ipv4Nameserver = nameservers.find(ns => !ns.includes(':'))
if (ipv4Nameserver) {
debugLog('client', `Using IPv4 nameserver from resolv.conf: ${ipv4Nameserver}`, this.options.verbose)
return ipv4Nameserver
}
debugLog('client', `No valid nameserver found in resolv.conf, using fallback: ${DnsClient.DEFAULT_NAMESERVER}`, this.options.verbose)
return DnsClient.DEFAULT_NAMESERVER
}
catch (err) {
debugLog('client', `Failed to read resolv.conf: ${(err as Error).message}`, this.options.verbose)
debugLog('client', `Using fallback nameserver: ${DnsClient.DEFAULT_NAMESERVER}`, this.options.verbose)
return DnsClient.DEFAULT_NAMESERVER
}
}
private async resolveWindowsNameserver(): Promise<string> {
try {
debugLog('client', `Using default nameserver for Windows: ${DnsClient.DEFAULT_NAMESERVER}`, this.options.verbose)
return DnsClient.DEFAULT_NAMESERVER
}
catch (err) {
debugLog('client', `Failed to get Windows nameserver: ${(err as Error).message}`, this.options.verbose)
throw new Error('Failed to get Windows nameserver')
}
}
private determineTransportType(): TransportType {
if (this.options.https)
return TransportType.HTTPS
if (this.options.tls)
return TransportType.TLS
if (this.options.tcp)
return TransportType.TCP
if (this.options.udp)
return TransportType.UDP
if (typeof this.options.transport === 'object') {
return this.options.transport.type || TransportType.UDP
}
return TransportType.UDP
}
private validateOptions(): void {
if (this.options.domains) {
for (const domain of Array.isArray(this.options.domains) ? this.options.domains : [this.options.domains]) {
this.validateDomainName(domain)
}
}
if (this.options.type) {
const types = Array.isArray(this.options.type)
? this.options.type
: [this.options.type]
for (const type of types) {
this.validateRecordType(type)
}
}
const transportCount = [
this.options.udp,
this.options.tcp,
this.options.tls,
this.options.https,
].filter(Boolean).length
if (transportCount > 1) {
throw new Error('Only one transport type can be specified')
}
if (this.options.https && !this.options.nameserver?.startsWith('https:
throw new Error('HTTPS transport requires an HTTPS nameserver URL')
}
debugLog('client', 'Options validation completed successfully', this.options.verbose)
}
}