From: Miod Vallat Date: Wed, 3 Dec 2025 10:53:00 +0000 (+0100) Subject: New API zone patch feature: individual record add/delete (not rrset). X-Git-Tag: rec-5.4.0-alpha1~21^2~7 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=acd2c6b5421e927e96ab55eb68bf7a4bf082056b;p=thirdparty%2Fpdns.git New API zone patch feature: individual record add/delete (not rrset). Signed-off-by: Miod Vallat --- diff --git a/docs/http-api/swagger/authoritative-api-swagger.yaml b/docs/http-api/swagger/authoritative-api-swagger.yaml index df1da4b79a..c8026e68cf 100644 --- a/docs/http-api/swagger/authoritative-api-swagger.yaml +++ b/docs/http-api/swagger/authoritative-api-swagger.yaml @@ -1265,15 +1265,15 @@ definitions: description: 'DNS TTL of the records, in seconds. MUST NOT be included when changetype is set to “DELETE”.' changetype: type: string - description: 'MUST be added when updating the RRSet. Must be REPLACE or DELETE. With DELETE, all existing RRs matching name and type will be deleted, including all comments. With REPLACE: when records is present, all existing RRs matching name and type will be deleted, and then new records given in records will be created. If no records are left, any existing comments will be deleted as well. When comments is present, all existing comments for the RRs matching name and type will be deleted, and then new comments given in comments will be created.' + description: 'MUST be added when updating the RRSet. Must be one of DELETE, EXTEND, PRUNE or REPLACE. With DELETE, all existing RRs matching name and type will be deleted, including all comments. With EXTEND, only a single record shall be present, and it will be added to the RRSet if not already present. With PRUNE, only a single record shall be present, and it will be deleted from the RRSet if present. With REPLACE: when records is present, all existing RRs matching name and type will be deleted, and then new records given in records will be created. If no records are left, any existing comments will be deleted as well. When comments is present, all existing comments for the RRs matching name and type will be deleted, and then new comments given in comments will be created.' records: type: array - description: 'All records in this RRSet. When updating Records, this is the list of new records (replacing the old ones). Must be empty when changetype is set to DELETE. An empty list results in deletion of all records (and comments).' + description: 'All records in this RRSet. When updating Records, this is the list of new records (replacing the old ones). Must be empty when changetype is set to DELETE, and must contain only one element when changetype is set to EXTEND or PRUNE. An empty list results in deletion of all records (and comments).' items: $ref: '#/definitions/Record' comments: type: array - description: 'List of Comment. Must be empty when changetype is set to DELETE. An empty list results in deletion of all comments. modified_at is optional and defaults to the current server time.' + description: 'List of Comment. Must be empty when changetype is set to DELETE, EXTEND or PRUNE. An empty list results in deletion of all comments. modified_at is optional and defaults to the current server time.' items: $ref: '#/definitions/Comment' diff --git a/docs/http-api/zone.rst b/docs/http-api/zone.rst index f9d2c7e2cc..99309e2a5c 100644 --- a/docs/http-api/zone.rst +++ b/docs/http-api/zone.rst @@ -134,6 +134,25 @@ Will yield a response similar to this (several headers omitted): HTTP/1.1 204 No Content +Adding a single record to a RRset +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: http + + PATCH /api/v1/servers/localhost/zones/example.org. HTTP/1.1 + X-API-Key: secret + Content-Type: application/json + + {"rrsets": [{"name": "test.example.org.", "type": "TXT", "changetype": "EXTEND", "records": [{"content": "the contents of the records to add", "disabled": false}]}]} + +Will yield a response similar to this (several headers omitted): + +.. code-block:: http + + HTTP/1.1 204 No Content + +If a record with the same exact content already exists in the RRSet, no action is performed and no error is returned. + Deleting a RRset ^^^^^^^^^^^^^^^^^^ @@ -147,6 +166,25 @@ Deleting a RRset Will yield a response similar to this (several headers omitted): +.. code-block:: http + + HTTP/1.1 204 No Content + +If no record with the same exact content exist in the RRSet, no action is performed and no error is returned. + +Deleting a single record from a RRset +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: http + + PATCH /api/v1/servers/localhost/zones/example.org. HTTP/1.1 + X-API-Key: secret + Content-Type: application/json + + {"rrsets": [{"name": "test.example.org.", "type": "TXT", "changetype": "PRUNE", "records": [{"content": "the contents of the records to delete"}]}]} + +Will yield a response similar to this (several headers omitted): + .. code-block:: http HTTP/1.1 204 No Content diff --git a/pdns/ws-auth.cc b/pdns/ws-auth.cc index 74741f184d..0d93f1774a 100644 --- a/pdns/ws-auth.cc +++ b/pdns/ws-auth.cc @@ -2408,6 +2408,8 @@ enum changeType { DELETE, // delete complete RRset REPLACE, // replace complete RRset + PRUNE, // remove single record from RRset if found + EXTEND // add single record to RRset if not found }; // Validate the "changetype" field of a Json patch record. @@ -2421,6 +2423,12 @@ static changeType validateChangeType(const std::string& changetype) if (changetype == "REPLACE") { return REPLACE; } + if (changetype == "PRUNE") { + return PRUNE; + } + if (changetype == "EXTEND") { + return EXTEND; + } throw ApiException("Changetype '" + changetype + "' is not a valid value"); } @@ -2466,6 +2474,24 @@ static void replaceZoneRecords(const DomainInfo& domainInfo, const ZoneName& zon } } +// Check that two changetype values are compatible with each other. +// Throws if they aren't. +static void checkChangetypeCompatibility(changeType lastOperationType, changeType thisOperationType, const std::string thisChangetype) +{ + // DELETE and REPLACE operations are not compatible with PRUNE and + // EXTEND operations. + bool lastOperationOperatedOnRRsets = lastOperationType == DELETE || lastOperationType == REPLACE; + bool thisOperationOperatesOnRRsets = thisOperationType == DELETE || thisOperationType == REPLACE; + if (lastOperationOperatedOnRRsets ^ thisOperationOperatesOnRRsets) { + throw ApiException("Mixing RRset operations with single-record operations is not allowed"); + } + // Moreover, we currently only allow a single rrset for PRUNE and + // EXTEND. + if (thisOperationType == PRUNE || thisOperationType == EXTEND) { + throw ApiException("Only one rrset may be provided for " + thisChangetype + " changetype"); + } +} + // Parse the record name and type from a Json patch record. static void parseRecordNameAndType(const Json& rrset, DNSName& qname, QType& qtype) { @@ -2549,6 +2575,77 @@ static bool applyReplace(const DomainInfo& domainInfo, const ZoneName& zonename, return true; } +static bool applyPruneOrExtend(const DomainInfo& domainInfo, const ZoneName& zonename, const Json& container, DNSName& qname, QType& qtype, bool allowUnderscores, soaEditSettings& soa, HttpResponse* resp, changeType operationType) +{ + if (!container["records"].is_array()) { + throw ApiException("No record provided for PRUNE or EXTEND operation"); + } + + try { + vector new_records; + uint32_t ttl = uintFromJson(container, "ttl"); + gatherRecords(container, qname, qtype, ttl, new_records); + if (new_records.size() != 1) { + throw ApiException("Exactly one record should be provided for PRUNE or EXTEND operation"); + } + + auto& new_record = new_records.front(); + new_record.domain_id = static_cast(domainInfo.id); + if (new_record.qtype.getCode() == QType::SOA && new_record.qname == zonename.operator const DNSName&()) { + soa.edit_done = increaseSOARecord(new_record, soa.edit_api_kind, soa.edit_kind, zonename); + } + + if (!checkNewRecords(resp, new_records, zonename, allowUnderscores)) { + // Proper error response has been setup, no need to do anything further. + return false; + } + + // Fetch the existing RRSet + bool seenRecord{false}; + DNSResourceRecord record; + vector rrset; + domainInfo.backend->lookup(qtype, qname, domainInfo.id); + while (domainInfo.backend->get(record)) { + if (record.content == new_record.content) { + // We found the record we've been instructed to add or delete. + seenRecord = true; + // If it is to be added, we don't have anything more to do. + // If it is to be deleted, just omit it from the RRset we're building. + if (operationType == EXTEND) { + domainInfo.backend->lookupEnd(); + break; + } + } + else { + rrset.emplace_back(record); + } + } + // Add new record to RRset if not found. + if (operationType == EXTEND && !seenRecord) { + rrset.emplace_back(new_record); + } + bool submitChanges = (operationType == EXTEND && !seenRecord) || (operationType == PRUNE && seenRecord); + if (submitChanges) { + if (!domainInfo.backend->replaceRRSet(domainInfo.id, qname, qtype, rrset)) { + throw ApiException("Hosting backend does not support editing records."); + } + } + else { + resp->body = ""; + resp->status = 204; // No Content, but indicate success + // This will force our caller to abort the transaction and return quickly, + // without increasing the zone serial number and flushing caches. + // This is safe to do as we do not allow more than one PRUNE or EXTEND + // operation, so there is no further zone changes to process. + return false; + } + } + catch (const JsonException& e) { + throw ApiException("Submitted record is invalid: " + string(e.what())); + } + return true; +} + static void patchZone(UeberBackend& backend, const ZoneName& zonename, DomainInfo& domainInfo, const vector& rrsets, HttpResponse* resp) { domainInfo.backend->startTransaction(zonename); @@ -2559,9 +2656,18 @@ static void patchZone(UeberBackend& backend, const ZoneName& zonename, DomainInf bool allowUnderscores = areUnderscoresAllowed(zonename, *domainInfo.backend); set> seen; + bool firstPass{true}; + changeType lastOperationType{DELETE}; // have to initialize with something... for (const auto& rrset : rrsets) { string changetype = toUpper(stringFromJson(rrset, "changetype")); auto operationType = validateChangeType(changetype); + if (firstPass) { + firstPass = false; + } + else { + checkChangetypeCompatibility(lastOperationType, operationType, changetype); + } + lastOperationType = operationType; // for next pass DNSName qname; QType qtype; parseRecordNameAndType(rrset, qname, qtype); @@ -2584,17 +2690,23 @@ static void patchZone(UeberBackend& backend, const ZoneName& zonename, DomainInf } } + bool abortPatch{false}; switch (operationType) { case DELETE: applyDelete(domainInfo, qname, qtype); break; case REPLACE: - if (!applyReplace(domainInfo, zonename, rrset, qname, qtype, allowUnderscores, soa, resp)) { - // Proper error response has been setup, no need to do anything further. - domainInfo.backend->abortTransaction(); - return; - } + abortPatch = !applyReplace(domainInfo, zonename, rrset, qname, qtype, allowUnderscores, soa, resp); break; + case PRUNE: + case EXTEND: + abortPatch = !applyPruneOrExtend(domainInfo, zonename, rrset, qname, qtype, allowUnderscores, soa, resp, operationType); + break; + } + if (abortPatch) { + // Proper error response has been setup, no need to do anything further. + domainInfo.backend->abortTransaction(); + return; } }