From: Chris Hofstaedtler Date: Wed, 12 Oct 2022 12:22:42 +0000 (+0200) Subject: API Auth: replace zone contents X-Git-Tag: rec-5.0.0-alpha1~24^2~18 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=70f1db7c228e9ae8ca2b8aa1a250a0e6fc56b49e;p=thirdparty%2Fpdns.git API Auth: replace zone contents In PUT on a specific zone it is now possible to set "rrsets", like in POST. --- diff --git a/pdns/ws-auth.cc b/pdns/ws-auth.cc index af30c9a0a3..fc8eaca2c8 100644 --- a/pdns/ws-auth.cc +++ b/pdns/ws-auth.cc @@ -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 new_records; + vector 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 = ""; diff --git a/regression-tests.api/test_Zones.py b/regression-tests.api/test_Zones.py index 2f5aa08815..050eef9ab9 100644 --- a/regression-tests.api/test_Zones.py +++ b/regression-tests.api/test_Zones.py @@ -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):