]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
New API zone patch feature: individual record add/delete (not rrset).
authorMiod Vallat <miod.vallat@powerdns.com>
Wed, 3 Dec 2025 10:53:00 +0000 (11:53 +0100)
committerMiod Vallat <miod.vallat@powerdns.com>
Thu, 4 Dec 2025 11:58:43 +0000 (12:58 +0100)
Signed-off-by: Miod Vallat <miod.vallat@powerdns.com>
docs/http-api/swagger/authoritative-api-swagger.yaml
docs/http-api/zone.rst
pdns/ws-auth.cc

index df1da4b79a54be5e1ca20af195e18a3efa2809b0..c8026e68cf1d83c016d3228033f15870748ad8c9 100644 (file)
@@ -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'
 
index f9d2c7e2ccb7429b58431ba44724f4b7d1e9da94..99309e2a5cd88dfd2d5e23e496f3f0afd2383e2c 100644 (file)
@@ -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
index 74741f184dbf05fabf8eb927865469b7da09a3d6..0d93f1774a9ebae28ce5abc050c6064976b8ae33 100644 (file)
@@ -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<DNSResourceRecord> 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<int>(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<DNSResourceRecord> 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<Json>& 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<std::tuple<DNSName, QType, changeType>> 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;
       }
     }