DNS proxy on Node.JS do it yourself

A package of bumps has been carried to the far forest beyond 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 keep this in mind. For simplicity, we restrict ourselves to the implementation of the most basic functionality:


  • IP spoofing only for type A (host address) and IN (internet) class records
  • version 4 IP spoofed addresses only
  • connection for local incoming requests via UDP only
  • connection to the upstream DNS server via UDP or TLS
  • if there are several network interfaces, incoming local requests will be received on any of them
  • no EDNS support

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 the 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 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 it can be 0, 1 or more. But in practice, the Question section can contain only one entry. If the Question Sectioncontained several records, and one of them would lead to an error when processing the request on the server, an indefinite 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 entry in the section.Question , the rest is simply ignored. 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 can be present or not, 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 is the same as 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:


  • TTL is a 32-bit number indicating the time of record validity (in seconds).
  • RDLENGTH is a 16-bit number indicating the length of the next RDATA field in bytes.
  • RDATA is the actual payload, the format depends on the type of recording. For example, for an A (host address) and IN (Internet) class entry, this is 4 bytes, representing an IPv4 address.

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 the DNS message (for example, mail.yandex.ru and yandex.ru), instead of re-specifying the entire domain name, the byte number in the DNS message is indicated, from which the domain name should continue to be read. 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. Just end all derived domain names will be the root domain, that is, write, say, mail.yandex also fail.


Domain name can:


  • be completely written without compression,
  • start with a place that uses compression,
  • start with one or more tags without compression, and then go on to compression,
  • be empty (for the root domain).

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, indicate 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:


functionreadDomainName (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 * 256const 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
functionparseDnsMessageBytes (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);                // байт #3const 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// читаем содержимое секции Questionlet 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. We check whether it is necessary to return a fictitious response, and if so, then we form and return. If not, then send the request to the remote DNS server, just as it was received, in binary form. Having received the answer, we transfer it to the addressee without any changes.


Parsing the request, checking and forming the response will occur in a callback server.on("message", () => {})from Listing 1. The code is as follows:


Listing 4. Processing an incoming local DNS request
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 implementationslet forgingHostParams = undefined;
    // Проверяем, нужно ли для данного доменного имени возвращать наш IPfor (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
    }
});

Listing 4. Processing an incoming local DNS request


Adding TLS support


Recently, many people are concerned about the issue of encrypting DNS traffic. To be in trend, we will add support for connecting to the upstream DNS server via TLS (we will not touch HTTPS for now). DNS messaging over TLS is similar to TCP. The only difference is that the encrypted channel is pre-set for TLS. But inside this channel, the exchange of information is similar to TCP, and RFC-7766 DNS Transport over TCP ( https://tools.ietf.org/html/rfc7766 ) is regulated . In order not to confuse anyone, I’ll immediately note: we add TLS support to the program, we will not work with TCP (in principle, to add support for communicating with an external DNS via TCP, you only need to replace the TLS socket with a TCP socket, but now we’ll skip this).


TLS connection setup


Installing a TLS connection entails additional overhead on the server side and the client, so it is advisable to keep it open and restore it if a gap has occurred. Generally speaking, no one forbids creating a new TLS connection for each request, and thus simplify the logic of the application. But RFC-7858 still recommends using one connection to perform different requests:


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

Before sending each request, the program will check whether the TLS connection is active, and if so, send data through it, and if not, create a new one, and send the data through it again. We also agree that if the connection is not active within 30 seconds, we close it ourselves, and then, if necessary, create a new one so as not to waste resources on the remote DNS server. A time of 30 seconds ~ taken from the ceiling ~ chosen by me arbitrarily, you can make 15 or 60 seconds, or generally implement getting this parameter from the configuration file. You can keep the connection open for as long as you want; the remote server will close it itself in case of a lack of resources. But it is somehow inelegant.


We will establish a TLS connection using standard NodeJS tools. In order not to litter the code, it is advisable to take the logic of working with a TLS connection into a separate module:


const tls = require('tls');
const TLS_SOCKET_IDLE_TIMEOUT = 30000;   // интервал неактивности в милисекундах, после которого мы закроем TLS-соединениеfunctionModule(connectionOptions, funcOnData, funcOnError, funcOnClose, funcOnEnd) {
    let socket;
    functionconnect() {
        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);
    }
    returnthis;
}
module.exports = Module;

Listing 5. Module responsible for TLS connection


This is sufficient to connect to public DNS-over-TLS services, such as Google DNS. If the server requires authentication using a client certificate, you will need to add another reading of the certificate from the local file and transfer it to the connection designer socket = tls.connect(connectionOptions, () => {}). This is described in the NodeJS documentation: https://nodejs.org/api/tls.html#tls_tls_connect_options_callback , here we will not consider this case.


Establishing a TLS connection using the module:


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

Listing 6. Set up a TLS connection


After the connection is established, further work with it is similar to a normal TCP connection. One TCP / TLS message may contain one or more DNS messages following in succession one after the other, and to distinguish them, each message is preceded by two bytes containing its length. When working on TCP (and accordingly TLS), the length of the DNS message is not limited to 512 bytes, unlike UDP (although, in EDNS, this restriction for UDP is also removed). Otherwise, the structure of the DNS message is identical to that for UDP, and we use the same functions and methods for processing it. The resulting code is placed in the body of the onData () function from Listing 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;
    }
};

Listing 7. Processing the reply TLS message from the upstream DNS server from Listing 6


Order of responses from a remote DNS server


According to the standard, the responses from the remote server do not have to come in the same order in which the requests were sent. In this case, the specification prescribes to match the received answers to queries on the message header ID field and the QNAME, QTYPE and QCLASS fields of the Question section :


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

Therefore, we need to implement a mechanism that determines the addressee to whom the answer will be transmitted, based on the ID and the Question section (as already mentioned, they match the request and response).


When we communicated with the remote server via UDP (see Listing 4), this was not relevant, because for simplicity I decided to create a new UDP socket for communication with the remote server in each callback processing the local incoming request. When creating a socket, it is allocated a free unique port from which the socket will send a request to the remote DNS server, and will receive a response to the same port. The response will be sent to the client who requested it via a local connection, the properties of which are saved in the same callback. Thus, the responses of the remote server for different local requests are not confused, because they will be received by different UDP sockets on different ports and transmitted to the addressees in different calls. Well, having received the answer, do not forget to close the socket.


But when working on TLS, the answers of the upstream server for different local clients will be sent over the same connection. You will need to store the connection parameters for each local request (IP and port), as well as to determine which of the local clients each response is intended for.


For each local request, we will save its IP and port in the collection of key-value pairs. As a key, for simplicity and clarity, we will use the string obtained by concatenating the above fields of the DNS message. When you receive an answer, you will need to read it in order to get the IP and the port through which the answer will be forwarded through the same fields. Pay attention to these lines in Listing 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) => {});

Listing 8. An explanation of the choice of local connection in the code in Listing 7


Sending a request to a remote server via a TLS connection:


// данные локального подключения, по которому получен запрос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 байта длины и последовательность байт запроса должны быть отправлены за один вызов метода записи сокета

Listing 9. Sending a request to a remote DNS server via a TLS connection (see also Listing 4)


Reading configuration from file and updating it


And finally, for elementary convenience, we will take out the program settings to the configuration file. We choose JSON format for it, it is convenient to work with it, because NodeJS can connect JSON-files as modules and parse them transparently. Minus JSON - the configuration file will not be able to keep comments, and oh, how they are needed. Alternatively, you can create a comment field in JSON (or any similar field) and place comment text in its value. Although, of course, it is a crutch, but still better than nothing. Also, until we make the configuration syntax correctness check, we will have to keep this in mind. Configuration reading is implemented through a plug-in that returns a singleton instance of the configuration object, which is the same for the entire application, and also monitors the configuration file for changes using standard NodeJS tools. If the file has been modified, it is read again, and the configuration is updated on the fly. That is, when making changes to the configuration, it is not necessary to pre-launch the program, it is enough just to fix the config in a text editor; as for me, it is very convenient. Although with the growth and complexity of the structure of the config, the probability of making a mistake will increase, and this will have to solve something.


Listing 10. Configuration read and update module
const path = require('path');
const fs = require('fs');
const CONFIG_FILE_PATH = path.resolve('./config.json');
functionModule () {
    // config является объектом-константой, поэтому может быть безопасно назначен другой переменной.// Но внутренние свойства config переопределяются при изменении и последующем чтении конфигурационного файла,// поэтому обращаться к ним можно как к свойствам объекта. Например, вы можете сделать так://      const conf = config;// и свойства conf будут обновлены при обновлении конфигурации, но избегайте делать так://      const requestsToForge = config.requestsToForge;// поскольку при обновлении конфигурации, requestsToForge не будет обновлён.const config = {};
    Object.defineProperty(this, 'config', {
        get() {
            return config;
        },
        enumerable: true
    })
    this.initConfig = asyncfunction() {
        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);
    };
    asyncfunctionreadConfigFile(configPath) {
        const promise = newPromise((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;
    }
    functionparseConfig(fileContents) {
        const configData = JSON.parse(fileContents);
        return configData;
    }
    // Обновляем когфигурацию программы, если конфигурационный файл был отредактирован и сохранён.// На Windows, при изменении файла fs.watch вызывается дважды с небольшим интервалом,// поэтому чтобы предотвратить конфликт при чтении, используем флаг configReadInProgresslet configReadInProgress = false;
    fs.watch(CONFIG_FILE_PATH, async () => {
        if(!configReadInProgress) {
            configReadInProgress = true;
            console.log('===== config changed, run initConfig() =====');
            try {
                awaitthis.initConfig();
            } catch (err) {
                console.log('===== error initConfig(), skip =====,', err);
                configReadInProgress = false;
            }
            configReadInProgress = false;
        }
        else {
            console.log('===== config changed, initConfig() already running, skip =====');
        }
    });
}
let instance;
asyncfunctiongetInstance() {
    if(!instance) {
        instance = new Module();
        await instance.initConfig();
    }
    return instance;
}
module.exports = getInstance;

Listing 10. Configuration read and update module


Total


We wrote a small DNS proxy on NodeJS, without using npm and the side of the library. Although its capabilities are limited, it copes well with the task of serving local customers, and, if desired, can log incoming requests and answers for further study.


Full GitHub Code


Sources:



Also popular now: