From 66e60865babb6afc2d564fe6032aa18fff8cdf4f Mon Sep 17 00:00:00 2001 From: =?utf8?q?Marek=20Vavrus=CC=8Ca?= Date: Wed, 13 Mar 2019 19:26:29 -0700 Subject: [PATCH] modules/http: added handlers for DNS over HTTPS --- modules/http/dns_over_https.lua | 300 ++++++++++++++++++++++++++++++++ modules/http/http.mk | 2 +- 2 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 modules/http/dns_over_https.lua diff --git a/modules/http/dns_over_https.lua b/modules/http/dns_over_https.lua new file mode 100644 index 000000000..9022ad35b --- /dev/null +++ b/modules/http/dns_over_https.lua @@ -0,0 +1,300 @@ +local condition = require('cqueues.condition') +local ffi = require('ffi') +local edns = require('edns') +local utils = require('utils') + +-- Errors +local err_name_invalid = + 'A valid query name must be set.' +local err_type_invalid = + 'RR type can be represented as a number in [1, 65535] or a canonical string (case-insensitive, such as A or aaaa).' +local err_flag_invalid = + 'Flag can be represented as a number in [0, 1] or a boolean [true, false].' +local err_dnssec_bogus = + '"Comment": "DNSSEC validation failure. Please check http://dnsviz.net/d/%s/dnssec/"' + +-- Section name formatting +local section_pretty_name = { + [kres.section.ANSWER] = 'Answer', + [kres.section.AUTHORITY] = 'Authority', + [kres.section.ADDITIONAL] = 'Additional', +} + +-- JSON escape table +local escape_char_map = { + [ "\\" ] = "\\\\", + [ "\"" ] = "\\\"", + [ "\b" ] = "\\b", + [ "\f" ] = "\\f", + [ "\n" ] = "\\n", + [ "\r" ] = "\\r", + [ "\t" ] = "\\t", +} + +local function escape_char(c) + return escape_char_map[c] or string.format("\\u%04x", c:byte()) +end + +local function escape_string(val) + if not val then return '' end + return val:gsub('[%z\1-\31\\"]', escape_char) +end + +-- Serialize a section to a JSON object array +local function section_tostring(pkt, section, min_ttl) + local data = {} + local records = pkt:rrsets(section) + for _, rr in ipairs(records) do + if rr.type ~= kres.type.OPT and rr.type ~= kres.type.TSIG then + for i = 1, rr:rdcount() do + -- Scan for minimum TTL in the packet + if not min_ttl or rr:ttl() < min_ttl then + min_ttl = rr:ttl() + end + -- Escape text values + local rd = escape_string(rr:tostring(i - 1)) + table.insert(data, string.format( + '{"name": "%s", "type": %d, "TTL": %d, "data": "%s"}', + kres.dname2str(rr:owner()), rr.type, rr:ttl(), rd) + ) + end + end + end + return table.concat(data, ','), min_ttl +end + + +-- Serialize packet to a JSON object using the Google's DNS-over-HTTPS schema +-- https://developers.google.com/speed/public-dns/docs/dns-over-https +local function packet_tojson(pkt, bogus) + local data = {} + -- Serialise header + table.insert(data, string.format('"Status": %d,"TC": %s,"RD": %s, "RA": %s, "AD": %s,"CD": %s', + pkt:rcode(), pkt:tc(), pkt:rd(), pkt:ra(), pkt:ad(), pkt:cd())) + -- Optional question + local query_name = '.' + if pkt:qdcount() > 0 then + query_name = kres.dname2str(pkt:qname()) + table.insert(data, string.format('"Question":[{"name": "%s", "type": %d}]', + query_name, pkt:qtype())) + end + -- Record sections + local res, min_ttl + for i = kres.section.ANSWER, tonumber(pkt.current) do + res, min_ttl = section_tostring(pkt, i, min_ttl) + if #res > 0 then + res = string.format('"%s":[%s]', section_pretty_name[i], res) + table.insert(data, res) + end + end + -- DNSSEC validation state + if bogus then + table.insert(data, err_dnssec_bogus:format(query_name:sub(1, -2))) + end + return string.format('{%s}', table.concat(data, ',')), min_ttl +end + + + +-- Map flag values to bit value +local flag_truth_table = { + ['1'] = true, + ['true'] = true, + ['0'] = false, + ['false'] = false, +} + +local function parse_flag(v, dst, name) + if not v then return end + local ret = flag_truth_table[v] + if ret == nil then + return err_flag_invalid + end + if ret then + table.insert(dst, name) + end +end + +-- Serve DNS-over-HTTPS request for application/dns-json +-- https://developers.google.com/speed/public-dns/docs/dns-over-https +local function serve_json(h, _, media_type) + local path = h:get(':path') + + -- Parse query name + local name = path:match('name=([^&]+)') + if not name or #name > 254 or not kres.str2dname(name) then + return 400, err_name_invalid + end + + -- Parse query type, either a numeric value or (or default to A) + local query_type = path:match('type=([^&]+)') + if query_type then + -- The value is either string or numeric + query_type = kres.type[string.upper(query_type)] or + tonumber(query_type) or 0 + + -- Check that the resolved type is valid + if query_type < 1 or query_type > 65535 then + return 400, err_type_invalid + end + else + -- Default + query_type = kres.type.A + end + + -- Parse flags + local flags = {} + + -- Parse DO flag + local err = parse_flag(path:match('do=([^&]+)'), flags, 'DNSSEC_WANT') + if err then + return 400, err + end + -- Parse CD flag + local err = parse_flag(path:match('cd=([^&]+)'), flags, 'DNSSEC_CD') + if err then + return 400, err + end + + -- Track client address from x-forwarded-for + local client_addr = h:get('x-forwarded-for') + if client_addr then + client_addr = ffi.gc(ffi.C.kr_straddr_socket(client_addr, 0), ffi.C.free) + end + + -- Wait for the result of the query + local result, min_ttl + local cond = condition.new() + local waiting, done = false, false + resolve { + name = name, + type = query_type, + init = function (req) + local vars = kres.request_t(req):vars() + -- Track internal DoH queries + vars.request_doh_host = h:get(':authority') + -- Track client address + req.qsource.addr = client_addr + end, + finish = function (answer, req) + local query = req:last() + result, min_ttl = packet_tojson(answer, query and query.flags.DNSSEC_BOGUS) + if waiting then + cond:signal() + end + done = true + end, + options = flags, + } + + -- Wait for asynchronous query and free callbacks + if not done then + waiting = true + cond:wait() + end + + -- Return buffered data + if not done then + return 504, result + end + + return result, nil, media_type, min_ttl +end + +-- Serve DNS-over-HTTPS request for application/dns-message +-- https://tools.ietf.org/html/draft-ietf-doh-dns-over-https-10 +local function serve_wireformat(h, stream, media_type) + -- Only POST is supported currently + local method = h:get(':method') + if method ~= 'POST' then + return 405 + end + + -- Parse packet and read question + local body = stream:get_body_as_string() + local pkt = kres.packet(#body, body) + local ok = pkt:parse() + if not ok then + return 400 + end + + -- Track client address from x-forwarded-for + local client_addr = h:get('x-forwarded-for') + if client_addr then + client_addr = ffi.gc(ffi.C.kr_straddr_socket(client_addr, 0), ffi.C.free) + end + + -- Parse flags + local flags = {} + if edns.has_do(pkt.opt_rr) then + table.insert(flags, 'DNSSEC_WANT') + end + if pkt:cd() then + table.insert(flags, 'DNSSEC_CD') + end + + + -- Wait for the result of the query + local result, min_ttl + local cond = condition.new() + local waiting, done = false, false + resolve { + name = kres.dname2str(pkt:qname()), + type = pkt:qtype(), + init = function (req) + local vars = kres.request_t(req):vars() + -- Track internal DoH queries + vars.request_doh_host = h:get(':authority') + -- Track client address + req.qsource.addr = client_addr + end, + finish = function (answer, _) + --- Keep original message ID + answer:id(pkt:id()) + -- Copy response + result = ffi.string(answer.wire, answer.size) + min_ttl = utils.packet_minttl(answer) + if waiting then + cond:signal() + end + done = true + end, + options = flags, + } + + -- Wait for asynchronous query and free callbacks + if not done then + waiting = true + cond:wait() + end + + -- Return buffered data + if not done then + return 504 + end + + return result, nil, media_type, min_ttl +end + +-- Handlers for different supported media types +local content_type_handlers = { + ['application/dns-udpwireformat'] = serve_wireformat, -- https://tools.ietf.org/html/draft-ietf-doh-dns-over-https-03 + ['application/dns-message'] = serve_wireformat, -- https://tools.ietf.org/html/draft-ietf-doh-dns-over-https-07 + ['application/dns-json'] = serve_json, +} + + +-- Serve content-negotiated DoH +local function serve_doh(h, stream) + -- https://tools.ietf.org/html/draft-ietf-doh-dns-over-https-09#section-5.1 + local media_type = h:get('content-type') or h:get('accept') or 'application/dns-message' + media_type = media_type:match('[^;]+') + local serve = content_type_handlers[media_type] or serve_wireformat + return serve(h, stream, media_type) +end + +-- Export endpoints +return { + ['/dns-query'] = {'application/dns-message', serve_doh}, + ['/.well-known/dns-query'] = {'application/dns-message', serve_doh}, +} diff --git a/modules/http/http.mk b/modules/http/http.mk index 9ce4f0de2..d4882c9a4 100644 --- a/modules/http/http.mk +++ b/modules/http/http.mk @@ -1,3 +1,3 @@ -http_SOURCES := http.lua prometheus.lua http_trace.lua +http_SOURCES := http.lua prometheus.lua dns_over_https.lua http_trace.lua http_INSTALL := $(wildcard modules/http/static/*) $(call make_lua_module,http) -- 2.47.2