]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
Merge pull request #13768 from rgacogne/ddist-maintenance-hook
authorRemi Gacogne <remi.gacogne@powerdns.com>
Thu, 8 Feb 2024 14:31:55 +0000 (15:31 +0100)
committerGitHub <noreply@github.com>
Thu, 8 Feb 2024 14:31:55 +0000 (15:31 +0100)
dnsdist: Add a Lua maintenance hook

16 files changed:
.github/actions/spell-check/expect.txt
.github/workflows/build-and-test-all.yml
builder-support/dockerfiles/Dockerfile.target.ubuntu-noble
docs/lua-records/functions.rst
docs/upgrading.rst
pdns/bpf-filter.cc
pdns/dnsdistdist/doh3.cc
pdns/dnsdistdist/doq-common.hh
pdns/dnsdistdist/doq.cc
pdns/lua-record.cc
pdns/ws-auth.cc
regression-tests.auth-py/test_LuaRecords.py
regression-tests.dnsdist/dnsdistDynBlockTests.py
regression-tests.dnsdist/dnsdisttests.py
regression-tests.dnsdist/test_DynBlocksEBPF.py [new file with mode: 0644]
tasks.py

index e88447ab9c12a89b7695fbd4e3f970360cdefc1c..9d37e70ff59d5b6a89ee2ca0fe0a4755df954981 100644 (file)
@@ -257,6 +257,7 @@ Davids
 Dayneko
 dbfile
 dblfilename
+dblookup
 dbpf
 dbr
 dcobject
index 2b97fd36f302e91f7b023a7336abca425d97bcc0..cbf691718dc509aa736242c2f569067f84d20a81 100644 (file)
@@ -612,7 +612,7 @@ jobs:
         SKIP_INCLUDEDIR_TESTS: yes
         SANITIZERS: ${{ matrix.sanitizers }}
         COVERAGE: yes
-      options: --sysctl net.ipv6.conf.all.disable_ipv6=0
+      options: --sysctl net.ipv6.conf.all.disable_ipv6=0 --privileged
     steps:
       - uses: actions/checkout@v4
         with:
index 6166a0a9a71caa1167a0ff7be1630247200b8260..e25bddfb8551d904d1fe83e775020f3f1a8926df 100644 (file)
@@ -14,6 +14,8 @@ FROM arm64v8/ubuntu:noble as dist-base
 ARG BUILDER_CACHE_BUSTER=
 ARG APT_URL
 RUN apt-get update && apt-get -y dist-upgrade
+# FIXME: Package usrmerge missing sha256 str
+RUN apt-get purge -y usrmerge
 
 @INCLUDE Dockerfile.debbuild-prepare
 
index 6902d761ad2c2e8c2782301f04894f96091228f9..7c6a22fec8b40c9f90780875148eb89a19871f4e 100644 (file)
@@ -454,3 +454,20 @@ Helper functions
   Returns true if ``bestwho`` is within any of the listed subnets.
 
   :param [string] netmasks: The list of IP addresses to check against
+
+.. function:: dblookup(name, type)
+
+  Does a database lookup for name and type, and returns a (possibly empty) array of string results.
+
+  Please keep the following in mind:
+
+  * it does not evaluate any LUA code found
+  * if you needed just one string, perhaps you want ``dblookup('www.example.org', 'A')[1]`` to take the first item from the array
+  * some things, like ifurlup, don't like empty tables, so be careful not to accidentally look up a name that does not have any records of that type, if you are going to use the result in ``ifurlup``
+
+  Example usage: ::
+
+    www IN LUA A "ifurlup('https://www.example.com/', {dblookup('www1.example.com', 'A'), dblookup('www2.example.com', 'A'), dblookup('www3.example.com', 'A')})"
+
+  :param string name: Name to look up in the database
+  :param string type: DNS type to look for
index fb35e7a49ed0bca8fb317f3e25b837edcc0d0b9e..1fce623997f77a238a77bad82ce2c253e65de02f 100644 (file)
@@ -49,6 +49,13 @@ Various custom queries have been renamed.
 
 Also, ``get-all-domains-query`` got an extra column for a zone's catalog assignment.
 
+API changes
+~~~~~~~~~~~
+
+A long time ago (in version 3.4.2), the ``priority`` field was removed from record content in the HTTP API.
+Starting with 4.9, API calls containing a ``priority`` field are actively rejected.
+This makes it easier for users to detect they are attempting to use a very old API client.
+
 any version to 4.8.x
 --------------------
 
index 32f6986d97630fae720383deebf4f5594bd10099..4a12d9d94e615cecbae56729f1cee5fe88e7f54c 100644 (file)
@@ -718,21 +718,21 @@ std::vector<std::pair<ComboAddress, uint64_t> > BPFFilter::getAddrStats()
     result.reserve(maps->d_v4.d_count + maps->d_v6.d_count);
   }
 
-  sockaddr_in v4Addr;
+  sockaddr_in v4Addr{};
   memset(&v4Addr, 0, sizeof(v4Addr));
   v4Addr.sin_family = AF_INET;
 
   uint32_t v4Key = 0;
-  uint32_t nextV4Key;
-  CounterAndActionValue value;
+  uint32_t nextV4Key{};
+  CounterAndActionValue value{};
 
-  uint8_t v6Key[16];
-  uint8_t nextV6Key[16];
-  sockaddr_in6 v6Addr;
+  std::array<uint8_t, 16> v6Key{};
+  std::array<uint8_t, 16> nextV6Key{};
+  sockaddr_in6 v6Addr{};
   memset(&v6Addr, 0, sizeof(v6Addr));
   v6Addr.sin6_family = AF_INET6;
 
-  static_assert(sizeof(v6Addr.sin6_addr.s6_addr) == sizeof(v6Key), "POSIX mandates s6_addr to be an array of 16 uint8_t");
+  static_assert(sizeof(v6Addr.sin6_addr.s6_addr) == v6Key.size(), "POSIX mandates s6_addr to be an array of 16 uint8_t");
   memset(&v6Key, 0, sizeof(v6Key));
 
   auto maps = d_maps.lock();
@@ -754,16 +754,16 @@ std::vector<std::pair<ComboAddress, uint64_t> > BPFFilter::getAddrStats()
 
   {
     auto& map = maps->d_v6;
-    int res = bpf_get_next_key(map.d_fd.getHandle(), &v6Key, &nextV6Key);
+    int res = bpf_get_next_key(map.d_fd.getHandle(), v6Key.data(), nextV6Key.data());
 
     while (res == 0) {
-      if (bpf_lookup_elem(map.d_fd.getHandle(), &nextV6Key, &value) == 0) {
-        memcpy(&v6Addr.sin6_addr.s6_addr, &nextV6Key, sizeof(nextV6Key));
+      if (bpf_lookup_elem(map.d_fd.getHandle(), nextV6Key.data(), &value) == 0) {
+        memcpy(&v6Addr.sin6_addr.s6_addr, nextV6Key.data(), nextV6Key.size());
 
         result.emplace_back(ComboAddress(&v6Addr), value.counter);
       }
 
-      res = bpf_get_next_key(map.d_fd.getHandle(), &nextV6Key, &nextV6Key);
+      res = bpf_get_next_key(map.d_fd.getHandle(), nextV6Key.data(), nextV6Key.data());
     }
   }
 
index 4c0823ab621e669b027f20ad977af477cb20bf32..6b9ff9a0a20349b10f85e6b2f9cfebdbfcb9cb3a 100644 (file)
@@ -880,6 +880,11 @@ static void handleSocketReadable(DOH3Frontend& frontend, ClientState& clientStat
 
     if (!conn) {
       DEBUGLOG("Connection not found");
+      if (type != static_cast<uint8_t>(DOQ_Packet_Types::QUIC_PACKET_TYPE_INITIAL)) {
+        DEBUGLOG("Packet is not initial");
+        continue;
+      }
+
       if (!quiche_version_is_supported(version)) {
         DEBUGLOG("Unsupported version");
         ++frontend.d_doh3UnsupportedVersionErrors;
index 92af37f25fc29ebc00f2d2506ffd4d1a50d9f579..2d1094c6bd593d49172fd2eed1aa7d06998b81a8 100644 (file)
@@ -71,6 +71,17 @@ enum class DOQ_Error_Codes : uint64_t
   DOQ_UNSPECIFIED_ERROR = 5
 };
 
+/* Quiche type values do not match rfc9000 */
+enum class DOQ_Packet_Types : uint8_t
+{
+  QUIC_PACKET_TYPE_INITIAL = 1,
+  QUIC_PACKET_TYPE_RETRY = 2,
+  QUIC_PACKET_TYPE_HANDSHAKE = 3,
+  QUIC_PACKET_TYPE_ZERO_RTT = 4,
+  QUIC_PACKET_TYPE_SHORT = 5,
+  QUIC_PACKET_TYPE_VERSION_NEGOTIATION = 6
+};
+
 static constexpr size_t MAX_TOKEN_LEN = dnsdist::crypto::authenticated::getEncryptedSize(std::tuple_size<decltype(dnsdist::crypto::authenticated::Nonce::value)>{} /* nonce */ + sizeof(uint64_t) /* TTD */ + 16 /* IPv6 */ + QUICHE_MAX_CONN_ID_LEN);
 static constexpr size_t MAX_DATAGRAM_SIZE = 1200;
 static constexpr size_t LOCAL_CONN_ID_LEN = 16;
index 277a8c5985ac1f86ffe5d88dcf225b24376cbfea..4b544dc8504cb7ecab86f256e704b14bbb02d59b 100644 (file)
@@ -674,6 +674,11 @@ static void handleSocketReadable(DOQFrontend& frontend, ClientState& clientState
 
     if (!conn) {
       DEBUGLOG("Connection not found");
+      if (type != static_cast<uint8_t>(DOQ_Packet_Types::QUIC_PACKET_TYPE_INITIAL)) {
+        DEBUGLOG("Packet is not initial");
+        continue;
+      }
+
       if (!quiche_version_is_supported(version)) {
         DEBUGLOG("Unsupported version");
         ++frontend.d_doqUnsupportedVersionErrors;
index 343781b0631c3e0780b937771d9a38154a149fd2..6749ffaa01693e523771b12b12b077053383fc09 100644 (file)
@@ -4,6 +4,7 @@
 #include <utility>
 #include <algorithm>
 #include <random>
+#include "qtype.hh"
 #include "version.hh"
 #include "ext/luawrapper/include/LuaContext.hpp"
 #include "lock.hh"
@@ -503,6 +504,16 @@ static std::vector<DNSZoneRecord> lookup(const DNSName& name, uint16_t qtype, in
   return ret;
 }
 
+static bool getAuth(const DNSName& name, uint16_t qtype, SOAData* soaData)
+{
+  static LockGuarded<UeberBackend> s_ub;
+
+  {
+    auto ueback = s_ub.lock();
+    return ueback->getAuth(name, qtype, soaData);
+  }
+}
+
 static std::string getOptionValue(const boost::optional<std::unordered_map<string, string>>& options, const std::string &name, const std::string &defaultValue)
 {
   string selector=defaultValue;
@@ -1116,6 +1127,39 @@ static void setupLuaRecords(LuaContext& lua) // NOLINT(readability-function-cogn
       return result;
     });
 
+  lua.writeFunction("dblookup", [](const string& record, const string& type) {
+    DNSName rec;
+    QType qtype;
+    vector<string> ret;
+    try {
+      rec = DNSName(record);
+      qtype = type;
+      if (qtype.getCode() == 0) {
+        throw std::invalid_argument("unknown type");
+      }
+    }
+    catch (const std::exception& e) {
+      g_log << Logger::Error << "DB lookup cannot be performed, the name (" << record << ") or type (" << type << ") is malformed: " << e.what() << endl;
+      return ret;
+    }
+    try {
+      SOAData soaData;
+
+      if (!getAuth(rec, qtype, &soaData)) {
+        return ret;
+      }
+
+      vector<DNSZoneRecord> drs = lookup(rec, qtype, soaData.domain_id);
+      for (const auto& drec : drs) {
+        ret.push_back(drec.dr.getContent()->getZoneRepresentation());
+      }
+    }
+    catch (std::exception& e) {
+      g_log << Logger::Error << "Failed to do DB lookup for " << rec << "/" << qtype << ": " << e.what() << endl;
+    }
+    return ret;
+  });
+
   lua.writeFunction("include", [&lua](string record) {
       DNSName rec;
       try {
index 7cfaea4619ea92aa5ec6e6870b4083622eae2206..9c82d7dafa0f31b0fd4441e5269ec26d6dc39e5e 100644 (file)
@@ -591,6 +591,9 @@ static void gatherRecords(const Json& container, const DNSName& qname, const QTy
   const auto& items = container["records"].array_items();
   for (const auto& record : items) {
     string content = stringFromJson(record, "content");
+    if (record.object_items().count("priority") > 0) {
+      throw std::runtime_error("`priority` element is not allowed in record");
+    }
     resourceRecord.disabled = false;
     if (!record["disabled"].is_null()) {
       resourceRecord.disabled = boolFromJson(record, "disabled");
index 3b1cc721dbeeecb427d7cef8f6ee6daed09bb7be..96dc9bc409d61d89859a0f45a1d23f7a049633e1 100644 (file)
@@ -149,6 +149,9 @@ newcafromraw     IN    LUA    A    "newCAFromRaw('ABCD'):toString()"
 newcafromraw     IN    LUA    AAAA "newCAFromRaw('ABCD020340506070'):toString()"
 
 counter          IN    LUA    TXT  ";counter = counter or 0 counter=counter+1 return tostring(counter)"
+
+lookmeup         IN           A  192.0.2.5
+dblookup         IN    LUA    A  "dblookup('lookmeup.example.org', 'A')[1]"
         """,
         'createforward6.example.org': """
 createforward6.example.org.                 3600 IN SOA  {soa}
@@ -1026,6 +1029,24 @@ createforward6.example.org.                 3600 IN NS   ns2.example.org.
         self.assertEqual(len(resUDP), 1)
         self.assertEqual(len(resTCP), 1)
 
+    def testDblookup(self):
+        """
+        Test dblookup() function
+        """
+
+        name = 'dblookup.example.org.'
+
+        query = dns.message.make_query(name, 'A')
+
+        response = dns.message.make_response(query)
+
+        response.answer.append(dns.rrset.from_text(name, 0, dns.rdataclass.IN, 'A', '192.0.2.5'))
+
+        res = self.sendUDPQuery(query)
+        self.assertRcodeEqual(res, dns.rcode.NOERROR)
+        self.assertEqual(self.sortRRsets(res.answer), self.sortRRsets(response.answer))
+
+
 class TestLuaRecordsShared(TestLuaRecords):
     _config_template = """
 geoip-database-files=../modules/geoipbackend/regression-tests/GeoLiteCity.mmdb
index 67f986b585e14f8a69c98b1014bd6c16b078d0f8..f9acc85952554c308147987784d5a0fa71d54bdf 100644 (file)
@@ -23,7 +23,7 @@ class DynBlocksTest(DNSDistTest):
     _dynBlockDuration = _maintenanceWaitTime + 2
     _config_params = ['_dynBlockQPS', '_dynBlockPeriod', '_dynBlockDuration', '_testServerPort']
 
-    def doTestDynBlockViaAPI(self, ipRange, reason, minSeconds, maxSeconds, minBlocks, maxBlocks):
+    def doTestDynBlockViaAPI(self, ipRange, reason, minSeconds, maxSeconds, minBlocks, maxBlocks, ebpf=False):
         headers = {'x-api-key': self._webServerAPIKey}
         url = 'http://127.0.0.1:' + str(self._webServerPort) + '/jsonstat?command=dynblocklist'
         r = requests.get(url, headers=headers, timeout=self._webTimeout)
@@ -35,7 +35,7 @@ class DynBlocksTest(DNSDistTest):
         self.assertIn(ipRange, content)
 
         values = content[ipRange]
-        for key in ['reason', 'seconds', 'blocks', 'action']:
+        for key in ['reason', 'seconds', 'blocks', 'action', 'ebpf']:
             self.assertIn(key, values)
 
         self.assertEqual(values['reason'], reason)
@@ -43,8 +43,9 @@ class DynBlocksTest(DNSDistTest):
         self.assertLessEqual(values['seconds'], maxSeconds)
         self.assertGreaterEqual(values['blocks'], minBlocks)
         self.assertLessEqual(values['blocks'], maxBlocks)
+        self.assertEqual(values['ebpf'], True if ebpf else False)
 
-    def doTestQRate(self, name, testViaAPI=True):
+    def doTestQRate(self, name, testViaAPI=True, ebpf=False):
         query = dns.message.make_query(name, 'A', 'IN')
         response = dns.message.make_response(query)
         rrset = dns.rrset.from_text(name,
@@ -77,11 +78,11 @@ class DynBlocksTest(DNSDistTest):
             waitForMaintenanceToRun()
 
         # we should now be dropped for up to self._dynBlockDuration + self._dynBlockPeriod
-        (_, receivedResponse) = self.sendUDPQuery(query, response=None, useQueue=False, timeout=1)
+        (_, receivedResponse) = self.sendUDPQuery(query, response=None, useQueue=False, timeout=0.5)
         self.assertEqual(receivedResponse, None)
 
         if testViaAPI:
-            self.doTestDynBlockViaAPI('127.0.0.1/32', 'Exceeded query rate', 1, self._dynBlockDuration, (sent-allowed)+1, (sent-allowed)+1)
+            self.doTestDynBlockViaAPI('127.0.0.1/32', 'Exceeded query rate', 1, self._dynBlockDuration, (sent-allowed)+1, (sent-allowed)+1, ebpf)
 
         # wait until we are not blocked anymore
         time.sleep(self._dynBlockDuration + self._dynBlockPeriod)
@@ -116,7 +117,7 @@ class DynBlocksTest(DNSDistTest):
             waitForMaintenanceToRun()
 
         # we should now be dropped for up to self._dynBlockDuration + self._dynBlockPeriod
-        (_, receivedResponse) = self.sendTCPQuery(query, response=None, useQueue=False)
+        (_, receivedResponse) = self.sendTCPQuery(query, response=None, useQueue=False, timeout=0.5)
         self.assertEqual(receivedResponse, None)
 
         # wait until we are not blocked anymore
@@ -293,7 +294,7 @@ class DynBlocksTest(DNSDistTest):
         allowed = 0
         sent = 0
         for _ in range(int(dynBlockBytesPerSecond * 5 / len(response.to_wire()))):
-            (receivedQuery, receivedResponse) = self.sendTCPQuery(query, response)
+            (receivedQuery, receivedResponse) = self.sendTCPQuery(query, response, timeout=0.5)
             sent = sent + len(response.to_wire())
             if receivedQuery:
                 receivedQuery.id = query.id
@@ -428,7 +429,7 @@ class DynBlocksTest(DNSDistTest):
             waitForMaintenanceToRun()
 
         # we should now be dropped for up to self._dynBlockDuration + self._dynBlockPeriod
-        (_, receivedResponse) = self.sendTCPQuery(query, response=None, useQueue=False)
+        (_, receivedResponse) = self.sendTCPQuery(query, response=None, useQueue=False, timeout=0.5)
         self.assertEqual(receivedResponse, None)
 
         # wait until we are not blocked anymore
@@ -535,7 +536,7 @@ class DynBlocksTest(DNSDistTest):
         waitForMaintenanceToRun()
 
         # we should now be dropped for up to self._dynBlockDuration + self._dynBlockPeriod
-        (_, receivedResponse) = self.sendTCPQuery(query, response=None, useQueue=False)
+        (_, receivedResponse) = self.sendTCPQuery(query, response=None, useQueue=False, timeout=0.5)
         self.assertEqual(receivedResponse, None)
 
         # wait until we are not blocked anymore
index d8e0cd9fff7e5368759c7fa2a083c2c7c388c1cd..323c67554940f788674ada02873df2d4c2312ffc 100644 (file)
@@ -88,6 +88,7 @@ class DNSDistTest(AssertEqualDNSMessageMixin, unittest.TestCase):
     _answerUnexpected = True
     _checkConfigExpectedOutput = None
     _verboseMode = False
+    _sudoMode = False
     _skipListeningOnCL = False
     _alternateListeningAddr = None
     _alternateListeningPort = None
@@ -150,6 +151,12 @@ class DNSDistTest(AssertEqualDNSMessageMixin, unittest.TestCase):
 
         if cls._verboseMode:
             dnsdistcmd.append('-v')
+        if cls._sudoMode:
+            preserve_env_values = ['LD_LIBRARY_PATH', 'LLVM_PROFILE_FILE']
+            for value in preserve_env_values:
+                if value in os.environ:
+                    dnsdistcmd.insert(0, value + '=' + os.environ[value])
+            dnsdistcmd.insert(0, 'sudo')
 
         for acl in cls._acl:
             dnsdistcmd.extend(['--acl', acl])
@@ -693,11 +700,15 @@ class DNSDistTest(AssertEqualDNSMessageMixin, unittest.TestCase):
         if useQueue:
             cls._toResponderQueue.put(response, True, timeout)
 
-        sock = cls.openTCPConnection(timeout)
+        try:
+            sock = cls.openTCPConnection(timeout)
+        except socket.timeout as e:
+            print("Timeout while opening TCP connection: %s" % (str(e)))
+            return (None, None)
 
         try:
-            cls.sendTCPQueryOverConnection(sock, query, rawQuery)
-            message = cls.recvTCPResponseOverConnection(sock)
+            cls.sendTCPQueryOverConnection(sock, query, rawQuery, timeout=timeout)
+            message = cls.recvTCPResponseOverConnection(sock, timeout=timeout)
         except socket.timeout as e:
             print("Timeout while sending or receiving TCP data: %s" % (str(e)))
         except socket.error as e:
diff --git a/regression-tests.dnsdist/test_DynBlocksEBPF.py b/regression-tests.dnsdist/test_DynBlocksEBPF.py
new file mode 100644 (file)
index 0000000..8260649
--- /dev/null
@@ -0,0 +1,45 @@
+#!/usr/bin/env python
+import dns
+import os
+import unittest
+from dnsdisttests import DNSDistTest
+from dnsdistDynBlockTests import DynBlocksTest
+
+class EBPFTest(object):
+    pass
+
+@unittest.skipUnless('ENABLE_SUDO_TESTS' in os.environ, "sudo is not available")
+class TestDynBlockEBPFQPS(DynBlocksTest):
+
+    _config_template = """
+    bpf = newBPFFilter({ipv4MaxItems=10, ipv6MaxItems=10, qnamesMaxItems=10})
+    setDefaultBPFFilter(bpf)
+    local dbr = dynBlockRulesGroup()
+    dbr:setQueryRate(%d, %d, "Exceeded query rate", %d)
+    function maintenance()
+        dbr:apply()
+    end
+
+    -- not going to wait 60s!
+    setDynBlocksPurgeInterval(1)
+
+    -- exercise the manual blocking methods
+    bpf:block(newCA("2001:DB8::42"))
+    bpf:blockQName(newDNSName("powerdns.com."), 255)
+    bpf:getStats()
+    bpf:unblock(newCA("2001:DB8::42"))
+    bpf:unblockQName(newDNSName("powerdns.com."), 255)
+
+    newServer{address="127.0.0.1:%s"}
+    webserver("127.0.0.1:%s")
+    setWebserverConfig({password="%s", apiKey="%s"})
+    """
+    _config_params = ['_dynBlockQPS', '_dynBlockPeriod', '_dynBlockDuration', '_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed']
+    _sudoMode = True
+
+    def testDynBlocksQRate(self):
+        """
+        Dyn Blocks: QRate
+        """
+        name = 'qrate.dynblocks.tests.powerdns.com.'
+        self.doTestQRate(name, ebpf=True)
index 1fbb5553f1449667a91130e95391efc6aba9c2ed..9c5fe5b305bdebc8ada9d59bb0530512cd040de1 100644 (file)
--- a/tasks.py
+++ b/tasks.py
@@ -585,7 +585,7 @@ def ci_dnsdist_configure(c, features):
         features_set,
         unittests,
         fuzztargets,
-        ' --enable-lto=thin',
+        '--enable-lto=thin',
         '--prefix=/opt/dnsdist'
     ])
 
@@ -871,7 +871,7 @@ def test_dnsdist(c):
     c.run('ls -ald /var /var/agentx /var/agentx/master')
     c.run('ls -al /var/agentx/master')
     with c.cd('regression-tests.dnsdist'):
-        c.run('DNSDISTBIN=/opt/dnsdist/bin/dnsdist LD_LIBRARY_PATH=/opt/dnsdist/lib/ ./runtests')
+        c.run('DNSDISTBIN=/opt/dnsdist/bin/dnsdist LD_LIBRARY_PATH=/opt/dnsdist/lib/ ENABLE_SUDO_TESTS=1 ./runtests')
 
 @task
 def test_regression_recursor(c):