From: Remi Gacogne Date: Wed, 14 Aug 2019 17:15:08 +0000 (+0200) Subject: dnsdist: Add support for early DoH HTTP responses X-Git-Tag: dnsdist-1.4.0-rc2~3^2~3 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=28b564829dbad0166bb2c9f18e876db3e348fad8;p=thirdparty%2Fpdns.git dnsdist: Add support for early DoH HTTP responses --- diff --git a/pdns/dnsdist-lua-bindings.cc b/pdns/dnsdist-lua-bindings.cc index 9ad4b70d37..a8c1f931d3 100644 --- a/pdns/dnsdist-lua-bindings.cc +++ b/pdns/dnsdist-lua-bindings.cc @@ -369,4 +369,7 @@ void setupLuaBindings(bool client) return values; }); + g_lua.writeFunction("newDOHResponseMapEntry", [](const std::string& regex, uint16_t status, const std::string& content) { + return std::make_shared(regex, status, content); + }); } diff --git a/pdns/dnsdist-lua.cc b/pdns/dnsdist-lua.cc index a1aba5321f..c5de7973ac 100644 --- a/pdns/dnsdist-lua.cc +++ b/pdns/dnsdist-lua.cc @@ -1812,8 +1812,11 @@ void setupLuaConfig(bool client) #endif }); - g_lua.writeFunction("getDOHFrontend", [](size_t index) { + g_lua.writeFunction("getDOHFrontend", [client](size_t index) { std::shared_ptr result = nullptr; + if (client) { + return result; + } #ifdef HAVE_DNS_OVER_HTTPS setLuaNoSideEffect(); try { @@ -1841,6 +1844,19 @@ void setupLuaConfig(bool client) } }); + g_lua.registerFunction::*)(const std::map>&)>("setResponsesMap", [](std::shared_ptr frontend, const std::map>& map) { + if (frontend != nullptr) { + std::vector> newMap; + newMap.reserve(map.size()); + + for (const auto& entry : map) { + newMap.push_back(entry.second); + } + + frontend->d_responsesMap = std::move(newMap); + } + }); + g_lua.writeFunction("addTLSLocal", [client](const std::string& addr, boost::variant>> certFiles, boost::variant>> keyFiles, boost::optional vars) { #ifdef HAVE_DNS_OVER_TLS if (client) diff --git a/pdns/dnsdistdist/docs/reference/config.rst b/pdns/dnsdistdist/docs/reference/config.rst index b2752c653e..a202ea01a5 100644 --- a/pdns/dnsdistdist/docs/reference/config.rst +++ b/pdns/dnsdistdist/docs/reference/config.rst @@ -1145,6 +1145,24 @@ DOHFrontend Reload the current TLS certificate and key pairs. + .. method:: DOHFrontend:setResponsesMap(rules) + + Set a list of HTTP response rules allowing to intercept HTTP queries very early, before the DNS payload has been processed, and send custom responses including error pages, redirects and static content. + + :param list of DOHResponseMapEntry objects rules: A list of DOHResponseMapEntry objects, obtained with :func:`newDOHResponseMapEntry`. + + +.. function:: newDOHResponseMapEntry(regex, status, content) -> DOHResponseMapEntry + + .. versionadded:: 1.4.0 + + Return a DOHResponseMapEntry that can be used with :meth:`DOHFrontend.setResponsesMap`. Every query whose path matches the regular expression supplied in ``regex`` will be immediately answered with a HTTP response. + The status of the HTTP response will be the one supplied by ``status``, and the content set to the one supplied by ``content``, except if the status is a redirection (3xx) in which case the content is expected to be the URL to redirect to. + + :param str regex: A regular expression to match the path against. + :param int status: The HTTP code to answer with. + :param str content: The content of the HTTP response, or a URL if the status is a redirection (3xx). + TLSContext ~~~~~~~~~~ diff --git a/pdns/dnsdistdist/docs/rules-actions.rst b/pdns/dnsdistdist/docs/rules-actions.rst index d2a09dc811..239f8b2a3f 100644 --- a/pdns/dnsdistdist/docs/rules-actions.rst +++ b/pdns/dnsdistdist/docs/rules-actions.rst @@ -587,6 +587,7 @@ These ``DNSRule``\ s be one of the following items: .. versionadded:: 1.4.0 Matches DNS over HTTPS queries with a HTTP path matching the regular expression supplied in ``regex``. For example, if the query has been sent to the https://192.0.2.1:443/PowerDNS?dns=... URL, the path would be '/PowerDNS'. + Only valid DNS over HTTPS queries are matched. If you want to match all HTTP queries, see :meth:`DOHFrontend.setResponsesMap` instead. :param str regex: The regex to match on @@ -594,6 +595,7 @@ These ``DNSRule``\ s be one of the following items: .. versionadded:: 1.4.0 Matches DNS over HTTPS queries with a HTTP path of ``path``. For example, if the query has been sent to the https://192.0.2.1:443/PowerDNS?dns=... URL, the path would be '/PowerDNS'. + Only valid DNS over HTTPS queries are matched. If you want to match all HTTP queries, see :meth:`DOHFrontend.setResponsesMap` instead. :param str path: The exact HTTP path to match on diff --git a/pdns/dnsdistdist/doh.cc b/pdns/dnsdistdist/doh.cc index d918d531cc..6c86158ba9 100644 --- a/pdns/dnsdistdist/doh.cc +++ b/pdns/dnsdistdist/doh.cc @@ -149,7 +149,111 @@ static void on_socketclose(void *data) ctx->release(); } -/* this duplicates way too much from the UDP handler. Sorry. + +static const std::string& getReasonFromStatusCode(uint16_t statusCode) +{ + /* no need to care too much about this, HTTP/2 has no 'reason' anyway */ + static const std::unordered_map reasons = { + { 200, "OK" }, + { 301, "Moved Permanently" }, + { 302, "Found" }, + { 303, "See Other" }, + { 304, "Not Modified" }, + { 305, "Use Proxy" }, + { 306, "Switch Proxy" }, + { 307, "Temporary Redirect" }, + { 308, "Permanent Redirect" }, + { 400, "Bad Request" }, + { 401, "Unauthorized" }, + { 402, "Payment Required" }, + { 403, "Forbidden" }, + { 404, "Not Found" }, + { 405, "Method Not Allowed" }, + { 406, "Not Acceptable" }, + { 407, "Proxy Authentication Required" }, + { 408, "Request Timeout" }, + { 409, "Conflict" }, + { 410, "Gone" }, + { 411, "Length Required" }, + { 412, "Precondition Failed" }, + { 413, "Payload Too Large" }, + { 414, "URI Too Long" }, + { 415, "Unsupported Media Type" }, + { 416, "Range Not Satisfiable" }, + { 417, "Expectation Failed" }, + { 418, "I'm a teapot" }, + { 451, "Unavailable For Legal Reasons" }, + { 500, "Internal Server Error" }, + { 501, "Not Implemented" }, + { 502, "Bad Gateway" }, + { 503, "Service Unavailable" }, + { 504, "Gateway Timeout" }, + { 505, "HTTP Version Not Supported" } + }; + static const std::string unknown = "Unknown"; + + const auto it = reasons.find(statusCode); + if (it == reasons.end()) { + return unknown; + } + else { + return it->second; + } +} + +static void handleResponse(DOHFrontend& df, st_h2o_req_t* req, uint16_t statusCode, const std::string& response, const std::string& contentType) +{ + if (statusCode == 200) { + ++df.d_validresponses; + req->res.status = 200; + + if (contentType.empty()) { + h2o_add_header(&req->pool, &req->res.headers, H2O_TOKEN_CONTENT_TYPE, nullptr, H2O_STRLIT("application/dns-message")); + } + else { + /* we need to duplicate the header content because h2o keeps a pointer and we will be deleted before the response has been sent */ + h2o_iovec_t ct = h2o_strdup(&req->pool, contentType.c_str(), contentType.size()); + h2o_add_header(&req->pool, &req->res.headers, H2O_TOKEN_CONTENT_TYPE, nullptr, ct.base, ct.len); + } + + req->res.content_length = response.size(); + h2o_send_inline(req, response.c_str(), response.size()); + } + else if (statusCode >= 300 && statusCode < 400) { + /* in that case the response is actually a URL */ + /* we need to duplicate the URL because h2o uses it for the location header, keeping a pointer, and we will be deleted before the response has been sent */ + h2o_iovec_t url = h2o_strdup(&req->pool, response.c_str(), response.size()); + h2o_send_redirect(req, statusCode, getReasonFromStatusCode(statusCode).c_str(), url.base, url.len); + ++df.d_redirectresponses; + } + else { + if (!response.empty()) { + h2o_send_error_generic(req, statusCode, getReasonFromStatusCode(statusCode).c_str(), response.c_str(), 0); + } + else { + switch(statusCode) { + case 400: + h2o_send_error_400(req, getReasonFromStatusCode(statusCode).c_str(), "invalid DNS query" , 0); + break; + case 403: + h2o_send_error_403(req, getReasonFromStatusCode(statusCode).c_str(), "dns query not allowed", 0); + break; + case 502: + h2o_send_error_502(req, getReasonFromStatusCode(statusCode).c_str(), "no downstream server available", 0); + break; + case 500: + /* fall-through */ + default: + h2o_send_error_500(req, getReasonFromStatusCode(statusCode).c_str(), "Internal Server Error", 0); + break; + } + } + + ++df.d_errorresponses; + } +} + +/* this function calls 'return -1' to drop a query without sending it caller should make sure HTTPS thread hears of that */ @@ -470,6 +574,13 @@ try string path(req->path.base, req->path.len); + for (const auto& entry : dsc->df->d_responsesMap) { + if (entry->matches(path)) { + handleResponse(*dsc->df, req, entry->getStatusCode(), entry->getContent(), std::string()); + return 0; + } + } + if (h2o_memis(req->method.base, req->method.len, H2O_STRLIT("POST"))) { ++dsc->df->d_postqueries; if(req->version >= 0x0200) @@ -712,58 +823,6 @@ void dnsdistclient(int qsock, int rsock) } } -static const std::string& getReasonFromStatusCode(uint16_t statusCode) -{ - /* no need to care too much about this, HTTP/2 has no 'reason' anyway */ - static const std::unordered_map reasons = { - { 200, "OK" }, - { 301, "Moved Permanently" }, - { 302, "Found" }, - { 303, "See Other" }, - { 304, "Not Modified" }, - { 305, "Use Proxy" }, - { 306, "Switch Proxy" }, - { 307, "Temporary Redirect" }, - { 308, "Permanent Redirect" }, - { 400, "Bad Request" }, - { 401, "Unauthorized" }, - { 402, "Payment Required" }, - { 403, "Forbidden" }, - { 404, "Not Found" }, - { 405, "Method Not Allowed" }, - { 406, "Not Acceptable" }, - { 407, "Proxy Authentication Required" }, - { 408, "Request Timeout" }, - { 409, "Conflict" }, - { 410, "Gone" }, - { 411, "Length Required" }, - { 412, "Precondition Failed" }, - { 413, "Payload Too Large" }, - { 414, "URI Too Long" }, - { 415, "Unsupported Media Type" }, - { 416, "Range Not Satisfiable" }, - { 417, "Expectation Failed" }, - { 418, "I'm a teapot" }, - { 451, "Unavailable For Legal Reasons" }, - { 500, "Internal Server Error" }, - { 501, "Not Implemented" }, - { 502, "Bad Gateway" }, - { 503, "Service Unavailable" }, - { 504, "Gateway Timeout" }, - { 505, "HTTP Version Not Supported" } - }; - static const std::string unknown = "Unknown"; - - const auto it = reasons.find(statusCode); - if (it == reasons.end()) { - return unknown; - } - else { - return it->second; - } -} - - // called if h2o finds that dnsdist gave us an answer static void on_dnsdist(h2o_socket_t *listener, const char *err) { @@ -786,57 +845,8 @@ static void on_dnsdist(h2o_socket_t *listener, const char *err) } *du->self = nullptr; // so we don't clean up again in on_generator_dispose - if (du->status_code == 200) { - ++dsc->df->d_validresponses; - du->req->res.status = 200; - - // struct dnsheader* dh = (struct dnsheader*)du->query.c_str(); - // cout<<"Attempt to send out "<query.size()<<" bytes over https, TC="<tc<<", RCODE="<rcode<<", qtype="<qtype<<", req="<<(void*)du->req<contentType.empty()) { - h2o_add_header(&du->req->pool, &du->req->res.headers, H2O_TOKEN_CONTENT_TYPE, nullptr, H2O_STRLIT("application/dns-message")); - } - else { - /* we need to duplicate the header content because h2o keeps a pointer and we will be deleted before the response has been sent */ - h2o_iovec_t ct = h2o_strdup(&du->req->pool, du->contentType.c_str(), du->contentType.size()); - h2o_add_header(&du->req->pool, &du->req->res.headers, H2O_TOKEN_CONTENT_TYPE, nullptr, ct.base, ct.len); - } - du->req->res.content_length = du->response.size(); - h2o_send_inline(du->req, du->response.c_str(), du->response.size()); - } - else if (du->status_code >= 300 && du->status_code < 400) { - /* in that case the response is actually a URL */ - /* we need to duplicate the URL because h2o uses it for the location header, keeping a pointer, and we will be deleted before the response has been sent */ - h2o_iovec_t url = h2o_strdup(&du->req->pool, du->response.c_str(), du->response.size()); - h2o_send_redirect(du->req, du->status_code, getReasonFromStatusCode(du->status_code).c_str(), url.base, url.len); - ++dsc->df->d_redirectresponses; - } - else { - if (!du->response.empty()) { - h2o_send_error_generic(du->req, du->status_code, getReasonFromStatusCode(du->status_code).c_str(), du->response.c_str(), 0); - } - else { - switch(du->status_code) { - case 400: - h2o_send_error_400(du->req, getReasonFromStatusCode(du->status_code).c_str(), "invalid DNS query" , 0); - break; - case 403: - h2o_send_error_403(du->req, getReasonFromStatusCode(du->status_code).c_str(), "dns query not allowed", 0); - break; - case 502: - h2o_send_error_502(du->req, getReasonFromStatusCode(du->status_code).c_str(), "no downstream server available", 0); - break; - case 500: - /* fall-through */ - default: - h2o_send_error_500(du->req, getReasonFromStatusCode(du->status_code).c_str(), "Internal Server Error", 0); - break; - } - } - - ++dsc->df->d_errorresponses; - } + handleResponse(*dsc->df, du->req, du->status_code, du->response, du->contentType); delete du; } diff --git a/pdns/doh.hh b/pdns/doh.hh index ddcb243780..a058404be2 100644 --- a/pdns/doh.hh +++ b/pdns/doh.hh @@ -4,11 +4,40 @@ struct DOHServerConfig; +class DOHResponseMapEntry +{ +public: + DOHResponseMapEntry(const std::string& regex, uint16_t status, const std::string& content): d_regex(regex), d_content(content), d_status(status) + { + } + + bool matches(const std::string& path) const + { + return d_regex.match(path); + } + + uint16_t getStatusCode() const + { + return d_status; + } + + const std::string& getContent() const + { + return d_content; + } + +private: + Regex d_regex; + std::string d_content; + uint16_t d_status; +}; + struct DOHFrontend { std::shared_ptr d_dsc{nullptr}; std::vector> d_certKeyPairs; std::vector d_ocspFiles; + std::vector> d_responsesMap; std::string d_ciphers; std::string d_ciphers13; std::string d_serverTokens{"h2o/dnsdist"}; @@ -30,7 +59,7 @@ struct DOHFrontend std::atomic d_postqueries; // valid DNS queries received via POST std::atomic d_badrequests; // request could not be converted to dns query std::atomic d_errorresponses; // dnsdist set 'error' on response - std::atomic d_redirectresponses; // dnsdist set 'redirect' on response + std::atomic d_redirectresponses; // dnsdist set 'redirect' on response std::atomic d_validresponses; // valid responses sent out struct HTTPVersionStats diff --git a/regression-tests.dnsdist/test_DOH.py b/regression-tests.dnsdist/test_DOH.py index bf4b990c3e..7b7e652cea 100644 --- a/regression-tests.dnsdist/test_DOH.py +++ b/regression-tests.dnsdist/test_DOH.py @@ -148,6 +148,8 @@ class TestDOH(DNSDistDOHTest): newServer{address="127.0.0.1:%s"} addDOHLocal("127.0.0.1:%s", "%s", "%s", { "/" }, {customResponseHeaders={["access-control-allow-origin"]="*",["user-agent"]="derp"}}) + dohFE = getDOHFrontend(0) + dohFE:setResponsesMap({newDOHResponseMapEntry('^/coffee$', 418, 'C0FFEE')}) addAction("drop.doh.tests.powerdns.com.", DropAction()) addAction("refused.doh.tests.powerdns.com.", RCodeAction(DNSRCode.REFUSED)) @@ -562,6 +564,38 @@ class TestDOH(DNSDistDOHTest): self.assertEquals(self._rcode, 200) self.assertTrue('content-type: text/plain' in self._response_headers.decode()) + def testHTTPEarlyResponse(self): + """ + DOH: HTTP Early Response + """ + url = self._dohBaseURL + 'coffee' + conn = self.openDOHConnection(self._dohServerPort, caFile=self._caCert, timeout=2.0) + conn.setopt(pycurl.URL, url) + conn.setopt(pycurl.RESOLVE, ["%s:%d:127.0.0.1" % (self._serverName, self._dohServerPort)]) + conn.setopt(pycurl.SSL_VERIFYPEER, 1) + conn.setopt(pycurl.SSL_VERIFYHOST, 2) + conn.setopt(pycurl.CAINFO, self._caCert) + data = conn.perform_rb() + rcode = conn.getinfo(pycurl.RESPONSE_CODE) + + self.assertEquals(rcode, 418) + self.assertEquals(data, b'C0FFEE') + + conn = self.openDOHConnection(self._dohServerPort, caFile=self._caCert, timeout=2.0) + conn.setopt(pycurl.URL, url) + conn.setopt(pycurl.RESOLVE, ["%s:%d:127.0.0.1" % (self._serverName, self._dohServerPort)]) + conn.setopt(pycurl.SSL_VERIFYPEER, 1) + conn.setopt(pycurl.SSL_VERIFYHOST, 2) + conn.setopt(pycurl.CAINFO, self._caCert) + conn.setopt(pycurl.POST, True) + data = '' + conn.setopt(pycurl.POSTFIELDS, data) + + data = conn.perform_rb() + rcode = conn.getinfo(pycurl.RESPONSE_CODE) + self.assertEquals(rcode, 418) + self.assertEquals(data, b'C0FFEE') + class TestDOHAddingECS(DNSDistDOHTest): _serverKey = 'server.key'