Хабрахабр

[Из песочницы] 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-запросы. Приступим. В свойствах сетевого подключения прописываем адрес DNS-сервера 127. Пишем простейший TCP-сервер, который просто слушает 53-й порт и логирует входящие подключения. 0. 0. Хорошо, меняем TCP на UDP, запускаем, ходим браузером — в браузере ошибка соединения, в консоли посыпались какие-то бинарные данные. 1, запускаем приложение, заходим браузером на несколько страниц — и… в консоли тишина, браузер отображает страницы нормально. Полчаса трудов, из них 15 минут гугление как поднять TCP- и UDP- сервер на NodeJS — и у нас решена краеугольная задача проекта, определяющая устройство будущего приложения. Значит, система шлёт запросы по UDP, и мы будем слушать входящие соединения по UDP на 53-м порту. Код получается таким:

const dgram = require('dgram');
const server = dgram.createSocket('udp4'); (function() `); 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
}());

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

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

DNS-сообщение

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

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

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

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

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 |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

1. Структура заголовка сообщения DNS (с) https://tools.ietf.org/html/rfc1035#section-4. 1

Секция Question

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

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

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

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

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

Секции Answer, Authority и Additional

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Полный код функции чтения 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;
}

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

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

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

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

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

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

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

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;

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

Если сервер требует аутентификации с помощью клиентского сертификата, понадобится ещё добавить чтение сертификата из локального файла и передачу его в конструктор соединения socket = tls.connect(connectionOptions, () => {}). Этого достаточно для соединения с публичными DNS-over-TLS сервисами, такими как Google DNS. Это описано в документации 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);

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

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

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

Порядок ответов от удалённого 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 (как уже было сказано, они совпадают у запроса и ответа).

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

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

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

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

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

Отправка запроса удалённому серверу по 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 байта длины и последовательность байт запроса должны быть отправлены за один вызов метода записи сокета

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

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

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

Источники:

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»