📜 ⬆️ ⬇️

DNS proxy on Node.JS do it yourself

The package has bumped over the bumps into the far forest behind the DNS ...
L. Kaganov "Hamlet at the bottom"

When developing a network application, it is sometimes necessary to launch it locally, but to access it by the real domain name. The standard proven solution is to register a domain in the hosts file. The disadvantage of the approach is that hosts require a strict correspondence of domain names, i.e. does not support asterisks. Those. if there are domains like:


dom1.example.com, dom2.example.com, dom3.example.com, ................ domN.example.com, 

then you need to register them in hosts. In some cases, the third level domain is not known in advance. There is a desire (I write for myself, someone might say that it’s okay to do so) to do with a line like this:


 *.example.com 

The solution may be to install a local DNS server that will process requests according to the specified logic. Such servers are, and quite free, and with a convenient graphical interface. You can put and not bother. But this article describes another way - writing your own bicycle DNS-proxy that will listen for incoming DNS-requests, and if the requested domain name is in the list, it will return the specified IP, and if not, it will request the upstream DNS server and forward the received response unchanged requesting program.


At the same time, you can log requests and responses to them. Since DNS is needed by everyone - browsers, and messengers, and antivirus programs, and operating system services, etc., it can be quite informative.


The principle is simple. In the network connection settings for IPv4, we change the address of the DNS server to the address of the machine with our running samopisny DNS proxy (127.0.0.1 if we are not working over the network), and in its settings we specify the address of the upstream DNS server. And, like, everything!


We will not use the standard functions of domain name resolution nslookup and nsresolve , so the system DNS settings and the contents of the hosts file will not affect the operation of the program. Depending on the situation, it may be useful or not, you just need to remember this. For simplicity, we restrict ourselves to the implementation of the most basic functionality:



Speaking of tests

There are few unit tests in the project. True, they work according to the principle: launched, and if something imputed is displayed in the console, then everything is fine, and if an exception crashes, then there is a problem. But even such a clumsy approach allows you to successfully localize the problem, therefore Unit.


Start - server on port 53


Let's get started The first step is to teach the application to accept incoming DNS requests. We write a simple TCP server that simply listens to port 53 and logs incoming connections. In the properties of a network connection, we register the address of the DNS server 127.0.0.1, launch the application, go to the browser for several pages - and ... silence in the console, the browser displays the pages normally. Well, we change TCP to UDP, we start, we go by the browser - in the browser a connection error, in the console any binary data fell down. So, the system sends requests via UDP, and we will listen to incoming connections via UDP on port 53. Half an hour of work, of which 15 minutes google how to raise the TCP and UDP server on NodeJS - and we have solved the key task of the project, which determines the structure of the future application. The code is:


 const dgram = require('dgram'); const server = dgram.createSocket('udp4'); (function() { server.on('error', (err) => { console.log(`server error:\n${err.stack}`); server.close(); }); server.on('message', async (localReq, linfo) => { console.log(localReq); // Здесь потом будем слушать и обрабатывать входящие запросы от локальных клиентов }); server.on('listening', () => { const address = server.address(); console.log(`server listening ${address.address}:${address.port}`); }); const localListenPort = 53; const localListenAddress = 'localhost'; server.bind(localListenPort, localListenAddress); // server listening 0.0.0.0:53 }()); 

Listing 1. The minimum code needed to receive local DNS queries


The next item is to read the received message in order to understand whether it is necessary to return our IP in response to it, or simply to transfer it further.


DNS message


The structure of a DNS message is described in RFC-1035. Both requests and replies follow this structure, and in principle they differ in a single bit flag (QR field) in the message header. The message includes five sections:


 +---------------------+ | Header | +---------------------+ | Question | the question for the name server +---------------------+ | Answer | RRs answering the question +---------------------+ | Authority | RRs pointing toward an authority +---------------------+ | Additional | RRs holding additional information +---------------------+ 

The general structure of the DNS message (s) https://tools.ietf.org/html/rfc1035#section-4.1


A DNS message starts with a fixed-length header (this is the so-called Header section), which contains fields from 1 bit to two bytes in length (thus, one byte in the header can contain several fields). The header starts with the ID field - this is a 16-bit request identifier, the response must have the same ID. This is followed by fields describing the type of request, the result of its execution and the number of records in each of the subsequent sections of the message. To describe them all for a long time, so whoever is interested is the well-known RFC: https://tools.ietf.org/html/rfc1035#section-4.1.1 . The Header section is always present in the DNS message.


  1 1 1 1 1 1 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ID | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ |QR| Opcode |AA|TC|RD|RA| Z | RCODE | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | QDCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ANCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | NSCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ARCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ 

DNS message header structure (s) https://tools.ietf.org/html/rfc1035#section-4.1.1


Question Section


The Question section contains a record telling the server exactly what information is needed from it. Theoretically, in the section of such records there can be one or several, their number is indicated in the QDCOUNT field in the message header, and can be 0, 1 or more. But in practice, the Question section can contain only one entry. If the Question section contained several entries, and one of them would lead to an error when processing the request on the server, an uncertain situation would arise. Although the server will return an error code in the RCODE field in the response message, it will not be able to indicate which particular problem occurred during the processing, the specification does not describe this. Records also do not have fields containing an indication of the error and its type. Therefore, there is an agreement (undocumented), according to which the Question section can contain only one record, and the QDCOUNT field is set to 1. It is also not entirely clear how to process a request on the server side if it does contain several records in the Question . Someone advises to return a message with an error request. And, for example, Google DNS processes only the first record in the Question section, the others simply ignore it. Apparently, this remains at the discretion of the developers of DNS services.


In the response DNS message from the server, the Question section is also present and must completely copy the Question request (in order to avoid collisions, in case one ID field is not enough).


The only entry in the Question section contains the fields: QNAME (domain name), QTYPE (type), QCLASS (class). QTYPE and QCLASS are two-byte numbers denoting the type and class of the request. Possible types and classes are described in RFC-1035 https://tools.ietf.org/html/rfc1035#section-3.2 , everything is clear. But on the way of recording a domain name, we will dwell in more detail in the section "Domain Name Record Format".


In the case of a query, the DNS message most often ends with the Question section, sometimes it can be followed by the Additional section.


If an error occurred while processing the request on the server (for example, an incoming request was formed incorrectly), the response message will also end with the Question or Additional section, and the RCODE field of the response message header will contain an error code.


Section Answer , Authority and Additional


The following sections are Answer , Authority and Additional ( Answer and Authority are contained only in the reply DNS message, Additional may be found in the request and in the answer). They are optional, i.e. any of them may or may not be present, depending on the incoming request. These sections have the same structure and contain information in the format of so-called "resource records" ( resourse record , or RR). Figuratively speaking, each of these sections is an array of resource records, and a record is an object with fields. Each section can contain one or several records, their number is indicated in the corresponding field in the message header (ANCOUNT, NSCOUNT, ARCOUNT, respectively). For example, an IP request for the "google.com" domain will return several IP addresses, so there will also be several entries in the Answer section, one for each address. If there is no section, then the corresponding header field contains 0.


Each resource record (RR) begins with the NAME field containing the domain name. The format of this field repeats the format of the QNAME field of the Question section.
Next to NAME are the TYPE (record type), and CLASS (its class) fields, both fields are 16-bit numeric, indicating the type and class of the record. This also resembles the Question section, with the difference that its QTYPE and QCLASS can have all the same values ​​as TYPE and CLASS, and some more of their own, unique to them. That is, to put it in a dry scientific language, the set of QTYPE and QCLASS values ​​is a superset of the TYPE and CLASS values. Learn more about the differences in https://tools.ietf.org/html/rfc1035#section-3.2.2 .
Remaining fields:



Domain Name Record Format


The format of a domain name record is the same for the QNAME and NAME fields, as well as for the RDATA field, if it is a CNAME, MX, NS class record or another one that assumes a domain name as a result.


A domain name is a sequence of labels (name sections, subdomains - in the original this label , I did not find a better translation). A label is a single byte of length, containing a number - the length of the contents of the label in bytes, followed by a sequence of bytes of the specified length. The labels follow one another until a length byte containing 0 is encountered. The very first label can immediately have a zero length, which means the root domain (Root Domain) with an empty domain name (sometimes written as "").


In earlier versions, the DNS bytes in the label could have any value from (from 0 to 255). There were rules that had the character of a strong recommendation: that the label should begin with a letter, end with a letter or a number, and contain only letters, numbers or a hyphen in 7-bit ASCII, with a zero significant bit. The current EDNS specification already requires you to follow these rules clearly, without deviations.


The two high bits of the length byte are used as a label type attribute. If they are zero ( 0b00xxxxxx ), then this is a common label, and the remaining bits of the length byte indicate the number of bytes of the data included in it. The maximum mark length is 63 characters. 63 in binary encoding is just 0b00111111 .


If the two most significant bits are 0 and 1, respectively ( 0b01xxxxxx ), then this is an extended type label of the EDNS standard ( https://tools.ietf.org/html/rfc2671#section-3.1 ), which came to us from February 1, 2019. The lower six bits will contain the value of the label. In this article EDNS we do not consider, but it is useful to know that this also happens.


The combination of the two most significant bits, 1 and 0 ( 0b10xxxxxx ), is reserved for future use.


If both high-order bits are equal to 1 ( 0b11xxxxxx ), this means that compression of domain names is used, and we will dwell on this in more detail.


Domain Name Compression


So, if a byte of length two high bits are 1 ( 0b11xxxxxx ), this is a sign of the compression of the domain name. Compression is used to make messages shorter and more capacious. This is especially true when working on UDP, when the total length of a DNS message is limited to 512 bytes (although this is the old standard, see https://tools.ietf.org/html/rfc1035#section-2.3.4 Size limits , new EDNS allows you to send messages using the UPD protocol (longer). The essence of the process is such that if there are domain names with the same top-level subdomains in a DNS message (for example, mail.yandex.ru and yandex.ru ), instead of re-indicating the entire domain name, the byte number in the DNS message is indicated, from which should continue reading the domain name. This can be any byte of the DNS message, not only in the current record or section, but with the proviso that it is a byte of the length of the domain label. Refer to the middle of the label can not be. Suppose there is a domain mail.yandex.ru in the message, with the help of compression it is possible to also denote the domains yandex.ru , ru and root "" (of course, it is easier to write the root without compression, but it is technically possible to do it with compression), here to make ndex.ru will not work. Also, to finish all derived domain names will be the root domain, that is, write, say, mail.yandex also fails.


Domain name can:



For example, we are compiling a DNS message, and we have previously encountered the name "dom3.example.com", now we need to specify "dom4.dom3.example.com". In this case, you can write the section "dom4" without compression, and then go to compression, that is, add a link to "dom3.example.com". Or vice versa, if the name "dom4.dom3.example.com" was previously encountered, then to indicate "dom3.example.com" you can immediately use compression, referring to the label "dom3" in it. What we can NOT do - as already mentioned, specify through compression part of 'dom4.dom3', because the name must end with the top level section. If you suddenly need to specify the segments from the middle - they are simply indicated without compression.


For simplicity, our program can not write domain names with compression, can only read. The standard allows it, the reading must be implemented necessarily, the record is optional. Technically, the reading is implemented as follows: if the two high-order bits of a length byte contain 1, then we read the next byte, and treat these two bytes as a 16-bit unsigned integer, with the order of the Big Endian bits. The two high-order bits (containing 1) are discarded, we read the resulting 14-bit number, and we continue further reading of the domain name from a byte in the DNS message with the number corresponding to this number.


The code for the function of reading a domain name is:


 function readDomainName (buf, startOffset, objReturnValue = {}) { let currentByteIndex = startOffset; // Номер байта в буфере, содержащем DNS-сообщение полностью, который читаем в данный момент let initOctet = buf.readUInt8(currentByteIndex); let domain = ''; // Обрабатываем возможный случай с корневым доменом, т.е. когда первый же байт длины равен 0, // и следовательно, доменное имя является пустой строкой // "the root domain name has no labels." (c) RFC-1035, p. 4.1.4. Message compression objReturnValue['endOffset'] = currentByteIndex; let lengthOctet = initOctet; while (lengthOctet > 0) { // Читаем метку доменного имени var label; if (lengthOctet >= 192) { // Признак использования компрессии: значение 0b1100 0000 или больше const pointer = buf.readUInt16BE(currentByteIndex) - 49152; // 49152 === 0b1100 0000 0000 0000 === 192 * 256 const returnValue = {} label = readDomainName(buf, pointer, returnValue); domain += ('.' + label); objReturnValue['endOffset'] = currentByteIndex + 1; // Участок с компрессией всегда заканчивает последовательность, поэтому здесь выходим из цикла break; } else { currentByteIndex++; label = buf.toString('ascii', currentByteIndex, currentByteIndex + lengthOctet); domain += ('.' + label); currentByteIndex += lengthOctet; lengthOctet = buf.readUInt8(currentByteIndex); objReturnValue['endOffset'] = currentByteIndex; } } return domain.substring(1); // Убираем первый символ — точку "." } 

Listing 2. Reading domain names from a DNS query


The full code of the function of reading DNS records from the binary buffer:


Listing 3. Reading DNS records from binary buffer
 function parseDnsMessageBytes (buf) { const msgFields = {}; // (c) RFC 1035 p. 4.1.1. Header section format msgFields['ID'] = buf.readUInt16BE(0); const byte_2 = buf.readUInt8(2); // байт #2 (starting from 0) const mask_QR = 0b10000000; msgFields['QR'] = !!(byte_2 & mask_QR); // Тип сообщения: 0 "false" => запрос, 1 "true" => ответ const mask_Opcode = 0b01111000; const opcode = (byte_2 & mask_Opcode) >>> 3; // значимые значения (десятичные): 0, 1, 2, остальные зарезервированы msgFields['Opcode'] = opcode; const mask_AA = 0b00000100; msgFields['AA'] = !!(byte_2 & mask_AA); const mask_TC = 0b00000010; msgFields['TC'] = !!(byte_2 & mask_TC); const mask_RD = 0b00000001; msgFields['RD'] = !!(byte_2 & mask_RD); const byte_3 = buf.readUInt8(3); // байт #3 const mask_RA = 0b10000000; msgFields['RA'] = !!(byte_3 & mask_RA); const mask_Z = 0b01110000; msgFields['Z'] = (byte_3 & mask_Z) >>> 4; // всегда 0, зарезервировани const mask_RCODE = 0b00001111; msgFields['RCODE'] = (byte_3 & mask_RCODE); // 0 => no error; (dec) 1, 2, 3, 4, 5 - errors, see RFC msgFields['QDCOUNT'] = buf.readUInt16BE(4); // число записей в секции Question, по факту 0 или 1 msgFields['ANCOUNT'] = buf.readUInt16BE(6); // число записей в секции Answer msgFields['NSCOUNT'] = buf.readUInt16BE(8); // число записей в секции Authority msgFields['ARCOUNT'] = buf.readUInt16BE(10); // число записей в секции Additional // читаем содержимое секции Question let currentByteIndex = 12; // секция Question начинается с 12-го байта DNS-сообщения (c) RFC 1035 p. 4.1.2. Question section format msgFields['questions'] = []; for (let qdcount = 0; qdcount < msgFields['QDCOUNT']; qdcount++) { const question = {}; const resultByteIndexObj = { endOffset: undefined }; const domain = readDomainName(buf, currentByteIndex, resultByteIndexObj); currentByteIndex = resultByteIndexObj.endOffset + 1; question['domainName'] = domain; question['qtype'] = buf.readUInt16BE(currentByteIndex); // 1 => "A" record currentByteIndex += 2; question['qclass'] = buf.readUInt16BE(currentByteIndex); // 1 => "IN" Internet currentByteIndex += 2; msgFields['questions'].push(question); } // (c) RFC 1035 p. 4.1.3. Resource record format // читаем ресурсные записи (Resourse Records, RR) секций Answer, Authority, Additional ['answer', 'authority', 'additional'].forEach(function(section, i, arr) { let msgFieldsName, countFieldName; switch(section) { case 'answer': msgFieldsName = 'answers'; countFieldName = 'ANCOUNT'; break; case 'authority': msgFieldsName = 'authorities'; countFieldName = 'NSCOUNT'; break; case 'additional': msgFieldsName = 'additionals'; countFieldName = 'ARCOUNT'; break; } msgFields[msgFieldsName] = []; for (let recordsCount = 0; recordsCount < msgFields[countFieldName]; recordsCount++) { let record = {}; const objReturnValue = {}; const domain = readDomainName(buf, currentByteIndex, objReturnValue); currentByteIndex = objReturnValue['endOffset'] + 1; record['domainName'] = domain; record['type'] = buf.readUInt16BE(currentByteIndex); // 1 => "A" record currentByteIndex += 2; record['class'] = buf.readUInt16BE(currentByteIndex); // 1 => "IN" Internet currentByteIndex += 2; // TTL занимает 4 байта record['ttl'] = buf.readUIntBE(currentByteIndex, 4); currentByteIndex += 4; record['rdlength'] = buf.readUInt16BE(currentByteIndex); currentByteIndex += 2; const rdataBinTempBuf = buf.slice(currentByteIndex, currentByteIndex + record['rdlength']); record['rdata_bin'] = Buffer.alloc(record['rdlength'], rdataBinTempBuf); if (record['type'] === 1 && record['class'] === 1) { // если данные представляют собой адрес IPv4, читаем и преобразуем в строку let ipStr = ''; for (ipv4ByteIndex = 0; ipv4ByteIndex < 4; ipv4ByteIndex++) { ipStr += '.' + buf.readUInt8(currentByteIndex).toString(); currentByteIndex++; } record['IPv4'] = ipStr.substring(1); // убираем заглавную точку '.' } else { // иначе просто пропускаем данные, не читая currentByteIndex += record['rdlength']; } msgFields[msgFieldsName].push(record); } }); return msgFields; } 

Listing 3. Reading DNS records from binary buffer


Finally, a request from a local client is received and parsed. Проверяем, нужно ли возвращать фиктивный ответ, и если да, то формируем и возвращаем. Если же нет, то посылаем запрос удалённому DNS-серверу, прямо так как он получен, в двоичном виде. Получив ответ, передаём его адресату так же без изменений.


Разбор запроса, проверка и формирование ответа будет происходить в колл-бэке server.on("message", () => {}) из листинга 1. Код получается таким:


Листинг 4. Обработка входящего локального DNS-запроса
 server.on('message', async (localReq, linfo) => { const dnsRequest = functions.parseDnsMessageBytes(localReq); const question = dnsRequest.questions[0]; // currently, only one question per query is supported by DNS implementations let forgingHostParams = undefined; // Проверяем, нужно ли для данного доменного имени возвращать наш IP for (let i = 0; i < config.requestsToForge.length; i++) { const requestToForge = config.requestsToForge[i]; const targetDomainName = requestToForge.hostName; if (functions.domainNameMatchesTemplate(question.domainName, targetDomainName) && question.qclass === 1 && question.qtype === 1) { forgingHostParams = requestToForge; break; } } // Если да, то формируем полностью DNS-ответ и возвращаем его локальному клиенту if (!!forgingHostParams) { const forgeIp = forgingHostParams.ip; const answers = []; answers.push({ domainName: question.domainName, type: question.qtype, class: question.qclass, ttl: forgedRequestsTTL, rdlength: 4, rdata_bin: functions.ip4StringToBuffer(forgeIp), IPv4: forgeIp }); const localDnsResponse = { ID: dnsRequest.ID, QR: dnsRequest.QR, Opcode: dnsRequest.Opcode, AA: dnsRequest.AA, TC: false, // dnsRequest.TC, RD: dnsRequest.RD, RA: true, Z: dnsRequest.Z, RCODE: 0, // dnsRequest.RCODE, 0 - no errors, look in RFC-1035 for other error conditions QDCOUNT: dnsRequest.QDCOUNT, ANCOUNT: answers.length, NSCOUNT: dnsRequest.NSCOUNT, ARCOUNT: dnsRequest.ARCOUNT, questions: dnsRequest.questions, answers: answers } // Преобразуем объект с полями DNS-ответа в бинарный буфер const responseBuf = functions.composeDnsMessageBin(localDnsResponse); console.log('response composed for: ', localDnsResponse.questions[0]); server.send(responseBuf, linfo.port, linfo.address, (err, bytes) => {}); } // Иначе, делаем запрос на вышестоящий DNS-сервер, и передаём его ответ локальному клиенту без изменений else { // При связи с удалённым DNS-сервером по UDP, пересылаем ему локальный запрос const responseBuf = await functions.getRemoteDnsResponseBin(localReq, upstreamDnsIP, upstreamDnsPort); // и прозрачно отправляем локальному клиенту полученный ответ server.send(responseBuf, linfo.port, linfo.address, (err, bytes) => {}); // При связи с удалённым DNS-сервером по TLS, механизм будет другим, см. листинг 9 } }); 

Листинг 4. Обработка входящего локального DNS-запроса


Добавляем поддержку TLS


В последнее время многих заботит вопрос шифрования DNS-трафика. Чтобы быть в тренде, добавим поддержку подключения к вышестоящему DNS-серверу по протоколу TLS (HTTPS пока трогать не будем). Обмен DNS-сообщениями по TLS похож на таковой по TCP, разница только в том, что для TLS предварительно устанавливается шифрованный канал. Но внутри этого канала обмен информацией идёт сходно с TCP, и регламентируется RFC-7766 DNS Transport over TCP ( https://tools.ietf.org/html/rfc7766 ). Чтобы никого не путать, сразу отмечу: мы добавляем в программу поддержку TLS, работать с TCP не будем (в принципе, чтобы добавить поддержку связи с внешним DNS по TCP, нужно только заменить в программе TLS-сокет на TCP-сокет, но сейчас мы это пропустим).


Установка TLS-соединения


Установка TLS-соединения влечёт за собой дополнительные накладные расходы со стороны сервера и клиента, поэтому его целесообразно поддерживать открытым и восстанавливать, если произошёл разрыв. Вообще говоря, никто не запрещает на каждый запрос создавать новое TLS-подключение, и таким образом упростить логику работы приложения. Но RFC-7858 всё-таки рекомендует использовать одно подключение для выполнения разных запросов:


 In order to amortize TCP and TLS connection setup costs, clients and servers SHOULD NOT immediately close a connection after each response. Instead, clients and servers SHOULD reuse existing connections for subsequent queries as long as they have sufficient resources. In some cases, this means that clients and servers may need to keep idle connections open for some amount of time. (с) https://tools.ietf.org/html/rfc7858#section-3.4 

Перед отправкой каждого запроса программа будет проверять, активно ли TLS-подключение, и если да, то отправит данные через него, а если нет, то создаст новое, и опять же пошлёт данные через него. Так же договоримся, что если подключение не активно в течение 30 секунд, закроем его сами, и потом при необходимости создадим новое, чтобы не занимать попусту ресурсы на удалённом DNS-сервере. Время 30 секунд ~взято с потолка~ выбрано мной произвольно, можно сделать 15 или 60 сек, или вообще реализовать получение этого параметра из файла конфигурации. Можно вообще держать подключение открытым сколько угодно долго, удалённый сервер сам его закроет в случае недостатка ресурсов. Но это как-то неэлегантно.


TLS-соединение будем устанавливать стандартными средствами NodeJS. Чтобы не захламлять код, логику работы с TLS-соединением целесообразно вынести в отдельный модуль:


 const tls = require('tls'); const TLS_SOCKET_IDLE_TIMEOUT = 30000; // интервал неактивности в милисекундах, после которого мы закроем TLS-соединение function Module(connectionOptions, funcOnData, funcOnError, funcOnClose, funcOnEnd) { let socket; function connect() { socket = tls.connect(connectionOptions, () => { console.log('client connection established:', socket.authorized ? 'authorized' : 'unauthorized'); }); socket.on('data', funcOnData); // connection.on('end', () => {}); socket.on('close', (hasTransmissionError) => { // Не переоткрываем соединение, если оно закрыто удалённым сервером. // Откроем новое соединение, когда поступит входящий запрос console.log('connection closed; transmission error:', hasTransmissionError); }); socket.on('end', () => { console.log('remote TLS server connection closed.') }); socket.on('error', (err) => { console.log('connection error:', err); console.log('\tmessage:', err.message); console.log('\tstack:', err.stack); }) socket.setTimeout(TLS_SOCKET_IDLE_TIMEOUT); socket.on('timeout', () => { console.log('socket idle timeout, disconnected.'); socket.end(); }); } this.write = function (dataBuf) { if (socket && socket.writable) { // соединение активно, дополнительных действий не требуется } else { connect(); } socket.write(dataBuf); } return this; } module.exports = Module; 

Листинг 5. Модуль, отвечающий за TLS-соединение


Этого достаточно для соединения с публичными DNS-over-TLS сервисами, такими как Google DNS. Если сервер требует аутентификации с помощью клиентского сертификата, понадобится ещё добавить чтение сертификата из локального файла и передачу его в конструктор соединения socket = tls.connect(connectionOptions, () => {}) . Это описано в документации NodeJS: https://nodejs.org/api/tls.html#tls_tls_connect_options_callback , здесь мы этот случай рассматривать не будем.


Установка TLS-соединения с помощью модуля:


 const options = { port: config.upstreamDnsTlsPort, // работа с конфигурацией описана далее в статье host: config.upstreamDnsTlsHost } const onData = (data) => { // Здесь будем обрабатывать поступившие ответы, см. описание далее в статье и Листинг 7 }; remoteTlsClient = new TlsClient(options, onData); 

Листинг 6. Установка TLS-соединения


После того как соединение установлено, дальнейшая работа с ним происходит аналогично обычному TCP-соединению. В одном TCP/TLS-сообщении может содержаться одно или несколько DNS-сообщений, следующих подряд одно за другим, и чтобы различать их, каждому сообщению предшествуют два байта, содержащие его длину. При работе по TCP (и соответственно TLS), длина DNS-сообщения не ограничивается 512 байтами, в отличие от UDP (хотя, в EDNS это ограничение для UDP тоже снято). В остальном, структура DNS-сообщения идентична таковой для UDP, и для обработки его мы применяем одни и те же функции и методы. Получившийся код помещаем в тело функции onData() из листинга 6.


 const onData = (data) => { // Обрабатываем ответ удалённого DNS-сервера, с учётом того что в одном TLS-сообщении может содержаться // один или несколько ответов, и каждому ответу предшествует 2 байта, содержащих длину в байтах этого ответа let dataCurrentPos = 0; try { while (dataCurrentPos < data.length) { const respLen = data.readUInt16BE(dataCurrentPos); respBuf = data.slice(dataCurrentPos + 2, dataCurrentPos + 2 + respLen); const respData = functions.parseDnsMessageBytes(respBuf); const requestKey = functions.getRequestIdentifier(respData); const localResponseParams = localRequestsAwaiting.get(requestKey); localRequestsAwaiting.delete(requestKey); server.send(respBuf, localResponseParams.port, localResponseParams.address, (err, bytesNum) => {}); dataCurrentPos += 2 + respLen; } } catch (err) { console.error(err); // На время разработки, для наглядности бросаем исключение throw err; } }; 

Листинг 7. Обработка ответного TLS-сообщения от вышестоящего DNS-сервера из листинга 6


Порядок ответов от удалённого DNS-сервера


По стандарту, ответы от удалённого сервера не обязательно должны приходить в том же порядке, в котором были отправлены запросы. На этот случай, спецификация предписывает сопоставлять полученные ответы запросам по полю ID заголовка сообщения и полям QNAME, QTYPE и QCLASS секции Question :


 Since pipelined responses can arrive out of order, clients MUST match responses to outstanding queries on the same TLS connection using the Message ID. If the response contains a Question Section, the client MUST match the QNAME, QCLASS, and QTYPE fields. (с) https://tools.ietf.org/html/rfc7858#section-3.3 

Поэтому нам нужно реализовать механизм, определяющий адресата, которому будет передан ответ, на основе ID и секции Question (как уже было сказано, они совпадают у запроса и ответа).


Когда мы общались с удалённым сервером по UDP (см. листинг 4), это было не актуально, потому что для простоты я решил в каждом колл-бэке, обрабатывающем локальный входящий запрос, создавать новый UDP-сокет для связи с удалённым сервером. При создании сокета ему выделяется свободный уникальный порт, с которого сокет отправит запрос удалённому DNS-серверу, и получит ответ на этот же порт. Полученный ответ будет отправлен запросившему его клиенту по локальному подключению, свойства которого сохраняются в этом же колл-бэке. Таким образом, ответы удалённого сервера для разных локальных запросов не перепутаются, потому что будут получены разными UDP-сокетами на разных портах и переданы адресатам в разных колл-бэках. Ну и, получив ответ, не забываем закрывать сокет.


А вот при работе по TLS, ответы вышестоящего сервера для разных локадьных клиентов будут приходить по одному и тому же подключению. Понадобится хранить параметры подключения для каждого локального запроса (IP и порт), а так же определять, какому из локальных клиентов предназначен каждый ответ.


Для каждого локального запроса будем сохранять его IP и порт в коллекции пар "ключ-значение". В качестве ключа, для простоты и наглядности, условимся использовать строку, полученную конкатенацией вышеуказанных полей DNS-сообщения. При получении ответа, его нужно будет прочитать, чтобы по этим же полям получить из коллекции IP и порт, по которому ответ будет переправлен. Обратите внимание на эти строки в листинге 7:


 // Получаем ключ на основе полей входящего подключения const requestKey = functions.getRequestIdentifier(respData); // Получаем из коллекции IP и порт лоакльного подключения, соответствующего ответу const localResponseParams = localRequestsAwaiting.get(requestKey); localRequestsAwaiting.delete(requestKey); // Переправляем ответ по полученным локальному IP и порту server.send(respBuf, localResponseParams.port, localResponseParams.address, (err, bytesNum) => {}); 

Листинг 8. Пояснение выбора локального подключения в коде листинга 7


Отправка запроса удалённому серверу по TLS-подключению:


 // данные локального подключения, по которому получен запрос const localReqParams = { address: linfo.address, port: linfo.port }; // Получаем ключ на основе полей входящего подключения const requestKey = functions.getRequestIdentifier(dnsRequest); // Сохраняем данные локальноо подключения в коллекцию localRequestsAwaiting.set(requestKey, localReqParams); // Добавляем перед байтовым буфером запроса два байта, хранящие его длину в байтах const lenBuf = Buffer.alloc(2); lenBuf.writeUInt16BE(localReq.length); const prepReqBuf = Buffer.concat([lenBuf, localReq], 2 + localReq.length); remoteTlsClient.write(prepReqBuf); // согласно RFC-7766 p.8, 2 байта длины и последовательность байт запроса должны быть отправлены за один вызов метода записи сокета 

Листинг 9. Отправка запроса удалённому DNS-серверу по TLS-подключению (так же см. листинг 4)


Чтение конфигурации из файла и её обновление


Ну и наконец, для элементарного удобства, вынесем настройки программы в файл конфингурации. Выберем для него формат JSON, с ним удобно работать, потому что NodeJS умеет подключать JSON-файлы как модули и парсить их прозрачно. Минус JSON — файл конфигурации не сможет сождержать комментарии, а они бывают ох как нужны. Как вариант, можно создавать в JSON-е поле "comment" (или любое похожее) и в его значении помещать текст комментария. Хотя, конечно же, это костыль, но всё же лучше, чем ничего. Так же, пока не будем делать проверку корректности синтаксиса конфигурации, это придётся держать в уме. Чтение конфигурации реализовано через подключаемый модуль, который возвращает синглтон-экземпляр объекта конфигурации, единый для всего приложения, а так же мониторит файл конфигурации на предмет изменений стандартными средствами NodeJS. Если файл был изменён, он снова считывается, и конфигурация обновляется на лету. То есть, при внесении изменений в конфигурацию пререзапускать программу не нужно, достаточно просто поправить конфиг в текстовом редакторе; как по мне, это весьма удобно. Хотя при разрастании и усложнении структуры конфига вероятность допустить ошибку возрастёт, и с этим придётся что-то решать.


Листинг 10. Модуль чтения и обновления конфигурации
 const path = require('path'); const fs = require('fs'); const CONFIG_FILE_PATH = path.resolve('./config.json'); function Module () { // config является объектом-константой, поэтому может быть безопасно назначен другой переменной. // Но внутренние свойства config переопределяются при изменении и последующем чтении конфигурационного файла, // поэтому обращаться к ним можно как к свойствам объекта. Например, вы можете сделать так: // const conf = config; // и свойства conf будут обновлены при обновлении конфигурации, но избегайте делать так: // const requestsToForge = config.requestsToForge; // поскольку при обновлении конфигурации, requestsToForge не будет обновлён. const config = {}; Object.defineProperty(this, 'config', { get() { return config; }, enumerable: true }) this.initConfig = async function() { const fileContents = await readConfigFile(CONFIG_FILE_PATH); console.log('initConfig:'); console.log(fileContents); console.log('fileContents logged ^^'); const parsedConfigData = parseConfig(fileContents); Object.assign(config, parsedConfigData); }; async function readConfigFile(configPath) { const promise = new Promise((resolve, reject) => { fs.readFile(configPath, { encoding: 'utf8', flag: 'r' }, (err, data) => { if (err) { console.log('readConfigFile err to throw'); throw err; } resolve(data); }); }) .then( fileContents => { return fileContents; } ) .catch(err => { console.log('readConfigFile error: ', err); }); return promise; } function parseConfig(fileContents) { const configData = JSON.parse(fileContents); return configData; } // Обновляем когфигурацию программы, если конфигурационный файл был отредактирован и сохранён. // На Windows, при изменении файла fs.watch вызывается дважды с небольшим интервалом, // поэтому чтобы предотвратить конфликт при чтении, используем флаг configReadInProgress let configReadInProgress = false; fs.watch(CONFIG_FILE_PATH, async () => { if(!configReadInProgress) { configReadInProgress = true; console.log('===== config changed, run initConfig() ====='); try { await this.initConfig(); } catch (err) { console.log('===== error initConfig(), skip =====,', err); configReadInProgress = false; } configReadInProgress = false; } else { console.log('===== config changed, initConfig() already running, skip ====='); } }); } let instance; async function getInstance() { if(!instance) { instance = new Module(); await instance.initConfig(); } return instance; } module.exports = getInstance; 

Листинг 10. Модуль чтения и обновления конфигурации


Итого


Мы написали небольшой DNS-прокси на NodeJS, не применяя при этом npm и стороние библиотеки. Хотя возможности его ограничены, с задачей обслуживания локальных клиентов он вполне справляется, а так же, при желании, может логировать поступающие запросы и ответы для дальнейшего изучения.


Полный код на GitHub


Источники:




Source: https://habr.com/ru/post/440050/