Skip to content

Commit

Permalink
feat: add dig-trace support (#36)
Browse files Browse the repository at this point in the history
* ref: move DIG parser to a helper file

* fix: tests typig errors

* add: trace unit test

* add: cmd/dig: shared handler

* add: trace dig parser

* add: implement trace support

* fix: missing query.trace validation rules

* add: dns unit test
  • Loading branch information
patrykcieszkowski authored May 9, 2022
1 parent 21f13e9 commit 32e2988
Show file tree
Hide file tree
Showing 9 changed files with 728 additions and 161 deletions.
177 changes: 17 additions & 160 deletions src/command/dns-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,23 @@ import type {CommandInterface} from '../types.js';
import {isExecaError} from '../helper/execa-error-check.js';
import {InvalidOptionsException} from './exception/invalid-options-exception.js';

import ClassicDigParser from './handlers/dig/classic.js';
import type {DnsParseResponse as DnsParseResponseClassic} from './handlers/dig/classic.js';
import TraceDigParser from './handlers/dig/trace.js';
import type {DnsParseResponse as DnsParseResponseTrace} from './handlers/dig/trace.js';

type DnsOptions = {
type: 'dns';
target: string;
query: {
type?: string[];
type?: string;
resolver?: string;
protocol?: string;
port?: number;
trace?: boolean;
};
};

type DnsParseLoopResponse = {
[key: string]: any;
question?: any[];
header: any[];
answer: any[];
time: number;
server: string;
};

type DnsParseResponse = DnsParseLoopResponse & {
rawOutput: string;
};

type DnsValueType = string | {
priority: number;
server: string;
};

type DnsSection = Record<string, unknown> | {
domain: string;
type: string;
ttl: number;
class: string;
value: DnsValueType;
};

/* eslint-disable @typescript-eslint/naming-convention */
const SECTION_REG_EXP = /(;; )(\S+)( SECTION:)/g;
const NEW_LINE_REG_EXP = /\r?\n/;
const QUERY_TIME_REG_EXP = /Query\s+time:\s+(\d+)/g;
const RESOLVER_REG_EXP = /SERVER:.*\((.*?)\)/g;
/* eslint-enable @typescript-eslint/naming-convention */

const allowedTypes = ['A', 'AAAA', 'ANY', 'CNAME', 'DNSKEY', 'DS', 'MX', 'NS', 'NSEC', 'PTR', 'RRSIG', 'SOA', 'TXT', 'SRV'];
const allowedProtocols = ['UDP', 'TCP'];

Expand All @@ -60,12 +33,14 @@ const dnsOptionsSchema = Joi.object<DnsOptions>({
resolver: Joi.string().optional(),
protocol: Joi.string().valid(...allowedProtocols).optional().default('udp'),
port: Joi.number().optional().default('53'),
trace: Joi.boolean().optional(),
}),
});

export const dnsCmd = (options: DnsOptions): ExecaChildProcess => {
const protocolArg = options.query.protocol?.toLowerCase() === 'tcp' ? '+tcp' : [];
const resolverArg = options.query.resolver ? `@${options.query.resolver}` : [];
const traceArg = options.query.trace ? '+trace' : [];

const args = [
options.target,
Expand All @@ -76,6 +51,7 @@ export const dnsCmd = (options: DnsOptions): ExecaChildProcess => {
'+timeout=3',
'+tries=2',
'+nocookie',
traceArg,
protocolArg,
].flat() as string[];

Expand All @@ -85,7 +61,7 @@ export const dnsCmd = (options: DnsOptions): ExecaChildProcess => {
export class DnsCommand implements CommandInterface<DnsOptions> {
constructor(private readonly cmd: typeof dnsCmd) {}

async run(socket: Socket, measurementId: string, testId: string, options: unknown): Promise<void> {
async run(socket: Socket, measurementId: string, testId: string, options: DnsOptions): Promise<void> {
const {value: cmdOptions, error} = dnsOptionsSchema.validate(options);

if (error) {
Expand All @@ -105,16 +81,13 @@ export class DnsCommand implements CommandInterface<DnsOptions> {

try {
const cmdResult = await cmd;
const parsedResult = this.parse(cmdResult.stdout);
const parsedResult = this.parse(cmdResult.stdout, Boolean(options.query.trace));

if (parsedResult instanceof Error) {
throw parsedResult;
}

const {answer, time, server, rawOutput} = parsedResult;
result = {
answer, time, server, rawOutput,
};
result = parsedResult;
} catch (error: unknown) {
const output = isExecaError(error) ? error.stderr.toString() : '';
result = {
Expand All @@ -129,127 +102,11 @@ export class DnsCommand implements CommandInterface<DnsOptions> {
});
}

private parse(rawOutput: string): Error | DnsParseResponse {
const lines = rawOutput.split(NEW_LINE_REG_EXP);

if (lines.length < 6) {
const message = lines[lines.length - 2];

if (!message || message.length < 2) {
return new Error(rawOutput);
}

return new Error(message);
}

return {
...this.parseLoop(lines),
rawOutput,
};
}

private parseLoop(lines: string[]): DnsParseLoopResponse {
const result: DnsParseLoopResponse = {
header: [],
answer: [],
server: '',
time: 0,
};

let section = 'header';
for (const line of lines) {
const time = this.getQueryTime(line);
if (time !== undefined) {
result.time = time;
}

const serverMatch = this.getResolverServer(line);
if (serverMatch) {
result.server = serverMatch;
}

let sectionChanged = false;
if (line.length === 0) {
section = '';
} else {
const sectionMatch = SECTION_REG_EXP.exec(line);

if (sectionMatch && sectionMatch.length >= 2) {
section = String(sectionMatch[2]).toLowerCase();
sectionChanged = true;
}
}

if (!section) {
continue;
}

if (!result[section]) {
result[section] = [];
}

if (!sectionChanged && line) {
if (section === 'header') {
result[section].push(line);
} else {
const sectionResult = this.parseSection(line.split(/\s+/g), section);
(result[section] as DnsSection[]).push(sectionResult);
}
}
}

return result;
}

private parseSection(values: string[], section: string): DnsSection {
if (!['answer', 'additional'].includes(section)) {
return {};
}

return {
domain: values[0],
type: values[3],
ttl: values[1],
class: values[2],
value: this.parseValue(values),
};
}

private getQueryTime(line: string): number | undefined {
const result = QUERY_TIME_REG_EXP.exec(line);

if (!result) {
return;
}

return Number(result[1]);
}

private getResolverServer(line: string): string | undefined {
const result = RESOLVER_REG_EXP.exec(line);

if (!result) {
return;
}

return String(result[1]);
}

private parseValue(values: string[]): DnsValueType {
const type = String(values[3]).toUpperCase();

if (type === 'SOA') {
return String(values.slice(4)).replace(/,/g, ' ');
}

if (type === 'MX') {
return {priority: Number(values[4]), server: String(values[5])};
}

if (type === 'TXT') {
return String(values.slice(4).join(' '));
private parse(rawOutput: string, trace: boolean): Error | DnsParseResponseClassic | DnsParseResponseTrace {
if (!trace) {
return ClassicDigParser.parse(rawOutput);
}

return String(values[values.length - 1]);
return TraceDigParser.parse(rawOutput);
}
}
125 changes: 125 additions & 0 deletions src/command/handlers/dig/classic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {
SECTION_REG_EXP,
NEW_LINE_REG_EXP,
SharedDigParser,
DnsSection,
DnsParseLoopResponse,
} from './shared.js';

export type DnsParseResponse = DnsParseLoopResponse & {
rawOutput: string;
};

/* eslint-disable @typescript-eslint/naming-convention */
const QUERY_TIME_REG_EXP = /Query\s+time:\s+(\d+)/g;
const RESOLVER_REG_EXP = /SERVER:.*\((.*?)\)/g;
/* eslint-enable @typescript-eslint/naming-convention */

// eslint-disable-next-line @typescript-eslint/naming-convention
export const ClassicDigParser = {
parse(rawOutput: string): Error | DnsParseResponse {
const lines = rawOutput.split(NEW_LINE_REG_EXP);

if (lines.length < 6) {
const message = lines[lines.length - 2];

if (!message || message.length < 2) {
return new Error(rawOutput);
}

return new Error(message);
}

return {
...ClassicDigParser.parseLoop(lines),
rawOutput,
};
},

parseLoop(lines: string[]): DnsParseLoopResponse {
const result: DnsParseLoopResponse = {
header: [],
answer: [],
server: '',
time: 0,
};

let section = 'header';
for (const line of lines) {
const time = ClassicDigParser.getQueryTime(line);
if (time !== undefined) {
result.time = time;
}

const serverMatch = ClassicDigParser.getResolverServer(line);
if (serverMatch) {
result.server = serverMatch;
}

let sectionChanged = false;
if (line.length === 0) {
section = '';
} else {
const sectionMatch = SECTION_REG_EXP.exec(line);

if (sectionMatch && sectionMatch.length >= 2) {
section = String(sectionMatch[2]).toLowerCase();
sectionChanged = true;
}
}

if (!section) {
continue;
}

if (!result[section]) {
result[section] = [];
}

if (!sectionChanged && line) {
if (section === 'header') {
result[section]!.push(line);
} else {
const sectionResult = ClassicDigParser.parseSection(line.split(/\s+/g), section);
(result[section] as DnsSection[]).push(sectionResult);
}
}
}

return {
answer: result.answer,
server: result.server,
time: result.time,
};
},

parseSection(values: string[], section: string): DnsSection {
if (!['answer', 'additional'].includes(section)) {
return {};
}

return SharedDigParser.parseSection(values);
},

getQueryTime(line: string): number | undefined {
const result = QUERY_TIME_REG_EXP.exec(line);

if (!result) {
return;
}

return Number(result[1]);
},

getResolverServer(line: string): string | undefined {
const result = RESOLVER_REG_EXP.exec(line);

if (!result) {
return;
}

return String(result[1]);
},
};

export default ClassicDigParser;
Loading

0 comments on commit 32e2988

Please sign in to comment.