источник изображения


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


То есть делаем туда:


  1. берём пакет из UDP
  2. добавляем к нему в начале пару байт в которых указан размер этого пакета
  3. отправляем в TCP канал

И в обратную сторону:


  1. читаем из TCP пару байт тем самым получаем размер пакета
  2. читаем пакет из TCP
  3. отправляем его получателю по UDP

Настраиваем Stunnel


  1. Скачиваем корневой сертификат Root-R2.crt в директорию с конфигом Stunnel
  2. Конвертируем сертификат в PEM
    openssl x509 -inform DER -in Root-R2.crt -out Root-R2.pem -text
  3. Пишем в 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:


  1. примет не шифрованное TCP по адресу 127.0.0.1:53
  2. откроет шифрованный TLS туннель до адреса 8.8.8.8:853 (Google DNS)
  3. будет передавать данные туда и обратно

Запускаем 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

проверяем


  1. Запускаем stunnel


  2. Запускаем наш скрипт


    lua5.3 simple-udp-to-tcp-dns-proxy.lua

  3. Проверяем работу скрипта командой


    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.


ссылки


  1. RFC1035: DOMAIN NAMES — IMPLEMENTATION AND SPECIFICATION
  2. DNS поверх TLS
  3. simple-udp-to-tcp-dns-proxy.lua
  4. Составляем DNS-запрос вручную

Комментарии (19)


  1. Serge78rus
    27.10.2018 20:02

    Осталось добавить кэширование DNS записей.


    1. ivan386 Автор
      27.10.2018 20:18
      +1

      Чтобы вычитать из пакета TTL записи надо будет разбирать пакеты а это уже не так просто. А топорный кэш который будет хранить все записи определённое время добавить легко. Я просто отрезал от запроса и ответа ID и помещал в кэш. И если приходил идентичный запрос я возвращал ответ из кэша.


      Это основа а дальше с ней можно играться. Можно фильтры например сделать. Или к двум DNS слать запросы.


    1. nsmcan
      28.10.2018 18:45

      А вот это — не надо. С кэшированием это уже был бы недо-DNS сервер.
      Кэшированием DNS записей занимается OS


  1. 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 от Cloudflare


    1. ivan386 Автор
      28.10.2018 01:20

      Нет, не под защитой. В предложенном конфиге stunnel нет проверки сертификата.

      Поправил. Теперь проверяет сертификат.


      Почему бы не обойтись тогда просто одним, который сам TLS-коннект в апстрим-резолвер открывает?

      TLS на Lua я не умею делать. А stunnel полагаю DNS не знает. Мне было интересно решить задачу тем что есть.


      Как оно будет работать в случаях, когда DNS-ответ не умещается в одну UDP-датаграмму?

      Полагаю её просто отбросит. Надо тестировать. Но там не большая разница между максимальным размером TCP DNS пакета (65535 байт) и максимальной UDP-датаграммой (65507 байт). Программа также может обратится и по TCP каналу напрямую в туннель если рассчитывает получить большой ответ.


      И для чего тратить на это время на эту крайне спорную реализацию, если есть unbound и knot, которые поддерживают DoT нативно?

      Не знаю. Каждый сам решит для себя.


      1. 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%. Довольно скверно, согласитесь.

        Не знаю. Каждый сам решит для себя.

        Ну, пока не выглядит даже как добрый совет.


        1. ivan386 Автор
          28.10.2018 13:34

          Есть лимит 512 байт на размер DNS-ответа.

          У меня UDP пакеты не уходят дальше локальной машины. Проверил лимиты. Нормально уходят и приходят пакеты до 8192 байт. Дальше только отправка работает а на приёме ошибка что слишком большой пакет.


          Предлагаете ограничить?


          Потом если предполагается большой ответ обычно используется TCP а он также доступен напрямую через Stunnel.


          Лучше использовать системный набор сертификатов.

          Только вот они похоже реестре лежат на Windows.


          Далее, как я вижу, для маршрутизации ответов используется только ID запроса

          Да. Я не придумал как не разбирая пакета без сложностей использовать больше иформации. Можно конечно попробовать написать лёгкую фунцию парсинга запросов.


          Но опять же мы на локальной машине. Запросов от неё не так много.


          1. YourChief
            28.10.2018 15:10

            Предлагаете ограничить?

            Протокол DNS предусматривает передачу длинных ответов через TCP, вероятнее всего нужно ещё реализовать TCP-слушатель для них.
            Да. Я не придумал как не разбирая пакета без сложностей использовать больше иформации. Можно конечно попробовать написать лёгкую фунцию парсинга запросов.
            Качественная реализация какого-либо DNS-сервера требует много работы, которая уже была сделана.


            1. ivan386 Автор
              28.10.2018 15:14
              -1

              вероятнее всего нужно ещё реализовать TCP-слушатель

              Он есть. Это Stunnel.


            1. ivan386 Автор
              28.10.2018 16:07

              Я скриптом как раз решал проблему того что Stunnel UDP не понимает. А в начале статьи я как раз отправляю тестовый TCP запрос через Stunnel напрямую.


              nslookup -vc ya.ru 127.0.0.1


              Опция vc заставляет nslookup использовать TCP соединение к DNS серверу вместо UDP.


        1. ivan386 Автор
          28.10.2018 17:36

          для маршрутизации ответов используется только ID запроса

          Поправил. Теперь не только ID сравниваются но и query часть.


        1. Homas
          29.10.2018 06:32

          Есть лимит 512 байт на размер DNS-ответа.

          Почитайте на досуге про EDNS0. И это всего лишь лимит для UDP…


  1. Daddy_Cool
    28.10.2018 00:37

    Можно для чайников — тех кто с трудом отличает Domain Name System от Direct Numerical Simulation — что это дает обычным людям?
    Хотя бы в виде абзаца-абстракта. А то уж очень специфично/терминологично.


    1. ivan386 Автор
      28.10.2018 01:40

      DNS (англ. Domain Name System — система доменных имён) — компьютерная распределённая система для получения информации о доменах. Чаще всего используется для получения IP-адреса по имени хоста (компьютера или устройства)

      Сейчас все DNS запросы идут открытым текстом и провайдеры могут их читать и подменять в целях блокирования доступа или рекламы.


      Скрипт в сочетании со Stunnel отправляет все приходящие на него DNS запросы через шифрованный канал напрямую к Google DNS. Тем самым провайдер не может прочитать или подменить ответы DNS сервера.


      При сочетании DNS over TLS с Encrypted SNI провайдер перестанет понимать на какие сайты ходит пользователь. Всё что ему будет видно это только IP адреса на которые обращается пользователь.


      1. Daddy_Cool
        28.10.2018 01:51

        Спасибо! Ага, т.е. вообще такое надо ставить — а то вдруг мало ли что… Зайду куда-нибудь, где ругают не того кого можно и станет одним хабраюзером меньше.


      1. 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


    1. tchspprt
      28.10.2018 02:16
      +1

      DNS отвечает, например, за доступность веб-контента по URL. (Не)добросовестные провайдеры выполняют требования, например, РКН, и что-то блокируют. Для того, чтобы к этому заблокированному получить доступ, можно поднять, например, VPN. Но если VPN реализован туннелированием через абстракционно не низший чем DNS протокол, то DNS автоматически под туннелем не ходит (инкапсуляция почти как из ООП, возможно, с этим Вы больше знакомы, пример подменяемого DNS — VPN через SSH), и получается, что трафик хоть и идёт через другую точку света, но трансляции из доменного имени в IP всё равно от провайдера (тут есть пример, но в реальности подмена DNS — не самое зло, подробности тут, хотя и с большим злом можно справиться). А (не)добросовестные клиенты не желают, чтобы требования РКН выполнялись. Кто тут добросовестнее — выбирать Вам.


  1. pansa
    28.10.2018 02:23
    +3

    С точки зрения изучения интересно. Но если нужно ехать, а не шашечки, то просто ставим Unbound и радуемся =)


  1. xlenz
    28.10.2018 04:06
    +1

    Спасибо за статью, но как тут не раз упомянули уже, лучше поставить готовое простое решение.
    Лично мне больше всего понравился stubby
    https://github.com/getdnsapi/stubby
    Работает на маке, винде и линухе. Вроде даже на иОС. На дроиде уже не нужно, тк с девятки эта фича доступна на системном уровне.