UNPKG

dohdec-cli

Version:

DNS over HTTPS and DNS over TLS

304 lines (284 loc) 8.41 kB
import {Command, InvalidArgumentError} from 'commander'; import {DNSError, DNSoverHTTPS, DNSoverTLS, DNSutils} from 'dohdec'; import {Buffer} from 'node:buffer'; import assert from 'node:assert'; import net from 'node:net'; import readline from 'node:readline'; import util from 'node:util'; /** * Parse an int or throw if invalid. * * @param {string} value Command line value. * @returns {number} Parsed value. * @throws {InvalidArgumentError} Invalid number format. * @private */ function myParseInt(value) { const parsedValue = parseInt(value, 10); if (isNaN(parsedValue)) { throw new InvalidArgumentError('Bad number'); } return parsedValue; } /** * @param {any} pkt * @returns {asserts pkt is import('dns-packet').Packet} * @private */ function assertIsPacket(pkt) { assert(pkt); assert(typeof pkt === 'object'); assert(!Buffer.isBuffer(pkt)); assert(!Array.isArray(pkt)); } /** * @param {any} er * @returns {asserts er is Error} */ function assertIsError(er) { assert(er); assert(typeof er === 'object'); assert(Object.prototype.hasOwnProperty.call(er, 'message')); } /** * Parse an IPv4/IPv6 address or throw if invalid. * * @param {string} value Command line value. * @returns {string} Parsed value. * @throws {InvalidArgumentError} Invalid address format. * @private */ function checkAddress(value) { const family = net.isIP(value); if (family === 0) { throw new InvalidArgumentError('Invalid IPv[46] address'); } return value; } /** * @typedef {object} Stdio * @property {import('stream').Readable} [in] StdIn. * @property {import('stream').Writable} [out] StdOut. * @property {import('stream').Writable} [err] StdErr. */ /** * @typedef {object} HelpWidth * @property {number} [helpWidth] Width of help, in columns, for testing. */ /** * Command Line Interface for dohdec. */ export class DnsCli extends Command { /** * Create a CLI environment. * * @param {string[]} args Arguments from the command line * (usually process.argv). * @param {Stdio & HelpWidth} [stdio] Replacement streams for stdio, * for testing. */ constructor(args, stdio) { super(); /** @type {DNSoverHTTPS|DNSoverTLS|undefined} */ this.transport = undefined; /** @type {Required<Stdio>} */ this.std = { in: process.stdin, out: process.stdout, err: process.stderr, ...stdio, }; this .configureOutput({ writeOut: c => this.std.out.write(c), writeErr: c => this.std.err.write(c), }); if (stdio?.helpWidth) { this.configureHelp({helpWidth: stdio.helpWidth}); } this .version(DNSoverHTTPS.version) .argument('[name]', 'DNS name to look up (e.g. domain name) or IP address to reverse lookup. If not specified, a read-execute-print loop (REPL) is started.') .argument('[rrtype]', 'Resource record name or number', 'A') .option( '-c, --contentType <type>', 'MIME type for POST', 'application/dns-message' ) .option('-d, --dns', 'Use DNS format instead of JSON (ignored for TLS)') .option('-s, --dnssec', 'Request DNSsec records') .option('-k, --dnssecCheckingDisabled', 'Disable DNSsec validation') .option( '-e, --ecs <number>', 'Use this many bits for EDNS Client Subnet (ECS)', myParseInt ) .option( '-b, --ecsSubnet <address>', 'Use this IP address for EDNS Client Subnet (ECS)', checkAddress ) .option('-f, --full', 'Full response, not just answers') .option('-g, --get', 'Force http GET for DNS-format lookups') .option('-n, --no-decode', 'Do not decode JSON or DNS wire format') .option('-2, --no-http2', 'Disable http2 support') .option('-t, --tls', 'Use DNS-over-TLS instead of DNS-over-HTTPS') .option( '-i, --tlsServer <serverIP>', 'Connect to this DNS-over-TLS server', '1.1.1.1' ) .option( '-p, --tlsPort <number>', 'Connect to this TCP port for DNS-over-TLS', myParseInt, 853 ) .option( '-u, --url <URL>', 'The URL of the DoH service', DNSoverHTTPS.defaultURL ) .option( '-v, --verbose', 'Increase verbosity of debug information. May be specified multiple times.', (_, prev) => prev + 1, 0 ) .addHelpText('after', ` For more debug information: $ NODE_DEBUG=http,https,http2 dohdec -v [arguments]`); // END CLI CONFIG if (stdio && (Object.keys(stdio).length > 0)) { this.exitOverride(); } this.argv = this.parse(args).opts(); this.argv.name = this.args.shift(); this.argv.rrtype = this.args.shift(); this.transport = this.argv.tls ? new DNSoverTLS({ host: this.argv.tlsServer, port: this.argv.tlsPort, verbose: this.argv.verbose, verboseStream: this.std.err, }) : new DNSoverHTTPS({ contentType: this.argv.contentType, http2: this.argv.http2, preferPost: !this.argv.get, url: this.argv.url, verbose: this.argv.verbose, verboseStream: this.std.err, }); this.transport.verbose(1, 'DnsCli options:', this.argv); } /** * Run the CLI. */ async main() { assert(this.transport); try { if (this.argv.name) { await this.get(this.argv.name, this.argv.rrtype); } else { const [errors, total] = await this.prompt(); this.transport.verbose(0, `\n${errors}/${total} error${total === 1 ? '' : 's'}`); } } finally { this.transport.close(); } } /** * @param {string} name * @param {import('dns-packet').RecordType} rrtype */ async get(name, rrtype) { const opts = { name, rrtype, json: !this.argv.dns, decode: this.argv.decode, ecsSubnet: this.argv.ecsSubnet, ecs: this.argv.ecs, dnssec: this.argv.dnssec, dnssecCheckingDisabled: this.argv.dnssecCheckingDisabled, }; assert(this.transport); try { if (net.isIP(opts.name)) { opts.name = DNSutils.reverse(opts.name); if (!opts.rrtype) { opts.rrtype = 'PTR'; } } let resp = await this.transport.lookup(opts); if (this.argv.decode) { if (!this.argv.full) { assertIsPacket(resp); const er = DNSError.getError(resp); if (er) { // This isn't ideal, since a) this is normal operation and // b) we're just going to re-raise the error below. However, // this turned out to be easier to test. throw er; } resp = resp.answers || /** @type {Record<string, any>}*/ (resp).Answer || resp; } this.std.out.write(util.inspect(DNSutils.buffersToB64(resp), { depth: Infinity, // @ts-ignore TS2339: It's too hard to make this work correctly. colors: this.std.out.isTTY, })); this.std.out.write('\n'); } else { this.std.out.write(resp); if (!this.argv.dns && !this.argv.tls) { this.std.out.write('\n'); } } } catch (er) { assertIsError(er); this.transport.verbose(1, er) || this.transport.verbose(0, () => (er.message ? er.message : er)); throw er; } } async prompt() { assert(this.transport); let errors = 0; let total = 0; const rl = readline.createInterface({ input: this.std.in, output: this.std.out, prompt: 'domain (rrtype)> ', }); rl.prompt(); // Fix node v24.2 issue with close timing. // See https://github.com/nodejs/node/issues/58784 const oclose = rl.close; rl.close = (...args) => { setTimeout(() => oclose.apply(rl, args), 50); }; for await (const line of rl) { this.transport.verbose(1, 'LINE', line); if (line.length > 0) { total++; try { const [name, rrtype] = line.split(/\s+/); await this.get( name, /** @type {import('dns-packet').RecordType} */(rrtype) ); } catch (ignored) { // Catches all errors. get() printed them already errors++; } } rl.prompt(); } return [errors, total]; } }