]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
dnsdist: Add an API endpoint to remove entries from caches
authorRemi Gacogne <remi.gacogne@powerdns.com>
Wed, 25 Jan 2023 14:38:34 +0000 (15:38 +0100)
committerRemi Gacogne <remi.gacogne@powerdns.com>
Wed, 25 Jan 2023 14:38:34 +0000 (15:38 +0100)
pdns/dnsdist-web.cc
pdns/dnsdistdist/docs/guides/webserver.rst
pdns/dnsdistdist/docs/install.rst
regression-tests.dnsdist/test_Caching.py
tasks.py

index c85a52a8ee9a339023b8c3b0ac9c8d6fc905e526..4db57b034e598ee9d85baa7370b7b30c93b90c1f 100644 (file)
@@ -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<ServerPool> 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<std::string, std::function<void(const YaHTTP::Request&, YaHTTP::Response&)>> s_webHandlers;
 
 void registerWebHandler(const std::string& endpoint, std::function<void(const YaHTTP::Request&, YaHTTP::Response&)> 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);
 
index 80f7834586a7828ef72275b40a300c1cb808ad19..088defe34ba4f8cec8e072fc2f5b1f71de0e21e3 100755 (executable)
@@ -635,6 +635,45 @@ URL Endpoints
         username: dontcare
         password: yoursecret
 
+.. http:delete:: /api/v1/cache?pool=<pool-name>&name=<dns-name>[&type=<dns-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.
index 8b96747ceedea4b6b9f5d11474bf45df16d4f658..54194b729e8453021f414dca8d4e992b9a7db050 100644 (file)
@@ -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:
index 4dd5f88f0cafab6657dfd7ebce7a9133e59526fb..77c1d72af820d62d037dd11169cc5d8ad45946e6 100644 (file)
@@ -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)
index 8fba2a7c28832ef97ad7098f0a0b8ef78a3a93f5..78411cb3746d849c432d8c962419556423c4ea2b 100644 (file)
--- 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 \