]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
API Auth: replace zone contents
authorChris Hofstaedtler <chris.hofstaedtler@deduktiva.com>
Wed, 12 Oct 2022 12:22:42 +0000 (14:22 +0200)
committerChris Hofstaedtler <chris.hofstaedtler@deduktiva.com>
Fri, 11 Aug 2023 10:52:28 +0000 (12:52 +0200)
In PUT on a specific zone it is now possible to set "rrsets", like in
POST.

pdns/ws-auth.cc
regression-tests.api/test_Zones.py

index af30c9a0a34eec4db3f7ebd1cbeb90b23dd9eede..fc8eaca2c84990a54763d00c6d180249a82d90fa 100644 (file)
@@ -1899,10 +1899,76 @@ static void apiServerZoneDetail(HttpRequest* req, HttpResponse* resp) {
   }
 
   if(req->method == "PUT") {
-    // update domain settings
+    // update domain contents and/or settings
+    auto document = req->json();
 
-    di.backend->startTransaction(zonename, -1);
-    updateDomainSettingsFromDocument(B, di, zonename, req->json(), false);
+    auto rrsets = document["rrsets"];
+    bool zoneWasModified = false;
+    // if records/comments are given, load, check and insert them
+    if (rrsets.is_array()) {
+      zoneWasModified = true;
+      bool haveSoa = false;
+      string soaEditApiKind;
+      string soaEditKind;
+      di.backend->getDomainMetadataOne(zonename, "SOA-EDIT-API", soaEditApiKind);
+      di.backend->getDomainMetadataOne(zonename, "SOA-EDIT", soaEditKind);
+
+      vector<DNSResourceRecord> new_records;
+      vector<Comment> new_comments;
+
+      for (const auto& rrset : rrsets.array_items()) {
+        DNSName qname = apiNameToDNSName(stringFromJson(rrset, "name"));
+        apiCheckQNameAllowedCharacters(qname.toString());
+        QType qtype;
+        qtype = stringFromJson(rrset, "type");
+        if (qtype.getCode() == 0) {
+          throw ApiException("RRset "+qname.toString()+" IN "+stringFromJson(rrset, "type")+": unknown type given");
+        }
+        if (rrset["records"].is_array()) {
+          int ttl = intFromJson(rrset, "ttl");
+          gatherRecords(rrset, qname, qtype, ttl, new_records);
+        }
+        if (rrset["comments"].is_array()) {
+          gatherComments(rrset, qname, qtype, new_comments);
+        }
+      }
+
+      for(auto& rr : new_records) {
+        rr.qname.makeUsLowerCase();
+        if (!rr.qname.isPartOf(zonename) && rr.qname != zonename)
+          throw ApiException("RRset "+rr.qname.toString()+" IN "+rr.qtype.toString()+": Name is out of zone");
+        apiCheckQNameAllowedCharacters(rr.qname.toString());
+
+        if (rr.qtype.getCode() == QType::SOA && rr.qname==zonename) {
+          haveSoa = true;
+          increaseSOARecord(rr, soaEditApiKind, soaEditKind);
+        }
+      }
+
+      if (!haveSoa) {
+        // Require SOA regardless if this is a secondary zone or not.
+        // If clients want to "zero out" a secondary zone, they should still send a SOA with a,
+        // for their use case, "low enough" serial.
+        throw ApiException("Must give SOA record for zone when replacing all RR sets");
+      }
+
+      checkNewRecords(new_records, zonename);
+
+      di.backend->startTransaction(zonename, di.id);
+      for(auto& rr : new_records) {
+        rr.domain_id = di.id;
+        di.backend->feedRecord(rr, DNSName());
+      }
+      for(Comment& c : new_comments) {
+        c.domain_id = di.id;
+        di.backend->feedComment(c);
+      }
+    } else {
+      // avoid deleting current zone contents
+      di.backend->startTransaction(zonename, -1);
+    }
+
+    updateDomainSettingsFromDocument(B, di, zonename, document, zoneWasModified);
     di.backend->commitTransaction();
 
     resp->body = "";
index 2f5aa088155e221b734dd6ccc7ace96161045d68..050eef9ab94f6ee0e9f1149397991c6fa0d26d42 100644 (file)
@@ -49,6 +49,12 @@ def eq_zone_rrsets(rrsets, expected):
     assert data_got == data_expected, "%r != %r" % (data_got, data_expected)
 
 
+def assert_eq_rrsets(rrsets, expected):
+    """Assert rrsets sets are equal, ignoring sort order."""
+    key = lambda rrset: (rrset['name'], rrset['type'])
+    assert sorted(rrsets, key=key) == sorted(expected, key=key)
+
+
 class Zones(ApiTestCase):
 
     def _test_list_zones(self, dnssec=True):
@@ -2305,6 +2311,67 @@ $ORIGIN %NAME%
         }
         self.put_zone(name, payload, expect_error='A TSIG key with the name')
 
+    def test_zone_replace_rrsets_basic(self):
+        """Basic test: all automatic modification is off, on replace the new rrsets are ingested as is."""
+        name, _, _ = self.create_zone(dnssec=False, soa_edit='', soa_edit_api='')
+        rrsets = [
+            {'name': name, 'type': 'SOA', 'ttl': 3600, 'records': [{'content': 'invalid. hostmaster.invalid. 1 10800 3600 604800 3600'}]},
+            {'name': name, 'type': 'NS', 'ttl': 3600, 'records': [{'content': 'ns1.example.org.'}, {'content': 'ns2.example.org.'}]},
+            {'name': 'www.' + name, 'type': 'A', 'ttl': 3600, 'records': [{'content': '192.0.2.1'}]},
+            {'name': 'sub.' + name, 'type': 'NS', 'ttl': 3600, 'records': [{'content': 'ns1.example.org.'}]},
+        ]
+        self.put_zone(name, {'rrsets': rrsets})
+
+        data = self.get_zone(name)
+        for rrset in rrsets:
+            rrset.setdefault('comments', [])
+            for record in rrset['records']:
+                record.setdefault('disabled', False)
+        assert_eq_rrsets(data['rrsets'], rrsets)
+
+    def test_zone_replace_rrsets_dnssec(self):
+        """With dnssec: check automatic rectify is done"""
+        name, _, _ = self.create_zone(dnssec=True)
+        rrsets = [
+            {'name': name, 'type': 'SOA', 'ttl': 3600, 'records': [{'content': 'invalid. hostmaster.invalid. 1 10800 3600 604800 3600'}]},
+            {'name': name, 'type': 'NS', 'ttl': 3600, 'records': [{'content': 'ns1.example.org.'}, {'content': 'ns2.example.org.'}]},
+            {'name': 'www.' + name, 'type': 'A', 'ttl': 3600, 'records': [{'content': '192.0.2.1'}]},
+        ]
+        self.put_zone(name, {'rrsets': rrsets})
+
+        if not is_auth_lmdb():
+            # lmdb: skip, no get_db_records implementations
+            dbrecs = get_db_records(name, 'A')
+            assert dbrecs[0]['ordername'] is not None  # default = rectify enabled
+
+    def test_zone_replace_rrsets_with_soa_edit(self):
+        """SOA-EDIT was enabled before rrsets will be replaced"""
+        name, _, _ = self.create_zone(soa_edit='INCEPTION-INCREMENT', soa_edit_api='SOA-EDIT-INCREASE')
+        rrsets = [
+            {'name': name, 'type': 'SOA', 'ttl': 3600, 'records': [{'content': 'invalid. hostmaster.invalid. 1 10800 3600 604800 3600'}]},
+            {'name': name, 'type': 'NS', 'ttl': 3600, 'records': [{'content': 'ns1.example.org.'}, {'content': 'ns2.example.org.'}]},
+            {'name': 'www.' + name, 'type': 'A', 'ttl': 3600, 'records': [{'content': '192.0.2.1'}]},
+            {'name': 'sub.' + name, 'type': 'NS', 'ttl': 3600, 'records': [{'content': 'ns1.example.org.'}]},
+        ]
+        self.put_zone(name, {'rrsets': rrsets})
+
+        data = self.get_zone(name)
+        soa = [rrset['records'][0]['content'] for rrset in data['rrsets'] if rrset['type'] == 'SOA'][0]
+        assert int(soa.split()[2]) > 1  # serial is larger than what we sent
+
+    def test_zone_replace_rrsets_no_soa_primary(self):
+        """Replace all RRsets but supply no SOA. Should fail."""
+        name, _, _ = self.create_zone()
+        rrsets = [
+            {'name': name, 'type': 'NS', 'ttl': 3600, 'records': [{'content': 'ns1.example.org.'}, {'content': 'ns2.example.org.'}]}
+        ]
+        self.put_zone(name, {'rrsets': rrsets}, expect_error='Must give SOA record for zone when replacing all RR sets')
+
+    def test_zone_replace_rrsets_no_soa_secondary(self):
+        """Replace all RRsets in a SECONDARY zone, but supply no SOA. Should still fail."""
+        name, _, _ = self.create_zone(kind='Secondary', nameservers=None, masters=['127.0.0.2'])
+        self.put_zone(name, {'rrsets': []}, expect_error='Must give SOA record for zone when replacing all RR sets')
+
 
 @unittest.skipIf(not is_auth(), "Not applicable")
 class AuthRootZone(ApiTestCase, AuthZonesHelperMixin):