DNS прокси на Node.JS своими руками

Понесло пакет по кочкам в дальний лес за DNS…
Л. Каганов "Гамлет на дне"

При разработке сетевого приложения иногда возникает необходимость запустить его локально, но обращаться к нему по реальному доменному имени. Стандартное проверенное решение — прописать домен в файле hosts. Минус подхода в том, что hosts требует чёткого соответствия доменных имён, т.е. не поддерживает звёздочки. Т.е. если есть домены вида:


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

то в hosts нужно прописать их все. В отдельных случаях, домен третьего уровня не известен заранее. Возникает желание (пишу за себя, кто-то, возможно, скажет, что и так нормально) обойтись строкой вида:


*.example.com

Решением проблемы может стать установка локального DNS-сервера, который будет обрабатывать запросы в соответствии с заданной логикой. Такие сервера есть, и вполне бесплатные, и с удобным графическим интерфейсом. Можно поставить и не заморачиваться. Но в этой статье описан другой путь — написание собственного велосипеда DNS-прокси, который будет слушать входящие DNS-запросы, и если запрашиваемое доменное имя есть в списке, вернёт заданный IP, а если нет — запросит вышестоящий DNS-сервер, и переправит полученный ответ без изменений запрашивающей программе.


Заодно можно логировать запросы и поступающие на них ответы. Поскольку DNS нужен всем — браузерам, и мессенджерам, и антивирусам, и службам операционной системы, и т.д., то бывает весьма познавательно.


Принцип простой. В настройках сетевого подключения для протокола IPv4 меняем адрес DNS-сервера на адрес машины с нашим запущеным самописным DNS-прокси (127.0.0.1, если работаем не по сети), и в его настройках указываем адрес вышестоящего DNS-сервера. И, вроде бы, всё!


Стандартные функции разрешения доменных имён nslookup и nsresolve использовать не будем, поэтому системные настройки DNS и содержимое файла hosts никак не будут влиять на работу программы. В зависимости от ситуации, это может быть полезно или нет, нужно просто помнить об этом. Для простоты, ограничимся реализацией самого базового функционала:


  • подмена IP только для записей типа A (адрес хоста) и класса IN (интернет)
  • подменяемые IP адреса только версии 4
  • подключение для локальных входящих запросов только по UDP
  • подключение в вышестоящему DNS-серверу по UDP или TLS
  • при наличии нескольких сетевых интерфейсов, входящие локальные запросы будут приниматься на любом из них
  • отсутвует поддержка EDNS

Кстати, о тестах

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


Начало — сервер на 53-м порту


Приступим. Первым делом нужно научить приложение принимать входящие DNS-запросы. Пишем простейший TCP-сервер, который просто слушает 53-й порт и логирует входящие подключения. В свойствах сетевого подключения прописываем адрес DNS-сервера 127.0.0.1, запускаем приложение, заходим браузером на несколько страниц — и… в консоли тишина, браузер отображает страницы нормально. Хорошо, меняем TCP на UDP, запускаем, ходим браузером — в браузере ошибка соединения, в консоли посыпались какие-то бинарные данные. Значит, система шлёт запросы по UDP, и мы будем слушать входящие соединения по UDP на 53-м порту. Полчаса трудов, из них 15 минут гугление как поднять TCP- и UDP- сервер на NodeJS — и у нас решена краеугольная задача проекта, определяющая устройство будущего приложения. Код получается таким:


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
}());

Листинг 1. Минимальный код, нужный для приёма локальных DNS-запросов


Следующий пункт — прочитать поступившее сообщение, чтобы понять, нужно ли в ответ на него возвращать наш IP, или просто передать его дальше.


DNS-сообщение


Структура DNS-сообщения описана в RFC-1035. И запросы, и ответы следуют этой структуре, и в принципе отличаются одним битовым флагом (поле QR) в заголовке сообщения. Сообщение включает в себя пять секций:


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

Общая структура сообщения DNS (с) https://tools.ietf.org/html/rfc1035#section-4.1


DNS-сообщение начинается с заголовка фиксированной длины (это т.н. секция Header), который содержит поля длиной от 1 бита до двух байт (таким образом, один байт в заголовке может содержать несколько полей). Заголовок начинается с поля ID — это 16-битный идентификатор запроса, ответ должен иметь тот же ID. Далее следуют поля, описывающие тип запроса, результат его выполнения и количество записей в каждой из последующих секций сообщения. Описывать их все долго, поэтому кому интересно — велкам в RFC: https://tools.ietf.org/html/rfc1035#section-4.1.1. Секция Header присутствует в DNS-сообщении всегда.


                                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 (с) https://tools.ietf.org/html/rfc1035#section-4.1.1


Секция Question


Секция Question содержит запись, сообщающую серверу, какая именно информация от него нужна. Теоретически, в секции таких записей может быть одна или несколько, их количество указывавется в поле QDCOUNT в заголовке сообщения, и может быть 0, 1 или больше. Но на практике, в секции Question может содержаться только одна запись. Если бы секция Question содержала несколько записей, и одна из них привела бы к ошибке при обработке запроса на сервере, то возникла бы неопределённая ситуация. Сервер хотя и вернёт код ошибки в поле RCODE в ответном сообщении, но не сможет указать, при обработке какой именно записи возникла проблема, спецификация этого не описывает. У записей так же нет полей, содержащих указание на ошибку и её тип. Поэтому существует соглашение (недокументированное), по которому секция Question может содержать только одну запись, и поле QDCOUNT имеет значение 1. Так же не совсем ясно, как обрабатывать запрос на стороне сервера, если он всё-таки содержит несколько записей в Question. Кто-то советует возвращать сообщение с ошибкой запроса. А, например, Google DNS обрабатывает только первую запись в секции Question, остальные просто игнорирует. Видимо, это остаётся на усмотрение разработчиков DNS-сервисов.


В ответном DNS-сообщении от сервера секция Question тоже присутствует и должна полностью копировать Question запроса (во избежание коллизий, на случай если одного поля ID окажется недостаточно).


Единственная запись в секции Question содержит поля: QNAME (доменное имя), QTYPE (тип), QCLASS (класс). QTYPE и QCLASS — двухбайтовые числа, обозначающие тип и класс запроса. Возможные типы и классы описаны в RFC-1035 https://tools.ietf.org/html/rfc1035#section-3.2, там всё понятно. А вот на способе записи доменного имени остановимся более подробно в разделе "Формат записи доменных имён".


В случае запроса, DNS-сообщение чаще всего заканчивается секцией Question, иногда за ней может следовать секция Additional.


Если при обработке запроса на сервере произошла ошибка (например, неправильно был сформирован входящий запрос), то ответное сообщение тоже завершится секцией Question или Additional, и поле RCODE заголовка ответного сообщения будет содержать код ошибки.


Секции Answer, Authority и Additional


Следующие секции — Answer, Authority и Additional (Answer и Authority содержатся только в ответном DNS-сообщении, Additional может встречаться в запросе и в ответе). Они опциональны, т.е. любая из них может присутствовать или нет, в зависимости от поступившего запроса. Эти секции имеют одинаковую структуру и содержат информацию в формате так называемых "ресурсных записей" (resourse record, или RR). Образно говоря, каждая из этих секций — это массив ресурсных записей, а запись — это объект с полями. Каждая секция может содержать одну или несколько записей, их количество указывается в соответствующем ей поле в заголовке сообщения (ANCOUNT, NSCOUNT, ARCOUNT соответственно). Например, запрос IP для домена "google.com" вернёт несколько IP-адресов, поэтому записей в секции Answer тоже будет несколько, по одной для каждого адреса. Если секция отсутствует, то соответствующее ей поле заголовка содержит 0.


Каждая ресурсная запись (RR) начинается с поля NAME, содержащего доменное имя. Формат этого поля повторяет формат поля QNAME секции Question.
Следом за NAME идут поля TYPE (тип записи), и CLASS (её класс), оба поля 16-битные числовые, обозначают тип и класс записи. Это тоже напоминает секцию Question, с той разницей, что её QTYPE и QCLASS могут иметь все те же значения, что и TYPE и CLASS, и ещё некоторые собственные, присущие только им. Т.е., выражаясь сухим научным языком, множество значений QTYPE и QCLASS — это надмножество значений TYPE и CLASS. Подробнее об отличиях в https://tools.ietf.org/html/rfc1035#section-3.2.2.
Оставшиеся поля:


  • TTL — 32-битное число, обозначающее время актуальности записи (в секундах).
  • RDLENGTH — 16-битное число, обозначающее длину следующего за ним поля RDATA в байтах.
  • RDATA — собственно полезная нагрузка, формат зависит от типа записи. Например, для записи типа A (host address) и класса IN (Internet) это 4 байта, представляющие IPv4 адрес.

Формат записи доменных имён


Формат записи доменных имён одинаков для полей QNAME и NAME, а так же для поля RDATA, если это запись класса CNAME, MX, NS или другая, предполагающая доменное имя в качестве результата.


Доменное имя представляет собой последовательность меток (секций имени, поддоменов — в оригинале это label, лучшего перевода я не нашёл). Метка представляет собой один байт длины, содержащий число — длину содержимого метки в байтах, и следующую за ним последовательность байт указанной длины. Метки следуют одна за другой, пока не встретится байт длины, содержащий 0. Самая первая метка может сразу иметь нулевую длину, это обозначает корневой домен (Root Domain) с пустым доменным именем (иногда записывается как "").


В ранних версиях DNS байты в метке могли иметь любое значение от (от 0 до 255). Существовали правила, носившие характер настоятельной рекомендации: чтобы метка начиналась с буквы, заканчивалась на букву или цифру, и содержала только буквы, цифры или дефис в 7-битной кодировке ASCII, с нулевым старшим битом. Современная спецификация EDNS уже требует соблюдать эти правила чётко, без отклонений.


Два старших бита байта длины используются как признак типа метки. Если они нулевые (0b00xxxxxx), то это обычная метка, и оставшиеся биты байта длины обозначают количество байт данных, входящих в её состав. Максимальная длина метки — 63 символа. 63 в двоичной кодировке как раз 0b00111111.


Если два старших бита равены соответственно 0 и 1 (0b01xxxxxx), то это метка расширенного типа стандарта EDNS (https://tools.ietf.org/html/rfc2671#section-3.1), который пришёл к нам с 1 февраля 2019 года. Младшие шесть бит будут содержать значение метки. В этой статье EDNS мы не рассматриваем, но полезно знать, что так тоже бывает.


Комбинация двух старших бит, равных 1 и 0 (0b10xxxxxx), зарезервирована для будущего использования.


Если же оба старших бита равны 1 (0b11xxxxxx), это означает, что используется сжатие (compression) доменных имён, и вот на этом мы остановимся подробнее.


Сжатие доменных имён


Итак, если у байта длины два старших бита равны 1 (0b11xxxxxx), это признак сжатия доменного имени. Сжатие применяется, чтобы сделать сообщения более короткими и ёмкими. Это особенно актуально при работе по UDP, когда общая длина DNS-сообщения ограничена 512 байтами (хотя, это старый стандарт, см. https://tools.ietf.org/html/rfc1035#section-2.3.4 Size limits, новый EDNS позволяет пересылать по протоколу UPD сообщения и большей длины). Суть процесса такова, что если в DNS-сообщении есть доменные имена с одинаковыми поддоменами верхнего уровня (например, mail.yandex.ru и yandex.ru), то вместо повторного указания доменного имени целиком, указывается номер байта в DNS-сообщении, с которого следует продолжать чтение доменного имени. Это может быть любой байт DNS-сообщения, а не только находящийся в текущей записи или секции, но с условием, что это байт длины доменной метки. Сослаться на середину метки нельзя. Допустим, в сообщении есть домен mail.yandex.ru, с тогда помощью сжатия возможно так же обозначить домены yandex.ru, ru и корневой "" (конечно, корневой проще записать без сжатия, но и со сжатием сделать это технически возможно), а вот сделать ndex.ru уже не выйдет. Так же, заканчиваться все производные доменные имена будут корневым доменом, то есть записать, скажем, mail.yandex тоже не удастся.


Доменное имя может:


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

Например, мы составляем DNS-сообщение, и у нас в нём ранее уже встречалось имя "dom3.example.com", теперь же нужно указать "dom4.dom3.example.com". В этом случае, можно записать секцию "dom4" без сжатия, а далее перейти на сжатие, то есть добавить ссылку на "dom3.example.com". Или наоборот, если ранее встречалось имя "dom4.dom3.example.com", то для указания "dom3.example.com" можно сразу задействовать сжатие, сославшись на метку "dom3" в нём. Чего НЕ можем сделать — как уже было сказано, указать через компрессию часть 'dom4.dom3', потому что имя должно заканчиваться секцией верхнего уровня. Если вдруг надо указать именно сегменты из середины — то они просто указываются без сжатия.


Для простоты, наша программа не умеет записывать доменные имена со сжатием, умеет только читать. Стандарт это допускает, чтение должно быть реализовано обязательно, запись — опционально. Технически, чтение реализовано так: если два старших бита байта длины содержат 1, то читаем следующий за ним байт, и трактуем два эти байта как 16-разрядное беззнаковое целое число, с порядком бит Big Endian. Два старших бита (содержащие 1) отбрасываем, читаем получившееся 14-разрядное число, и дальнейшее чтение доменного имени продолжаем с байта в DNS-сообщении под номером, соответствующим этому числу.


Код функции чтения доменного имени получился таким:


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);     // Убираем первый символ — точку "."
}

Листинг 2. Чтение доменных имён из DNS-запроса


Полный код функции чтения DNS-записи из двоичного буфера:


Листинг 3. Чтение DNS-записи из двоичного буфера
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;
}

Листинг 3. Чтение DNS-записи из двоичного буфера


Наконец, запрос от локального клиента получен и разобран. Проверяем, нужно ли возвращать фиктивный ответ, и если да, то формируем и возвращаем. Если же нет, то посылаем запрос удалённому 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


Источники:


  • +22
  • 3,9k
  • 4
Поделиться публикацией

Комментарии 4

    0

    Идея хорошая; но в принципе можно было быстро погуглить, найти coredns, запустить его и больше ничего не писать :-)

      +1
      Ну так велосипед же. За ссылку спасибо, любопытно, посмотрю на досуге внимательнее.
      0
      А на роутере не проще добавлять и менять записи (если роутер конечно поддерживает)?
      В таком случае не надо клиентские резолверы вообще трогать, и сразу на всю сетку работает — при веб разработке можно сразу и на реальных смартфоне и планшете например посмотреть.
        0

        Да, такой вариант тоже работает.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое