From: Remi Gacogne Date: Wed, 25 Jan 2023 14:38:34 +0000 (+0100) Subject: dnsdist: Add an API endpoint to remove entries from caches X-Git-Tag: dnsdist-1.8.0-rc1~74^2~2 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=85241b78c2958fac3d43046b9dbe1f545cda322c;p=thirdparty%2Fpdns.git dnsdist: Add an API endpoint to remove entries from caches --- diff --git a/pdns/dnsdist-web.cc b/pdns/dnsdist-web.cc index c85a52a8ee..4db57b034e 100644 --- a/pdns/dnsdist-web.cc +++ b/pdns/dnsdist-web.cc @@ -350,6 +350,11 @@ static bool isMethodAllowed(const YaHTTP::Request& req) return true; } } + if (req.method == "DELETE") { + if (req.url.path == "/api/v1/cache") { + return true; + } + } return false; } @@ -1458,6 +1463,91 @@ static void handleAllowFrom(const YaHTTP::Request& req, YaHTTP::Response& resp) } #endif /* DISABLE_WEB_CONFIG */ +#ifndef DISABLE_WEB_CACHE_MANAGEMENT +static void handleCacheManagement(const YaHTTP::Request& req, YaHTTP::Response& resp) +{ + handleCORS(req, resp); + + resp.headers["Content-Type"] = "application/json"; + resp.status = 200; + + if (req.method != "DELETE") { + resp.status = 400; + Json::object obj{ + { "status", "denied" }, + { "error", "invalid method" } + }; + resp.body = Json(obj).dump(); + return; + } + + const auto poolName = req.getvars.find("pool"); + const auto expungeName = req.getvars.find("name"); + const auto expungeType = req.getvars.find("type"); + const auto suffix = req.getvars.find("suffix"); + if (poolName == req.getvars.end() || expungeName == req.getvars.end()) { + resp.status = 400; + Json::object obj{ + { "status", "denied" }, + { "error", "missing 'pool' or 'name' parameter" }, + }; + resp.body = Json(obj).dump(); + return; + } + + DNSName name; + QType type(QType::ANY); + try { + name = DNSName(expungeName->second); + } + catch (const std::exception& e) { + resp.status = 404; + Json::object obj{ + { "status", "error" }, + { "error", "unable to parse the requested name" }, + }; + resp.body = Json(obj).dump(); + return; + } + if (expungeType != req.getvars.end()) { + type = QType::chartocode(expungeType->second.c_str()); + } + + std::shared_ptr pool; + try { + pool = getPool(g_pools.getCopy(), poolName->second); + } + catch (const std::exception& e) { + resp.status = 404; + Json::object obj{ + { "status", "not found" }, + { "error", "the requested pool does not exist" }, + }; + resp.body = Json(obj).dump(); + return; + } + + auto cache = pool->getCache(); + if (cache == nullptr) { + resp.status = 404; + Json::object obj{ + { "status", "not found" }, + { "error", "there is no cache associated to the requested pool" }, + }; + resp.body = Json(obj).dump(); + return; + } + + auto removed = cache->expungeByName(name, type.getCode(), suffix != req.getvars.end()); + + Json::object obj{ + { "status", "purged" }, + { "count", std::to_string(removed) } + }; + resp.body = Json(obj).dump(); +} +#endif /* DISABLE_WEB_CACHE_MANAGEMENT */ + static std::unordered_map> s_webHandlers; void registerWebHandler(const std::string& endpoint, std::function handler); @@ -1526,6 +1616,9 @@ void registerBuiltInWebHandlers() registerWebHandler("/api/v1/servers/localhost/config", handleConfigDump); registerWebHandler("/api/v1/servers/localhost/config/allow-from", handleAllowFrom); #endif /* DISABLE_WEB_CONFIG */ +#ifndef DISABLE_WEB_CACHE_MANAGEMENT + registerWebHandler("/api/v1/cache", handleCacheManagement); +#endif /* DISABLE_WEB_CACHE_MANAGEMENT */ #ifndef DISABLE_BUILTIN_HTML registerWebHandler("/", redirectToIndex); diff --git a/pdns/dnsdistdist/docs/guides/webserver.rst b/pdns/dnsdistdist/docs/guides/webserver.rst index 80f7834586..088defe34b 100755 --- a/pdns/dnsdistdist/docs/guides/webserver.rst +++ b/pdns/dnsdistdist/docs/guides/webserver.rst @@ -635,6 +635,45 @@ URL Endpoints username: dontcare password: yoursecret +.. http:delete:: /api/v1/cache?pool=&name=[&type=][&suffix=] + + .. versionadded:: 1.8.0 + + Allows removing entries from a cache. The pool to which the cache is associated should be specified in the ``pool`` parameter, and the name to remove in the ``name`` parameter. + By default only entries matching the exact name will be removed, but it is possible to remove all entries below that name by passing the ``suffix`` parameter set to any value. + By default entries for all types for the name are removed, but it is possible to only remove entries for a specific type by passing the ``type`` parameter set to the requested type. Supported values are DNS type names as a strings (``AAAA``), or numerical values (as either ``#64`` or ``TYPE64``). + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/cache?pool=&name=free.fr HTTP/1.1 + Accept: */* + Accept-Encoding: gzip, deflate + Connection: keep-alive + Content-Length: 0 + Host: localhost:8080 + X-API-Key: supersecretAPIkey + + + **Example response**: + .. sourcecode:: http + + HTTP/1.1 200 OK + Connection: close + Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline' + Content-Type: application/json + Transfer-Encoding: chunked + X-Content-Type-Options: nosniff + X-Frame-Options: deny + X-Permitted-Cross-Domain-Policies: none + X-Xss-Protection: 1; mode=block + + { + "count": "1", + "status": "purged" + } + .. http:get:: /api/v1/servers/localhost Get a quick overview of several parameters. diff --git a/pdns/dnsdistdist/docs/install.rst b/pdns/dnsdistdist/docs/install.rst index 8b96747cee..54194b729e 100644 --- a/pdns/dnsdistdist/docs/install.rst +++ b/pdns/dnsdistdist/docs/install.rst @@ -135,6 +135,7 @@ Our ``configure`` script provides a fair number of options with regard to which * ``DISABLE_RECVMMSG`` for ``recvmmsg`` support * ``DISABLE_RULES_ALTERING_QUERIES`` to remove rules altering the content of queries * ``DISABLE_SECPOLL`` for security polling +* ``DISABLE_WEB_CACHE_MANAGEMENT`` to disable cache management via the API * ``DISABLE_WEB_CONFIG`` to disable accessing the configuration via the web interface Additionally several Lua bindings can be removed when they are not needed, as they increase the memory required during compilation and the size of the final binary: diff --git a/regression-tests.dnsdist/test_Caching.py b/regression-tests.dnsdist/test_Caching.py index 4dd5f88f0c..77c1d72af8 100644 --- a/regression-tests.dnsdist/test_Caching.py +++ b/regression-tests.dnsdist/test_Caching.py @@ -4,6 +4,7 @@ import time import dns import clientsubnetoption import cookiesoption +import requests from dnsdisttests import DNSDistTest class TestCaching(DNSDistTest): @@ -2781,3 +2782,133 @@ class TestCachingBackendSettingRD(DNSDistTest): self.assertTrue(receivedQuery) self.assertTrue(receivedResponse) self.assertEqual(receivedResponse, expectedResponse) + +class TestAPICache(DNSDistTest): + _webTimeout = 2.0 + _webServerPort = 8083 + _webServerBasicAuthPassword = 'secret' + _webServerBasicAuthPasswordHashed = '$scrypt$ln=10,p=1,r=8$6DKLnvUYEeXWh3JNOd3iwg==$kSrhdHaRbZ7R74q3lGBqO1xetgxRxhmWzYJ2Qvfm7JM=' + _webServerAPIKey = 'apisecret' + _webServerAPIKeyHashed = '$scrypt$ln=10,p=1,r=8$9v8JxDfzQVyTpBkTbkUqYg==$bDQzAOHeK1G9UvTPypNhrX48w974ZXbFPtRKS34+aso=' + _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed'] + _config_template = """ + newServer{address="127.0.0.1:%s"} + webserver("127.0.0.1:%s") + setWebserverConfig({password="%s", apiKey="%s"}) + pc = newPacketCache(100) + getPool(""):setCache(pc) + getPool("pool-with-cache"):setCache(pc) + getPool("pool-without-cache") + """ + + def testCacheClearingViaAPI(self): + """ + Cache: Clear cache via API + """ + headers = {'x-api-key': self._webServerAPIKey} + url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/cache' + name = 'cache-api.cache.tests.powerdns.com.' + query = dns.message.make_query(name, 'AAAA', 'IN') + response = dns.message.make_response(query) + rrset = dns.rrset.from_text(name, + 3600, + dns.rdataclass.IN, + dns.rdatatype.AAAA, + '::1') + response.answer.append(rrset) + + # first query to fill the cache + (receivedQuery, receivedResponse) = self.sendUDPQuery(query, response) + self.assertTrue(receivedQuery) + self.assertTrue(receivedResponse) + receivedQuery.id = query.id + self.assertEqual(query, receivedQuery) + self.assertEqual(receivedResponse, response) + + # second query should be a hit + (_, receivedResponse) = self.sendUDPQuery(query, response=None, useQueue=False) + self.assertEqual(receivedResponse, response) + + # GET should on the cache API should yield a 400 + r = requests.get(url + '?pool=pool-without-cache&name=cache-api.cache.tests.powerdns.com.&type=AAAA', headers=headers, timeout=self._webTimeout) + self.assertEqual(r.status_code, 400) + + # different pool + r = requests.delete(url + '?pool=pool-without-cache&name=cache-api.cache.tests.powerdns.com.&type=AAAA', headers=headers, timeout=self._webTimeout) + self.assertEqual(r.status_code, 404) + + # no 'pool' + r = requests.delete(url + '?name=cache-api.cache.tests.powerdns.com.&type=AAAA', headers=headers, timeout=self._webTimeout) + self.assertEqual(r.status_code, 400) + + # no 'name' + r = requests.delete(url + '?pool=pool-without-cache&type=AAAA', headers=headers, timeout=self._webTimeout) + self.assertEqual(r.status_code, 400) + + # different name + r = requests.delete(url + '?pool=&name=not-cache-api.cache.tests.powerdns.com.', headers=headers, timeout=self._webTimeout) + self.assertTrue(r) + self.assertEqual(r.status_code, 200) + content = r.json() + self.assertIn('count', content) + self.assertEqual(int(content['count']), 0) + + # different type + r = requests.delete(url + '?pool=&name=cache-api.cache.tests.powerdns.com.&type=A', headers=headers, timeout=self._webTimeout) + self.assertTrue(r) + self.assertEqual(r.status_code, 200) + content = r.json() + self.assertIn('count', content) + self.assertEqual(int(content['count']), 0) + + # should still be a hit + (_, receivedResponse) = self.sendUDPQuery(query, response=None, useQueue=False) + self.assertEqual(receivedResponse, response) + + # remove + r = requests.delete(url + '?pool=&name=cache-api.cache.tests.powerdns.com.&type=AAAA', headers=headers, timeout=self._webTimeout) + self.assertTrue(r) + self.assertEqual(r.status_code, 200) + content = r.json() + self.assertIn('count', content) + self.assertEqual(int(content['count']), 1) + + # should be a miss + (receivedQuery, receivedResponse) = self.sendUDPQuery(query, response) + self.assertTrue(receivedQuery) + self.assertTrue(receivedResponse) + receivedQuery.id = query.id + self.assertEqual(query, receivedQuery) + self.assertEqual(receivedResponse, response) + + # remove all types + r = requests.delete(url + '?pool=&name=cache-api.cache.tests.powerdns.com.', headers=headers, timeout=self._webTimeout) + self.assertTrue(r) + self.assertEqual(r.status_code, 200) + content = r.json() + self.assertIn('count', content) + self.assertEqual(int(content['count']), 1) + + # should be a miss + (receivedQuery, receivedResponse) = self.sendUDPQuery(query, response) + self.assertTrue(receivedQuery) + self.assertTrue(receivedResponse) + receivedQuery.id = query.id + self.assertEqual(query, receivedQuery) + self.assertEqual(receivedResponse, response) + + # suffix removal + r = requests.delete(url + '?pool=&name=cache.tests.powerdns.com.&suffix=true', headers=headers, timeout=self._webTimeout) + self.assertTrue(r) + self.assertEqual(r.status_code, 200) + content = r.json() + self.assertIn('count', content) + self.assertEqual(int(content['count']), 1) + + # should be a miss + (receivedQuery, receivedResponse) = self.sendUDPQuery(query, response) + self.assertTrue(receivedQuery) + self.assertTrue(receivedResponse) + receivedQuery.id = query.id + self.assertEqual(query, receivedQuery) + self.assertEqual(receivedResponse, response) diff --git a/tasks.py b/tasks.py index 8fba2a7c28..78411cb374 100644 --- a/tasks.py +++ b/tasks.py @@ -413,6 +413,7 @@ def ci_dnsdist_configure(c, features): -DDISABLE_DNSNAME_BINDINGS \ -DDISABLE_DNSHEADER_BINDINGS \ -DDISABLE_RECVMMSG \ + -DDISABLE_WEB_CACHE_MANAGEMENT \ -DDISABLE_WEB_CONFIG \ -DDISABLE_RULES_ALTERING_QUERIES \ -DDISABLE_ECS_ACTIONS \