DNS (англ. Domain Name System — система доменных имён) — компьютерная распределённая система для получения информации о доменах.
TLS (англ. transport layer security — Протокол защиты транспортного уровня) — обеспечивает защищённую передачу данных между Интернет узлами.
После новости "Google Public DNS тихо включили поддержку DNS over TLS" я решил попробовать его. У меня есть Stunnel который создаст шифрованный TCP туннель. Но программы обычно общаются с DNS по UDP протоколу. Поэтому нам нужен прокси который будет пересылать UDP пакеты в TCP поток и обратно. Мы напишем его на Lua.
Вся разница между TCP и UDP DNS пакетами:
4.2.2. TCP usage
Messages sent over TCP connections use server port 53 (decimal). The message is prefixed with a two byte length field which gives the message length, excluding the two byte length field. This length field allows the low-level processing to assemble a complete message before beginning to parse it.
RFC1035: DOMAIN NAMES — IMPLEMENTATION AND SPECIFICATION
То есть делаем туда:
- берём пакет из UDP
- добавляем к нему в начале пару байт в которых указан размер этого пакета
- отправляем в TCP канал
И в обратную сторону:
- читаем из TCP пару байт тем самым получаем размер пакета
- читаем пакет из TCP
- отправляем его получателю по UDP
Настраиваем Stunnel
- Скачиваем корневой сертификат Root-R2.crt в директорию с конфигом Stunnel
- Конвертируем сертификат в PEM
openssl x509 -inform DER -in Root-R2.crt -out Root-R2.pem -text
Пишем в stunnel.conf:
[dns] client = yes accept = 127.0.0.1:53 connect = 8.8.8.8:853 CAfile = Root-R2.pem verifyChain = yes checkIP = 8.8.8.8
То есть Stunnel:
- примет не шифрованное TCP по адресу 127.0.0.1:53
- откроет шифрованный TLS туннель до адреса 8.8.8.8:853 (Google DNS)
- будет передавать данные туда и обратно
Запускаем Stunnel
Работу тунеля можно проверить командой:
nslookup -vc ya.ru 127.0.0.1
Опция '-vc' заставляет nslookup использовать TCP соединение к DNS серверу вместо UDP.
Результат:
*** Can't find server name for address 127.0.0.1: Non-existent domain
Server: UnKnown
Address: 127.0.0.1
Non-authoritative answer:
Name: ya.ru
Address: (здесь IP яндекса)
Пишем скрипт
Я пишу на Lua 5.3. В нём уже доступны бинарные операции с числами. Ну и нам понадобится модуль Lua Socket.
Имя файла: simple-udp-to-tcp-dns-proxy.lua
local socket = require "socket" -- подключаем lua socket
--[[--
Напишем простенькую функцию которая позволит отправить дамп пакета в консоль. Хочется видеть что делает прокси.
--]]--
function serialize(data)
-- Преобразуем символы не входящие в диапазоны a-z и 0-9 и тире в HEX представление '\xFF'
return "'"..data:gsub("[^a-z0-9-]", function(chr) return ("\\x%02X"):format(chr:byte()) end).."'"
end
--[[--
UDP в TCP и обратно
Пишем две функции которые будут оперировать двумя каналами передачи данных.
--]]--
-- здесь пакеты из UDP пересылаются в TCP поток
function udp_to_tcp_coroutine_function(udp_in, tcp_out, clients)
repeat
coroutine.yield() -- возвращаем управление главному циклу
packet, err_ip, port = udp_in:receivefrom() -- принимаем UDP пакет
if packet then
-- > - big endian
-- I - unsigned integer
-- 2 - 2 bytes size
tcp_out:send(((">I2"):pack(#packet))..packet) -- добавляем размер пакета и отправляем в TCP
local id = (">I2"):unpack(packet:sub(1,2)) -- читаем ID пакета
if not clients[id] then
clients[id] = {}
end
table.insert(clients[id] ,{ip=err_ip, port=port, packet=packet}) -- записываем адрес отправителя
print(os.date("%c", os.time()) ,err_ip, port, ">", serialize(packet)) -- отображаем пакет в консоль
end
until false
end
-- здесь пакеты из TCP потока пересылаются к адресату по UDP
function tcp_to_udp_coroutine_function(tcp_in, udp_out, clients)
repeat
coroutine.yield() -- возврашяем управление главному циклу
-- > - big endian
-- I - unsigned integer
-- 2 - 2 bytes size
local packet = tcp_in:receive((">I2"):unpack(tcp_in:receive(2)), nil) -- принимаем TCP пакет
local id = (">I2"):unpack(packet:sub(1,2)) -- читаем ID пакета
if clients[id] then
for key, client in pairs(clients[id]) do
-- сравниваем query в запросе и ответе
if packet:find(client.packet:sub(13, -1), 13, true) == 13 then -- находим получателя
udp_out:sendto(packet, client.ip, client.port) -- отправляем пакет получателю по UDP
clients[id][key] = nil -- очищаем ячейку
-- отображаем пакет в консоль
print(os.date("%c", os.time()) ,client.ip, client.port, "<", serialize(packet))
break
end
end
if not next(clients[id]) then
clients[id] = nil
end
end
until false
end
--[[--
Обе функции сразу после запуска выполняют coroutine.yield(). Это позволяет первым вызовом передать параметры функции а дальше делать coroutine.resume(co) без дополнительных параметров.
main
А теперь main функция которая выполнит подготовку и запустит главный цикл.
--]]--
function main()
local tcp_dns_socket = socket.tcp() -- подготавливаем TCP сокет
local udp_dns_socket = socket.udp() -- подготавливаем UDP сокет
local tcp_connected, err = tcp_dns_socket:connect("127.0.0.1", 53) -- соединяемся с TCP тунелем
assert(tcp_connected, err) -- проверяем что соединились
print("tcp dns connected") -- сообщаем что соединились в консоль
local udp_open, err = udp_dns_socket:setsockname("127.0.0.1", 53) -- открываем UDP порт
assert(udp_open, err) -- проверяем что открыли
print("udp dns port open") -- сообщаем что UDP порт открыт
-- пользуемся тем что таблицы Lua позволяют использовать как ключ что угодно кроме nil
-- используем как ключ сокет чтобы при наличии данных на нём вызывать его сопрограмму
local coroutines = {
[tcp_dns_socket] = coroutine.create(tcp_to_udp_coroutine_function), -- создаём сопрограмму TCP to UDP
[udp_dns_socket] = coroutine.create(udp_to_tcp_coroutine_function) -- создаём сопрограмму UDP to TCP
}
local clients = {} -- здесь будут записываться получатели пакетов
-- передаём каждой сопрограмме сокеты и таблицу получателей
coroutine.resume(coroutines[tcp_dns_socket], tcp_dns_socket, udp_dns_socket, clients)
coroutine.resume(coroutines[udp_dns_socket], udp_dns_socket, tcp_dns_socket, clients)
-- таблица из которой socket.select будет выбирать сокет готовый к получению данных
local socket_list = {tcp_dns_socket, udp_dns_socket}
repeat -- запускаем главный цикл
-- socket.select выбирает из socket_list сокеты у которых есть данные на получение в буфере
-- и возвращает новую таблицу с ними. Цикл for последовательно возвращает значения из новой таблицы
for _, in_socket in ipairs(socket.select(socket_list)) do
-- запускаем ассоциированную с полученным сокетом сопрограмму
local ok, err = coroutine.resume(coroutines[in_socket])
if not ok then
-- если сопрограмма завершилась с ошибкой то
udp_dns_socket:close() -- закрываем UDP порт
tcp_dns_socket:close() -- закрываем TCP соединение
print(err) -- выводим ошибку
return -- завершаем главный цикл
end
end
until false
end
--[[--
Запускаем главную функцию. Если вдруг будет закрыто соединение мы через секунду установим его заново вызвав main.
--]]--
repeat
coroutine.resume(coroutine.create(main)) -- запускаем main
socket.sleep(1) -- перед рестартом ждём одну секунду
until false
проверяем
Запускаем stunnel
Запускаем наш скрипт
lua5.3 simple-udp-to-tcp-dns-proxy.lua
Проверяем работу скрипта командой
nslookup ya.ru 127.0.0.1
На этот раз без '-vc' так так мы уже написали и запустили прокси который заворачивает UDP DNS запросы в TCP тунель.
Результат:
*** Can't find server name for address 127.0.0.1: Non-existent domain
Server: UnKnown
Address: 127.0.0.1
Non-authoritative answer:
Name: ya.ru
Address: (здесь IP яндекса)
Если всё нормально можно указать в настройках соедидения как DNS сервер "127.0.0.1"
заключение
Теперь наши DNS запросы под защитой TLS.
ссылки
Комментарии (19)
YourChief
27.10.2018 23:06+3Теперь наши DNS запросы под зашитой TLS.
Нет, не под защитой. В предложенном конфиге stunnel нет проверки сертификата.
По коду — ну такое. Нужно запускать два демона. Почему бы не обойтись тогда просто одним, который сам TLS-коннект в апстрим-резолвер открывает? Как оно будет работать в случаях, когда DNS-ответ не умещается в одну UDP-датаграмму?
И для чего тратить на это время на эту крайне спорную реализацию, если есть unbound и knot, которые поддерживают DoT нативно?
Помимо этого, есть куча готовых стабов для DoH/DoT:
(DoH) doh-proxy от Facebook
(DoT) stubby от GetDNS
(DoH) cloudflared от Cloudflareivan386 Автор
28.10.2018 01:20Нет, не под защитой. В предложенном конфиге stunnel нет проверки сертификата.
Поправил. Теперь проверяет сертификат.
Почему бы не обойтись тогда просто одним, который сам TLS-коннект в апстрим-резолвер открывает?
TLS на Lua я не умею делать. А stunnel полагаю DNS не знает. Мне было интересно решить задачу тем что есть.
Как оно будет работать в случаях, когда DNS-ответ не умещается в одну UDP-датаграмму?
Полагаю её просто отбросит. Надо тестировать. Но там не большая разница между максимальным размером TCP DNS пакета (65535 байт) и максимальной UDP-датаграммой (65507 байт). Программа также может обратится и по TCP каналу напрямую в туннель если рассчитывает получить большой ответ.
И для чего тратить на это время на эту крайне спорную реализацию, если есть unbound и knot, которые поддерживают DoT нативно?
Не знаю. Каждый сам решит для себя.
YourChief
28.10.2018 01:50Но там не большая разница между максимальным размером TCP DNS пакета (65535 байт) и максимальной UDP-датаграммой (65507 байт).
Есть лимит 512 байт на размер DNS-ответа. Обсуждение на SO о причинах.
Там промежуточный серт получается по сути захардкожен — это не круто и он протухнет через 3 года. Лучше использовать системный набор сертификатов.
[dns]
client = yes
accept = 127.0.0.1:53
connect = 8.8.8.8:853
CApath = /usr/lib/ssl/certs
verifyChain = yes
checkIP = 8.8.8.8
verify — устаревшая опция, ей лучше не пользоваться.
Далее, как я вижу, для маршрутизации ответов используется только ID запроса, который каждый клиент выбирает случайно. Нормальные резолверы так не делают, так как велика вероятность коллизии. Они ещё помнят с какого адреса-порта что запрашивалось, а ID ответа в основном проверяют для защиты от DNS-отравлений. По моим подсчётам по формуле для парадокса дней рождения, вероятность совпадения ID хотя бы у двух запросов в группе из 100 запросов составляет 7,3%. Довольно скверно, согласитесь.
Не знаю. Каждый сам решит для себя.
Ну, пока не выглядит даже как добрый совет.ivan386 Автор
28.10.2018 13:34Есть лимит 512 байт на размер DNS-ответа.
У меня UDP пакеты не уходят дальше локальной машины. Проверил лимиты. Нормально уходят и приходят пакеты до 8192 байт. Дальше только отправка работает а на приёме ошибка что слишком большой пакет.
Предлагаете ограничить?
Потом если предполагается большой ответ обычно используется TCP а он также доступен напрямую через Stunnel.
Лучше использовать системный набор сертификатов.
Только вот они похоже реестре лежат на Windows.
Далее, как я вижу, для маршрутизации ответов используется только ID запроса
Да. Я не придумал как не разбирая пакета без сложностей использовать больше иформации. Можно конечно попробовать написать лёгкую фунцию парсинга запросов.
Но опять же мы на локальной машине. Запросов от неё не так много.
YourChief
28.10.2018 15:10Предлагаете ограничить?
Протокол DNS предусматривает передачу длинных ответов через TCP, вероятнее всего нужно ещё реализовать TCP-слушатель для них.
Да. Я не придумал как не разбирая пакета без сложностей использовать больше иформации. Можно конечно попробовать написать лёгкую фунцию парсинга запросов.
Качественная реализация какого-либо DNS-сервера требует много работы, которая уже была сделана.ivan386 Автор
28.10.2018 15:14-1вероятнее всего нужно ещё реализовать TCP-слушатель
Он есть. Это Stunnel.
ivan386 Автор
28.10.2018 16:07Я скриптом как раз решал проблему того что Stunnel UDP не понимает. А в начале статьи я как раз отправляю тестовый TCP запрос через Stunnel напрямую.
nslookup -vc ya.ru 127.0.0.1
Опция vc заставляет nslookup использовать TCP соединение к DNS серверу вместо UDP.
ivan386 Автор
28.10.2018 17:36для маршрутизации ответов используется только ID запроса
Поправил. Теперь не только ID сравниваются но и query часть.
Homas
29.10.2018 06:32Есть лимит 512 байт на размер DNS-ответа.
Почитайте на досуге про EDNS0. И это всего лишь лимит для UDP…
Daddy_Cool
28.10.2018 00:37Можно для чайников — тех кто с трудом отличает Domain Name System от Direct Numerical Simulation — что это дает обычным людям?
Хотя бы в виде абзаца-абстракта. А то уж очень специфично/терминологично.ivan386 Автор
28.10.2018 01:40DNS (англ. Domain Name System — система доменных имён) — компьютерная распределённая система для получения информации о доменах. Чаще всего используется для получения IP-адреса по имени хоста (компьютера или устройства)
Сейчас все DNS запросы идут открытым текстом и провайдеры могут их читать и подменять в целях блокирования доступа или рекламы.
Скрипт в сочетании со Stunnel отправляет все приходящие на него DNS запросы через шифрованный канал напрямую к Google DNS. Тем самым провайдер не может прочитать или подменить ответы DNS сервера.
При сочетании DNS over TLS с Encrypted SNI провайдер перестанет понимать на какие сайты ходит пользователь. Всё что ему будет видно это только IP адреса на которые обращается пользователь.
Daddy_Cool
28.10.2018 01:51Спасибо! Ага, т.е. вообще такое надо ставить — а то вдруг мало ли что… Зайду куда-нибудь, где ругают не того кого можно и станет одним хабраюзером меньше.
krylov_sn
29.10.2018 13:13Пару дней назад Mozilla объявила о поддержке Encrypted SNI. blog.mozilla.org/security/2018/10/18/encrypted-sni-comes-to-firefox-nightly
По их заявлению включение этой опции одновременно с DNS over HTTPS приведет к большей анонимностипровайдер перестанет понимать на какие сайты ходит пользователь
. Кто-то сразу заявил, что вот оно средство против блокировок. Но у моего провайдера так и не заработало. Выяснилось, что Encrypted SNI он понимает как отсутствие SNI и режет такие пакеты. Вот обсуждение: github.com/ValdikSS/GoodbyeDPI/issues/71
Не знаю как насчет DNS over TLS в сочетании с Encrypted SNI
tchspprt
28.10.2018 02:16+1DNS отвечает, например, за доступность веб-контента по URL. (Не)добросовестные провайдеры выполняют требования, например, РКН, и что-то блокируют. Для того, чтобы к этому заблокированному получить доступ, можно поднять, например, VPN. Но если VPN реализован туннелированием через абстракционно не низший чем DNS протокол, то DNS автоматически под туннелем не ходит (инкапсуляция почти как из ООП, возможно, с этим Вы больше знакомы, пример подменяемого DNS — VPN через SSH), и получается, что трафик хоть и идёт через другую точку света, но трансляции из доменного имени в IP всё равно от провайдера (тут есть пример, но в реальности подмена DNS — не самое зло, подробности тут, хотя и с большим злом можно справиться). А (не)добросовестные клиенты не желают, чтобы требования РКН выполнялись. Кто тут добросовестнее — выбирать Вам.
pansa
28.10.2018 02:23+3С точки зрения изучения интересно. Но если нужно ехать, а не шашечки, то просто ставим Unbound и радуемся =)
xlenz
28.10.2018 04:06+1Спасибо за статью, но как тут не раз упомянули уже, лучше поставить готовое простое решение.
Лично мне больше всего понравился stubby
https://github.com/getdnsapi/stubby
Работает на маке, винде и линухе. Вроде даже на иОС. На дроиде уже не нужно, тк с девятки эта фича доступна на системном уровне.
Serge78rus
Осталось добавить кэширование DNS записей.
ivan386 Автор
Чтобы вычитать из пакета TTL записи надо будет разбирать пакеты а это уже не так просто. А топорный кэш который будет хранить все записи определённое время добавить легко. Я просто отрезал от запроса и ответа ID и помещал в кэш. И если приходил идентичный запрос я возвращал ответ из кэша.
Это основа а дальше с ней можно играться. Можно фильтры например сделать. Или к двум DNS слать запросы.
nsmcan
А вот это — не надо. С кэшированием это уже был бы недо-DNS сервер.
Кэшированием DNS записей занимается OS