]> granicus.if.org Git - pdns/commitdiff
API: dot correctness
authorChristian Hofstaedtler <christian.hofstaedtler@deduktiva.com>
Fri, 4 Dec 2015 19:28:16 +0000 (20:28 +0100)
committerChristian Hofstaedtler <christian.hofstaedtler@deduktiva.com>
Sun, 6 Dec 2015 14:57:31 +0000 (15:57 +0100)
From and to API consumers we'll now always require/send names (and
content) with dots. To the backend, we'll always require/send without
dots. Some API tests now check the data written to the sqlite DB, too.

Incoming names are now checked against a restricted list of chars,
fixing #1437. The double dot case is taken care of by DNSName (and
we'll no longer report an ISE if DNSName parsing fails - we make
sure to parse all names in a try/except).

Cleanup leftovers from pre-DNSName times.

Turn auth api tests back on in travis.

.travis.yml
docs/markdown/httpapi/api_spec.md
pdns/json.cc
pdns/json.hh
pdns/ws-api.cc
pdns/ws-api.hh
pdns/ws-auth.cc
pdns/ws-recursor.cc
regression-tests.api/runtests.py
regression-tests.api/test_Zones.py
regression-tests.api/test_helper.py

index 9839895aaed4a69baab437c91ed9a706383313f9..7c35045baf8f3c70fbe7f483e776e65a6da25ffd 100644 (file)
@@ -288,7 +288,7 @@ script:
 
   ### api ###
   - cd regression-tests.api
-  #DNSName: - ./runtests authoritative
+  - ./runtests authoritative
   #DNSName: - ./runtests recursor
   - cd ..
 
index 5ef13952575beda03a5f0acdad6f2f8a31a1191c..29c1e9aca8533c10eb4a62309b8cb52072b32c05 100644 (file)
@@ -315,6 +315,10 @@ zone_collection
   Opaque zone id (string), assigned by the Server. Do not interpret.
   Guaranteed to be safe for embedding in URLs.
 
+* `name`
+  Zone name, always including the trailing dot. Example: `example.org.`
+  Note: Before 4.0.0, zone names were taken/given without the trailing dot.
+
 * `kind`
   Authoritative: `<kind>`: `Native`, `Master` or `Slave`
   Recursor: `<kind>`: `Native`, or `Forwarded`
@@ -351,7 +355,9 @@ zone_collection
   **Note**: Authoritative only.
 
 * `nameservers` MAY be sent in client bodies during creation, and MUST
-  NOT be sent by the server. Simple list of strings of nameserver names.
+  NOT be sent by the server. Simple list of strings of nameserver names,
+  including the trailing dot. Note: Before 4.0.0, names were taken without
+  the trailing dot.
   **Note**: Authoritative only. Not required for slave zones.
 
 * `servers`: list of forwarded-to servers, including port.
@@ -460,7 +466,7 @@ Client body for PATCH:
 Having `type` inside an RR differ from `type` at the RRset level is an error.
 
 * `name`
-  Full name of the RRset to modify. (Example: `foo.example.org`)
+  Full name of the RRset to modify. (Example: `foo.example.org.`)
 
 * `type`
   Type of the RRset to modify. (Example: `AAAA`)
@@ -505,7 +511,7 @@ Allowed methods: `PUT`
 
 Send a DNS NOTIFY to all slaves.
 
-Fails when zone kind is not `Master` or `Slave`, or `master` and `slave` are 
+Fails when zone kind is not `Master` or `Slave`, or `master` and `slave` are
 disabled in pdns configuration. Only works for `Slave` if renotify is on.
 
 Not supported for recursors.
@@ -860,7 +866,7 @@ override\_type
       "type": "Override",
       "id": <int>,
       "override": "replace",
-      "domain": "www.cnn.com",
+      "domain": "www.cnn.com.",
       "rrtype": "AAAA",
       "values": ["203.0.113.4", "203.0.113..2"],
       "until": <timestamp>,
@@ -873,7 +879,7 @@ override\_type
       "type": "Override",
       "id": <int>,
       "override": "purge",
-      "domain": "example.net",
+      "domain": "example.net.",
       "created": <timestamp>
     }
 
index 2124f8d323372f972166554aa9eaabff2e4273e9..f898bb88d755855708babe96069ed6408783c4b8 100644 (file)
@@ -90,6 +90,27 @@ string stringFromJson(const Value& container, const char* key, const string& def
   }
 }
 
+DNSName dnsnameFromJson(const rapidjson::Value& container, const char* key)
+{
+  if (!container.IsObject()) {
+    throw JsonException("Container was not an object.");
+  }
+  const Value& val = container[key];
+  if (val.IsString()) {
+    string name = val.GetString();
+    if (!isCanonical(name)) {
+      throw JsonException("DNS Name '" + name + "' is not canonical");
+    }
+    try {
+      return DNSName(name);
+    } catch (...) {
+      throw JsonException("Unable to parse DNS Name '" + name + "'");
+    }
+  } else {
+    throw JsonException("Key '" + string(key) + "' not present or not a String");
+  }
+}
+
 bool boolFromJson(const rapidjson::Value& container, const char* key)
 {
   if (!container.IsObject()) {
index 774631d8e0bb2b4cd3ab1480010452594e3d316f..da6ca5c89481f926391f50cdb695e659e4e47217 100644 (file)
@@ -25,6 +25,7 @@
 #include <map>
 #include <stdexcept>
 #include "rapidjson/document.h"
+#include "dnsname.hh"
 
 std::string returnJsonObject(const std::map<std::string, std::string>& items);
 std::string returnJsonError(const std::string& error);
@@ -34,6 +35,7 @@ int intFromJson(const rapidjson::Value& container, const char* key);
 int intFromJson(const rapidjson::Value& container, const char* key, const int default_value);
 std::string stringFromJson(const rapidjson::Value& container, const char* key);
 std::string stringFromJson(const rapidjson::Value& container, const char* key, const std::string& default_value);
+DNSName dnsnameFromJson(const rapidjson::Value& container, const char* key);
 bool boolFromJson(const rapidjson::Value& container, const char* key);
 bool boolFromJson(const rapidjson::Value& container, const char* key, const bool default_value);
 
index 0d64d6782ea9b523a0dcd9f8a56bee9387ec9ad5..54a62a1b342e6f5f825e2e638ba99a579b0ff7ae 100644 (file)
@@ -261,7 +261,11 @@ DNSName apiZoneIdToName(const string& id) {
 
   zonename = ss.str();
 
-  return DNSName(zonename);
+  try {
+    return DNSName(zonename);
+  } catch (...) {
+    throw ApiException("Unable to parse DNS Name '" + zonename + "'");
+  }
 }
 
 string apiZoneNameToId(const DNSName& dname) {
@@ -293,3 +297,8 @@ string apiZoneNameToId(const DNSName& dname) {
   }
   return id;
 }
+
+void apiCheckNameAllowedCharacters(const string& label) {
+  if (label.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890_/.-") != std::string::npos)
+    throw ApiException("Label '"+label+"' contains unsupported characters");
+}
index 7c070636b6b9ac559c694798ca6bfdcf23cfc05c..6ae4c78fb1ee4c68a0f95a3f796c7b39996df958 100644 (file)
@@ -37,6 +37,7 @@ void apiServerStatistics(HttpRequest* req, HttpResponse* resp);
 // helpers
 DNSName apiZoneIdToName(const string& id);
 string apiZoneNameToId(const DNSName& name);
+void apiCheckNameAllowedCharacters(const string& label);
 
 // To be provided by product code.
 void productServerStatisticsFetch(std::map<string,string>& out);
index 641cde8e940872b3a23e8c8ea804d2f92a0fa726..32590e3fd09ab7e0a92d9d49ae7993f60e5fcdc1 100644 (file)
@@ -247,7 +247,7 @@ void AuthWebServer::indexfunction(HttpRequest* req, HttpResponse* resp)
     d_queries.get5()<<", "<<
     d_queries.get10()<<". Max queries/second: "<<d_queries.getMax()<<
     "<br>"<<endl;
-  
+
   if(d_cachemisses.get10()+d_cachehits.get10()>0)
     ret<<"Cache hitrate, 1, 5, 10 minute averages: "<<
       makePercentage((d_cachehits.get1()*100.0)/((d_cachehits.get1())+(d_cachemisses.get1())))<<", "<<
@@ -289,6 +289,22 @@ void AuthWebServer::indexfunction(HttpRequest* req, HttpResponse* resp)
   resp->status = 200;
 }
 
+/** Helper to build a record content as needed. */
+static inline string makeRecordContent(const QType& qtype, const string& content, bool noDot) {
+  // noDot: for backend storage, pass true. for API users, pass false.
+  return DNSRecordContent::mastermake(qtype.getCode(), 1, content)->getZoneRepresentation(noDot);
+}
+
+/** "Normalize" record content for API consumers. */
+static inline string makeApiRecordContent(const QType& qtype, const string& content) {
+  return makeRecordContent(qtype, content, false);
+}
+
+/** "Normalize" record content for backend storage. */
+static inline string makeBackendRecordContent(const QType& qtype, const string& content) {
+  return makeRecordContent(qtype, content, true);
+}
+
 static void fillZoneInfo(const DomainInfo& di, Value& jdi, Document& doc) {
   DNSSECKeeper dk;
   jdi.SetObject();
@@ -349,7 +365,7 @@ static void fillZone(const DNSName& zonename, HttpResponse* resp) {
     object.AddMember("type", jtype, doc.GetAllocator());
     object.AddMember("ttl", rr.ttl, doc.GetAllocator());
     object.AddMember("disabled", rr.disabled, doc.GetAllocator());
-    Value jcontent(rr.content.c_str(), doc.GetAllocator()); // copy
+    Value jcontent(makeApiRecordContent(rr.qtype, rr.content).c_str(), doc.GetAllocator()); // copy
     object.AddMember("content", jcontent, doc.GetAllocator());
     records.PushBack(object, doc.GetAllocator());
   }
@@ -397,9 +413,9 @@ static void gatherRecords(const Value& container, vector<DNSResourceRecord>& new
   if (records.IsArray()) {
     for (SizeType idx = 0; idx < records.Size(); ++idx) {
       const Value& record = records[idx];
-      rr.qname = DNSName(stringFromJson(record, "name"));
+      rr.qname = dnsnameFromJson(record, "name");
       rr.qtype = stringFromJson(record, "type");
-      rr.content = stringFromJson(record, "content");
+      string content = stringFromJson(record, "content");
       rr.auth = 1;
       rr.ttl = intFromJson(record, "ttl");
       rr.disabled = boolFromJson(record, "disabled");
@@ -408,24 +424,26 @@ static void gatherRecords(const Value& container, vector<DNSResourceRecord>& new
         throw ApiException("Record "+rr.qname.toString()+"/"+stringFromJson(record, "type")+" is of unknown type");
       }
 
+      // validate that the client sent something we can actually parse, and require that data to be dotted.
       try {
-        shared_ptr<DNSRecordContent> drc(DNSRecordContent::mastermake(rr.qtype.getCode(), 1, rr.content));
-        string tmp = drc->serialize(rr.qname);
+        //shared_ptr<DNSRecordContent> drc(DNSRecordContent::mastermake(rr.qtype.getCode(), 1, content));
+        //string tmp = drc->serialize(rr.qname);
         if (rr.qtype.getCode() != QType::AAAA) {
-          tmp = drc->getZoneRepresentation();
-          if (!pdns_iequals(tmp, rr.content)) {
+          string tmp = makeApiRecordContent(rr.qtype, content);
+          if (!pdns_iequals(tmp, content)) {
             throw std::runtime_error("Not in expected format (parsed as '"+tmp+"')");
           }
         } else {
           struct in6_addr tmpbuf;
-          if (inet_pton(AF_INET6, rr.content.c_str(), &tmpbuf) != 1 || rr.content.find('.') != string::npos) {
+          if (inet_pton(AF_INET6, content.c_str(), &tmpbuf) != 1 || content.find('.') != string::npos) {
             throw std::runtime_error("Invalid IPv6 address");
           }
         }
+        rr.content = makeBackendRecordContent(rr.qtype, content);
       }
       catch(std::exception& e)
       {
-        throw ApiException("Record "+rr.qname.toString()+"/"+rr.qtype.getName()+" '"+rr.content+"': "+e.what());
+        throw ApiException("Record "+rr.qname.toString()+"/"+rr.qtype.getName()+" '"+content+"': "+e.what());
       }
 
       if ((rr.qtype.getCode() == QType::A || rr.qtype.getCode() == QType::AAAA) &&
@@ -588,7 +606,6 @@ static void gatherRecordsFromZone(const Value &container, vector<DNSResourceReco
       if(rr.qtype.getCode() == QType::SOA)
         seenSOA=true;
 
-      // rr.qname = stripDot(rr.qname);
       new_records.push_back(rr);
     }
   }
@@ -604,19 +621,14 @@ static void apiServerZones(HttpRequest* req, HttpResponse* resp) {
     DomainInfo di;
     Document document;
     req->json(document);
-    string zonename = stringFromJson(document, "name");
-    DNSName dzonename(zonename);
-
-    // strip trailing dot (from spec PoV this is wrong, but be nice to clients)
-    if (zonename.size() > 0 && zonename.substr(zonename.size()-1) == ".") {
-      zonename.resize(zonename.size()-1);
-    }
+    DNSName zonename = dnsnameFromJson(document, "name");
+    apiCheckNameAllowedCharacters(zonename.toString());
 
     string zonestring = stringFromJson(document, "zone", "");
 
-    bool exists = B.getDomainInfo(dzonename, di);
+    bool exists = B.getDomainInfo(zonename, di);
     if(exists)
-      throw ApiException("Domain '"+zonename+"' already exists");
+      throw ApiException("Domain '"+zonename.toString()+"' already exists");
 
     // validate 'kind' is set
     DomainInfo::DomainKind zonekind = DomainInfo::stringToKind(stringFromJson(document, "kind"));
@@ -649,49 +661,44 @@ static void apiServerZones(HttpRequest* req, HttpResponse* resp) {
     if (records.IsArray()) {
       gatherRecords(document, new_records, new_ptrs);
     } else if (zonestring != "") {
-      gatherRecordsFromZone(document, new_records, DNSName(zonename));
+      gatherRecordsFromZone(document, new_records, zonename);
     }
 
     gatherComments(document, new_comments, false);
 
     for(auto& rr :  new_records) {
-      if (!rr.qname.isPartOf(dzonename) && rr.qname != dzonename)
+      if (!rr.qname.isPartOf(zonename) && rr.qname != zonename)
         throw ApiException("RRset "+rr.qname.toString()+" IN "+rr.qtype.getName()+": Name is out of zone");
+      apiCheckNameAllowedCharacters(rr.qname.toString());
 
-      if (rr.qtype.getCode() == QType::SOA && rr.qname==dzonename) {
+      if (rr.qtype.getCode() == QType::SOA && rr.qname==zonename) {
         have_soa = true;
         increaseSOARecord(rr, soa_edit_api_kind, soa_edit_kind);
+        // fixup dots after serializeSOAData/increaseSOARecord
+        rr.content = makeBackendRecordContent(rr.qtype, rr.content);
       }
     }
 
     // synthesize RRs as needed
     DNSResourceRecord autorr;
-    autorr.qname = dzonename;
+    autorr.qname = zonename;
     autorr.auth = 1;
     autorr.ttl = ::arg().asNum("default-ttl");
 
     if (!have_soa && zonekind != DomainInfo::Slave) {
       // synthesize a SOA record so the zone "really" exists
-
+      string soa = (boost::format("%s %s %lu")
+        % ::arg()["default-soa-name"]
+        % (::arg().isEmpty("default-soa-mail") ? (DNSName("hostmaster.") + zonename).toString() : ::arg()["default-soa-mail"])
+        % intFromJson(document, "serial", 0)
+      ).str();
       SOAData sd;
-      sd.qname = dzonename;
-      sd.nameserver = DNSName(arg()["default-soa-name"]);
-      if (!arg().isEmpty("default-soa-mail")) {
-        sd.hostmaster = DNSName(arg()["default-soa-mail"]); // needs attodot?
-        // attodot(sd.hostmaster); FIXME400
-      } else {
-        sd.hostmaster = DNSName("hostmaster.") + dzonename;
-      }
-      sd.serial = intFromJson(document, "serial", 0);
-      sd.ttl = autorr.ttl;
-      sd.refresh = ::arg().asNum("soa-refresh-default");
-      sd.retry = ::arg().asNum("soa-retry-default");
-      sd.expire = ::arg().asNum("soa-expire-default");
-      sd.default_ttl = ::arg().asNum("soa-minimum-ttl");
-
-      autorr.content = serializeSOAData(sd);
+      fillSOAData(soa, sd);  // fills out default values for us
       autorr.qtype = "SOA";
+      autorr.content = serializeSOAData(sd);
       increaseSOARecord(autorr, soa_edit_api_kind, soa_edit_kind);
+      // fixup dots after serializeSOAData/increaseSOARecord
+      autorr.content = makeBackendRecordContent(autorr.qtype, autorr.content);
       new_records.push_back(autorr);
     }
 
@@ -700,35 +707,43 @@ static void apiServerZones(HttpRequest* req, HttpResponse* resp) {
       for (SizeType i = 0; i < nameservers.Size(); ++i) {
         if (!nameservers[i].IsString())
           throw ApiException("Nameservers must be strings");
-        autorr.content = nameservers[i].GetString();
+        string nameserver = nameservers[i].GetString();
+        if (!isCanonical(nameserver))
+          throw ApiException("Nameserver is not canonical: '" + nameserver + "'");
+        try {
+          // ensure the name parses
+          autorr.content = DNSName(nameserver).toStringNoDot();
+        } catch (...) {
+          throw ApiException("Unable to parse DNS Name for NS '" + nameserver + "'");
+        }
         autorr.qtype = "NS";
         new_records.push_back(autorr);
       }
     }
 
     // no going back after this
-    if(!B.createDomain(dzonename))
-      throw ApiException("Creating domain '"+zonename+"' failed");
+    if(!B.createDomain(zonename))
+      throw ApiException("Creating domain '"+zonename.toString()+"' failed");
 
-    if(!B.getDomainInfo(dzonename, di))
-      throw ApiException("Creating domain '"+zonename+"' failed: lookup of domain ID failed");
+    if(!B.getDomainInfo(zonename, di))
+      throw ApiException("Creating domain '"+zonename.toString()+"' failed: lookup of domain ID failed");
 
-    di.backend->startTransaction(dzonename, di.id);
+    di.backend->startTransaction(zonename, di.id);
 
     for(auto rr : new_records) {
       rr.domain_id = di.id;
       di.backend->feedRecord(rr);
     }
-    for(Comment& c :  new_comments) {
+    for(Comment& c : new_comments) {
       c.domain_id = di.id;
       di.backend->feedComment(c);
     }
 
-    updateDomainSettingsFromDocument(di, dzonename, document);
+    updateDomainSettingsFromDocument(di, zonename, document);
 
     di.backend->commitTransaction();
 
-    fillZone(dzonename, resp);
+    fillZone(zonename, resp);
     resp->status = 201;
     return;
   }
@@ -793,16 +808,6 @@ static void apiServerZoneDetail(HttpRequest* req, HttpResponse* resp) {
   throw HttpMethodNotAllowedException();
 }
 
-// static string makeDotted(string in) {
-//   if (in.empty()) {
-//     return ".";
-//   }
-//   if (in[in.size()-1] != '.') {
-//     return in + ".";
-//   }
-//   return in;
-// }
-
 static void apiServerZoneExport(HttpRequest* req, HttpResponse* resp) {
   DNSName zonename = apiZoneIdToName(req->parameters["id"]);
 
@@ -823,31 +828,11 @@ static void apiServerZoneExport(HttpRequest* req, HttpResponse* resp) {
     if (!rr.qtype.getCode())
       continue; // skip empty non-terminals
 
-    string content = rr.content;
-
-    switch(rr.qtype.getCode()) {
-    case QType::SOA:
-      fillSOAData(rr.content, sd);
-      /*      sd.nameserver = sd.nameserver.toString();
-             sd.hostmaster = sd.hostmaster.toString(); */ // XXX DNSName pain - these looked like noops?
-      content = serializeSOAData(sd);
-      break;
-    case QType::MX:
-    case QType::SRV:
-    case QType::CNAME:
-    case QType::NS:
-    case QType::AFSDB:
-      content = rr.content;
-      break;
-    default:
-      break;
-    }
-
     ss <<
       rr.qname.toString() << "\t" <<
       rr.ttl << "\t" <<
       rr.qtype.getName() << "\t" <<
-      content <<
+      makeApiRecordContent(rr.qtype, rr.content) <<
       endl;
   }
 
@@ -905,12 +890,12 @@ static void makePtr(const DNSResourceRecord& rr, DNSResourceRecord* ptr) {
     if (!IpToU32(rr.content, &ip)) {
       throw ApiException("PTR: Invalid IP address given");
     }
-    ptr->qname = DNSName((boost::format("%u.%u.%u.%u.in-addr.arpa")
+    ptr->qname = DNSName((boost::format("%u.%u.%u.%u.in-addr.arpa.")
                   % ((ip >> 24) & 0xff)
                   % ((ip >> 16) & 0xff)
                   % ((ip >>  8) & 0xff)
                   % ((ip      ) & 0xff)
-                         ).str());
+                         ).str());
   } else if (rr.qtype.getCode() == QType::AAAA) {
     ComboAddress ca(rr.content);
     char buf[3];
@@ -925,7 +910,7 @@ static void makePtr(const DNSResourceRecord& rr, DNSResourceRecord* ptr) {
     string tmp = ss.str();
     tmp.resize(tmp.size()-1); // remove last dot
     // reverse and append arpa domain
-    ptr->qname = DNSName(string(tmp.rbegin(), tmp.rend())) + DNSName("ip6.arpa");
+    ptr->qname = DNSName(string(tmp.rbegin(), tmp.rend())) + DNSName("ip6.arpa.");
   } else {
     throw ApiException("Unsupported PTR source '" + rr.qname.toString() + "' type '" + rr.qtype.getName() + "'");
   }
@@ -967,7 +952,8 @@ static void patchZone(HttpRequest* req, HttpResponse* resp) {
       const Value& rrset = rrsets[rrsetIdx];
       string changetype;
       QType qtype;
-      DNSName qname(stringFromJson(rrset, "name"));
+      DNSName qname = dnsnameFromJson(rrset, "name");
+      apiCheckNameAllowedCharacters(qname.toString());
       qtype = stringFromJson(rrset, "type");
       changetype = toUpper(stringFromJson(rrset, "changetype"));
 
@@ -978,11 +964,11 @@ static void patchZone(HttpRequest* req, HttpResponse* resp) {
         }
       }
       else if (changetype == "REPLACE") {
-               // we only validate for REPLACE, as DELETE can be used to "fix" out of zone records.
+        // we only validate for REPLACE, as DELETE can be used to "fix" out of zone records.
         if (!qname.isPartOf(zonename) && qname != zonename)
           throw ApiException("RRset "+qname.toString()+" IN "+qtype.getName()+": Name is out of zone");
 
-               new_records.clear();
+        new_records.clear();
         new_comments.clear();
         // new_ptrs is merged
         gatherRecords(rrset, new_records, new_ptrs);
@@ -996,6 +982,7 @@ static void patchZone(HttpRequest* req, HttpResponse* resp) {
 
           if (rr.qtype.getCode() == QType::SOA && rr.qname==zonename) {
             soa_edit_done = increaseSOARecord(rr, soa_edit_api_kind, soa_edit_kind);
+            rr.content = makeBackendRecordContent(rr.qtype, rr.content);
           }
         }
 
@@ -1039,6 +1026,8 @@ static void patchZone(HttpRequest* req, HttpResponse* resp) {
       rr.auth = 1;
       rr.ttl = sd.ttl;
       increaseSOARecord(rr, soa_edit_api_kind, soa_edit_kind);
+      // fixup dots after serializeSOAData/increaseSOARecord
+      rr.content = makeBackendRecordContent(rr.qtype, rr.content);
 
       if (!di.backend->replaceRRSet(di.id, rr.qname, rr.qtype, vector<DNSResourceRecord>(1, rr))) {
         throw ApiException("Hosting backend does not support editing records.");
@@ -1098,8 +1087,8 @@ static void apiServerSearchData(HttpRequest* req, HttpResponse* resp) {
   vector<DomainInfo> domains;
   vector<DNSResourceRecord> result_rr;
   vector<Comment> result_c;
-  map<int,DNSName> zoneIdZone;
-  map<int,DNSName>::iterator val;
+  map<int,DomainInfo> zoneIdZone;
+  map<int,DomainInfo>::iterator val;
   Document doc;
 
   doc.SetArray();
@@ -1107,18 +1096,19 @@ static void apiServerSearchData(HttpRequest* req, HttpResponse* resp) {
   B.getAllDomains(&domains, true);
 
   for(const DomainInfo di: domains)
-  {   
+  {
     if (ents < maxEnts && sm.match(di.zone)) {
       Value object;
       object.SetObject();
+      Value jzoneId(apiZoneNameToId(di.zone).c_str(), doc.GetAllocator()); // copy
       object.AddMember("object_type", "zone", doc.GetAllocator());
-      object.AddMember("zone_id", di.id, doc.GetAllocator());
+      object.AddMember("zone_id", jzoneId, doc.GetAllocator());
       Value jzoneName(di.zone.toString().c_str(), doc.GetAllocator()); // copy
       object.AddMember("name", jzoneName, doc.GetAllocator());
       doc.PushBack(object, doc.GetAllocator());
       ents++;
     }
-    zoneIdZone[di.id] = di.zone; // populate cache
+    zoneIdZone[di.id] = di; // populate cache
   }
 
   if (B.searchRecords(q, maxEnts, result_rr))
@@ -1128,9 +1118,10 @@ static void apiServerSearchData(HttpRequest* req, HttpResponse* resp) {
       Value object;
       object.SetObject();
       object.AddMember("object_type", "record", doc.GetAllocator());
-      object.AddMember("zone_id", rr.domain_id, doc.GetAllocator());
       if ((val = zoneIdZone.find(rr.domain_id)) != zoneIdZone.end()) {
-        Value zname(val->second.toString().c_str(), doc.GetAllocator()); // copy
+        Value jzoneId(apiZoneNameToId(val->second.zone).c_str(), doc.GetAllocator()); // copy
+        object.AddMember("zone_id", jzoneId, doc.GetAllocator());
+        Value zname(val->second.zone.toString().c_str(), doc.GetAllocator()); // copy
         object.AddMember("zone", zname, doc.GetAllocator()); // copy
       }
       Value jname(rr.qname.toString().c_str(), doc.GetAllocator()); // copy
@@ -1139,7 +1130,7 @@ static void apiServerSearchData(HttpRequest* req, HttpResponse* resp) {
       object.AddMember("type", jtype, doc.GetAllocator());
       object.AddMember("ttl", rr.ttl, doc.GetAllocator());
       object.AddMember("disabled", rr.disabled, doc.GetAllocator());
-      Value jcontent(rr.content.c_str(), doc.GetAllocator()); // copy
+      Value jcontent(makeApiRecordContent(rr.qtype, rr.content).c_str(), doc.GetAllocator()); // copy
       object.AddMember("content", jcontent, doc.GetAllocator());
       doc.PushBack(object, doc.GetAllocator());
     }
@@ -1149,13 +1140,13 @@ static void apiServerSearchData(HttpRequest* req, HttpResponse* resp) {
   {
     for(const Comment &c: result_c)
     {
-
       Value object;
       object.SetObject();
       object.AddMember("object_type", "comment", doc.GetAllocator());
-      object.AddMember("zone_id", c.domain_id, doc.GetAllocator());
       if ((val = zoneIdZone.find(c.domain_id)) != zoneIdZone.end()) {
-        Value zname(val->second.toString().c_str(), doc.GetAllocator()); // copy
+        Value jzoneId(apiZoneNameToId(val->second.zone).c_str(), doc.GetAllocator()); // copy
+        object.AddMember("zone_id", jzoneId, doc.GetAllocator());
+        Value zname(val->second.zone.toString().c_str(), doc.GetAllocator()); // copy
         object.AddMember("zone", zname, doc.GetAllocator()); // copy
       }
       Value jname(c.qname.c_str(), doc.GetAllocator()); // copy
index 7b3318f145399c34cb71ecc9d933d7c113114e6f..978e9da1b07dffdb2c0b2be88dc0b80bf288ff54 100644 (file)
@@ -187,10 +187,8 @@ static void doCreateZone(const Value& document)
     throw ApiException("Config Option \"api-config-dir\" must be set");
   }
 
-  if(stringFromJson(document, "name").empty())
-    throw ApiException("Zone name empty");
-  
-  DNSName zonename(stringFromJson(document, "name"));
+  DNSName zonename = dnsnameFromJson(document, "name");
+  apiCheckNameAllowedCharacters(zonename.toString());
 
   string singleIPTarget = stringFromJson(document, "single_target_ip", "");
   string kind = toUpper(stringFromJson(document, "kind"));
@@ -281,7 +279,7 @@ static void apiServerZones(HttpRequest* req, HttpResponse* resp)
     Document document;
     req->json(document);
 
-    DNSName zonename(stringFromJson(document, "name"));
+    DNSName zonename = dnsnameFromJson(document, "name");
 
     auto iter = t_sstorage->domainmap->find(zonename);
     if (iter != t_sstorage->domainmap->end())
@@ -342,7 +340,7 @@ static void apiServerZoneDetail(HttpRequest* req, HttpResponse* resp)
     doDeleteZone(zonename);
     doCreateZone(document);
     reloadAuthAndForwards();
-    fillZone(DNSName(stringFromJson(document, "name")), resp);
+    fillZone(dnsnameFromJson(document, "name"), resp);
   }
   else if(req->method == "DELETE" && !::arg().mustDo("api-readonly")) {
     if (!doDeleteZone(zonename)) {
index 42443e4fa718cc0bbb7b1f05357dec5c56bacf21..8d15b319fc6f4c0fa0a545721b4df4c85f34eb00 100755 (executable)
@@ -118,7 +118,7 @@ print "Running tests..."
 rc = 0
 test_env = {}
 test_env.update(os.environ)
-test_env.update({'WEBPORT': WEBPORT, 'APIKEY': APIKEY, 'DAEMON': daemon})
+test_env.update({'WEBPORT': WEBPORT, 'APIKEY': APIKEY, 'DAEMON': daemon, 'SQLITE_DB': SQLITE_DB})
 
 try:
     print ""
index e7aabbe30d7224fb6f5d2b2d70e8f6c19dabfef2..138fca456df988504e23f3909c43cbda77c7a818 100644 (file)
@@ -1,7 +1,7 @@
 import json
 import time
 import unittest
-from test_helper import ApiTestCase, unique_zone_name, is_auth, is_recursor
+from test_helper import ApiTestCase, unique_zone_name, is_auth, is_recursor, eq_zone_dict, get_db_records
 
 
 class Zones(ApiTestCase):
@@ -29,21 +29,23 @@ class AuthZonesHelperMixin(object):
         payload = {
             'name': name,
             'kind': 'Native',
-            'nameservers': ['ns1.example.com', 'ns2.example.com']
+            'nameservers': ['ns1.example.com.', 'ns2.example.com.']
         }
         for k, v in kwargs.items():
             if v is None:
                 del payload[k]
             else:
                 payload[k] = v
-        print payload
+        print "sending", payload
         r = self.session.post(
             self.url("/api/v1/servers/localhost/zones"),
             data=json.dumps(payload),
             headers={'content-type': 'application/json'})
         self.assert_success_json(r)
         self.assertEquals(r.status_code, 201)
-        return payload, r.json()
+        reply = r.json()
+        print "reply", reply
+        return payload, reply
 
 
 @unittest.skipIf(not is_auth(), "Not applicable")
@@ -58,11 +60,15 @@ class AuthZones(ApiTestCase, AuthZonesHelperMixin):
                 self.assertEquals(data[k], payload[k])
         self.assertEquals(data['comments'], [])
         # validate generated SOA
+        expected_soa = "a.misconfigured.powerdns.server. hostmaster." + payload['name'] + " " + \
+                       str(payload['serial']) + " 10800 3600 604800 3600"
         self.assertEquals(
             [r['content'] for r in data['records'] if r['type'] == 'SOA'][0],
-            "a.misconfigured.powerdns.server hostmaster." + payload['name'] + " " + str(payload['serial']) +
-            " 10800 3600 604800 3600"
+            expected_soa
         )
+        # Because we had confusion about dots, check that the DB is without dots.
+        dbrecs = get_db_records(payload['name'], 'SOA')
+        self.assertEqual(dbrecs[0]['content'], expected_soa.replace('. ', ' '))
 
     def test_create_zone_with_soa_edit_api(self):
         # soa_edit_api wins over serial
@@ -118,36 +124,93 @@ class AuthZones(ApiTestCase, AuthZonesHelperMixin):
         # check our comment has appeared
         self.assertEquals(data['comments'], comments)
 
+    def test_create_zone_uncanonical_nameservers(self):
+        name = unique_zone_name()
+        payload = {
+            'name': name,
+            'kind': 'Native',
+            'nameservers': ['uncanon.example.com']
+        }
+        print payload
+        r = self.session.post(
+            self.url("/api/v1/servers/localhost/zones"),
+            data=json.dumps(payload),
+            headers={'content-type': 'application/json'})
+        self.assertEquals(r.status_code, 422)
+        self.assertIn('Nameserver is not canonical', r.json()['error'])
+
+    def test_create_auth_zone_no_name(self):
+        name = unique_zone_name()
+        payload = {
+            'name': '',
+            'kind': 'Native',
+        }
+        print payload
+        r = self.session.post(
+            self.url("/api/v1/servers/localhost/zones"),
+            data=json.dumps(payload),
+            headers={'content-type': 'application/json'})
+        self.assertEquals(r.status_code, 422)
+        self.assertIn('is not canonical', r.json()['error'])
+
     def test_create_zone_with_custom_soa(self):
         name = unique_zone_name()
         records = [
             {
-                "name": name,
-                'type': 'soa',  # test uppercasing of type, too.
-                "ttl": 3600,
-                "content": "ns1.example.net testmaster@example.net 10 10800 3600 604800 3600",
-                "disabled": False
+                u"name": name,
+                u"type": u"soa",  # test uppercasing of type, too.
+                u"ttl": 3600,
+                u"content": u"ns1.example.net. testmaster@example.net. 10 10800 3600 604800 3600",
+                u"disabled": False
             }
         ]
-        payload, data = self.create_zone(name=name, records=records)
+        payload, data = self.create_zone(name=name, records=records, soa_edit_api='')
         records[0]['type'] = records[0]['type'].upper()
         self.assertEquals([r for r in data['records'] if r['type'] == records[0]['type']], records)
+        dbrecs = get_db_records(name, records[0]['type'])
+        self.assertEqual(dbrecs[0]['content'], records[0]['content'].replace('. ', ' '))
+
+    def test_create_zone_double_dot(self):
+        name = 'test..' + unique_zone_name()
+        payload = {
+            'name': name,
+            'kind': 'Native',
+            'nameservers': ['ns1.example.com.']
+        }
+        print payload
+        r = self.session.post(
+            self.url("/api/v1/servers/localhost/zones"),
+            data=json.dumps(payload),
+            headers={'content-type': 'application/json'})
+        self.assertEquals(r.status_code, 422)
+        self.assertIn('Unable to parse DNS Name', r.json()['error'])
 
-    def test_create_zone_trailing_dot(self):
-        # Trailing dots should not end up in the zone name.
-        basename = unique_zone_name()
-        payload, data = self.create_zone(name=basename+'.')
-        self.assertEquals(data['name'], basename)
+    def test_create_zone_restricted_chars(self):
+        name = 'test:' + unique_zone_name()  # : isn't good as a name.
+        payload = {
+            'name': name,
+            'kind': 'Native',
+            'nameservers': ['ns1.example.com']
+        }
+        print payload
+        r = self.session.post(
+            self.url("/api/v1/servers/localhost/zones"),
+            data=json.dumps(payload),
+            headers={'content-type': 'application/json'})
+        self.assertEquals(r.status_code, 422)
+        self.assertIn('contains unsupported characters', r.json()['error'])
 
     def test_create_zone_with_symbols(self):
         payload, data = self.create_zone(name='foo/bar.'+unique_zone_name())
         name = payload['name']
-        expected_id = (name.replace('/', '=2F')) + '.'
+        expected_id = name.replace('/', '=2F')
         for k in ('id', 'url', 'name', 'masters', 'kind', 'last_check', 'notified_serial', 'serial'):
             self.assertIn(k, data)
             if k in payload:
                 self.assertEquals(data[k], payload[k])
         self.assertEquals(data['id'], expected_id)
+        dbrecs = get_db_records(name, 'SOA')
+        self.assertEqual(dbrecs[0]['name'], name.rstrip('.'))
 
     def test_create_zone_with_nameservers_non_string(self):
         # ensure we don't crash
@@ -214,7 +277,7 @@ class AuthZones(ApiTestCase, AuthZonesHelperMixin):
     def test_get_zone_with_symbols(self):
         payload, data = self.create_zone(name='foo/bar.'+unique_zone_name())
         name = payload['name']
-        zone_id = (name.replace('/', '=2F')) + '.'
+        zone_id = (name.replace('/', '=2F'))
         r = self.session.get(self.url("/api/v1/servers/localhost/zones/" + zone_id))
         data = r.json()
         for k in ('id', 'url', 'name', 'masters', 'kind', 'last_check', 'notified_serial', 'serial', 'dnssec'):
@@ -225,13 +288,13 @@ class AuthZones(ApiTestCase, AuthZonesHelperMixin):
     def test_get_zone(self):
         r = self.session.get(self.url("/api/v1/servers/localhost/zones"))
         domains = r.json()
-        example_com = [domain for domain in domains if domain['name'] == u'example.com'][0]
+        example_com = [domain for domain in domains if domain['name'] == u'example.com.'][0]
         r = self.session.get(self.url("/api/v1/servers/localhost/zones/" + example_com['id']))
         self.assert_success_json(r)
         data = r.json()
         for k in ('id', 'url', 'name', 'masters', 'kind', 'last_check', 'notified_serial', 'serial'):
             self.assertIn(k, data)
-        self.assertEquals(data['name'], 'example.com')
+        self.assertEquals(data['name'], 'example.com.')
 
     def test_import_zone_broken(self):
         payload = {}
@@ -254,7 +317,7 @@ powerdns-broken.com.           3600    IN      MX      0 xs.powerdns.com.
 powerdns-broken.com.           3600    IN      NS      powerdnssec1.ds9a.nl.
 powerdns-broken.com.           86400   IN      SOA     powerdnssec1.ds9a.nl. ahu.ds9a.nl. 1343746984 10800 3600 604800 10800
 """
-        payload['name'] = 'powerdns-broken.com'
+        payload['name'] = 'powerdns-broken.com.'
         payload['kind'] = 'Master'
         payload['nameservers'] = []
         r = self.session.post(
@@ -263,6 +326,26 @@ powerdns-broken.com.           86400   IN      SOA     powerdnssec1.ds9a.nl. ahu
             headers={'content-type': 'application/json'})
         self.assertEquals(r.status_code, 422)
 
+    def test_import_zone_axfr_outofzone(self):
+        # Ensure we don't create out-of-zone records
+        name = unique_zone_name()
+        payload = {}
+        payload['zone'] = """
+NAME           86400   IN      SOA     powerdnssec1.ds9a.nl. ahu.ds9a.nl. 1343746984 10800 3600 604800 10800
+NAME           3600    IN      NS      powerdnssec2.ds9a.nl.
+example.org.   3600    IN      AAAA    2001:888:2000:1d::2
+NAME           86400   IN      SOA     powerdnssec1.ds9a.nl. ahu.ds9a.nl. 1343746984 10800 3600 604800 10800
+""".replace('NAME', name)
+        payload['name'] = name
+        payload['kind'] = 'Master'
+        payload['nameservers'] = []
+        r = self.session.post(
+            self.url("/api/v1/servers/localhost/zones"),
+            data=json.dumps(payload),
+            headers={'content-type': 'application/json'})
+        self.assertEquals(r.status_code, 422)
+        self.assertEqual(r.json()['error'], 'RRset example.org. IN AAAA: Name is out of zone')
+
     def test_import_zone_axfr(self):
         payload = {}
         payload['zone'] = """
@@ -284,9 +367,10 @@ powerdns.com.           3600    IN      MX      0 xs.powerdns.com.
 powerdns.com.           3600    IN      NS      powerdnssec1.ds9a.nl.
 powerdns.com.           86400   IN      SOA     powerdnssec1.ds9a.nl. ahu.ds9a.nl. 1343746984 10800 3600 604800 10800
 """
-        payload['name'] = 'powerdns.com'
+        payload['name'] = 'powerdns.com.'
         payload['kind'] = 'Master'
         payload['nameservers'] = []
+        payload['soa_edit_api'] = ''  # turn off so exact SOA comparison works.
         r = self.session.post(
             self.url("/api/v1/servers/localhost/zones"),
             data=json.dumps(payload),
@@ -305,23 +389,16 @@ powerdns.com.           86400   IN      SOA     powerdnssec1.ds9a.nl. ahu.ds9a.n
             'MX': [
                 { 'content': '0 xs.powerdns.com.' } ],
             'A': [
-                { 'content': '82.94.213.34', 'name': 'powerdns.com' } ],
+                { 'content': '82.94.213.34', 'name': 'powerdns.com.' } ],
             'AAAA': [
-                { 'content': '2001:888:2000:1d::2', 'name': 'powerdns.com' } ]
+                { 'content': '2001:888:2000:1d::2', 'name': 'powerdns.com.' } ]
         }
 
-        counter = {}
-        for et in expected.keys():
-            counter[et] = len(expected[et])
-            for ev in expected[et]:
-                for ret in data['records']:
-                    if 'name' in ev:
-                        if ret['name'] == ev['name'] and ret['content'] == ev['content'].rstrip('.'):
-                            counter[et] = counter[et]-1
-                            continue
-                    if ret['content'] == ev['content'].rstrip('.'):
-                        counter[et] = counter[et]-1
-            self.assertEquals(counter[et], 0)
+        eq_zone_dict(data['records'], expected)
+
+        # noDot check
+        dbrecs = get_db_records(payload['name'], 'NS')
+        self.assertEqual(dbrecs[0]['content'], 'powerdnssec2.ds9a.nl')
 
     def test_import_zone_bind(self):
         payload = {}
@@ -340,16 +417,17 @@ $ORIGIN example.org.
        IN  NS     ns2.smokeyjoe.com. ; external to domain
        IN MX  10 mail.another.com. ; external mail provider
 ; server host definitions
-ns1    IN A      192.168.0.1  ;name server definition     
+ns1    IN A      192.168.0.1  ;name server definition
 www    IN  A      192.168.0.2  ;web server definition
 ftp    IN CNAME  www.example.org.  ;ftp server definition
 ; non server domain hosts
 bill   IN  A      192.168.0.3
-fred   IN  A      192.168.0.4 
+fred   IN  A      192.168.0.4
 """
-        payload['name'] = 'example.org'
+        payload['name'] = 'example.org.'
         payload['kind'] = 'Master'
         payload['nameservers'] = []
+        payload['soa_edit_api'] = ''  # turn off so exact SOA comparison works.
         r = self.session.post(
             self.url("/api/v1/servers/localhost/zones"),
             data=json.dumps(payload),
@@ -368,29 +446,18 @@ fred   IN  A      192.168.0.4
             'MX': [
                 { 'content': '10 mail.another.com.' } ],
             'A': [
-                { 'content': '192.168.0.1', 'name': 'ns1.example.org' },
-                { 'content': '192.168.0.2', 'name': 'www.example.org' },
-                { 'content': '192.168.0.3', 'name': 'bill.example.org' },
-                { 'content': '192.168.0.4', 'name': 'fred.example.org' } ],
+                { 'content': '192.168.0.1', 'name': 'ns1.example.org.' },
+                { 'content': '192.168.0.2', 'name': 'www.example.org.' },
+                { 'content': '192.168.0.3', 'name': 'bill.example.org.' },
+                { 'content': '192.168.0.4', 'name': 'fred.example.org.' } ],
             'CNAME': [
-                { 'content': 'www.example.org', 'name': 'ftp.example.org' } ]
+                { 'content': 'www.example.org.', 'name': 'ftp.example.org.' } ]
         }
 
-        counter = {}
-        for et in expected.keys():
-            counter[et] = len(expected[et])
-            for ev in expected[et]:
-                for ret in data['records']:
-                    if 'name' in ev:
-                        if ret['name'] == ev['name'] and ret['content'] == ev['content'].rstrip('.'):
-                            counter[et] = counter[et]-1
-                            continue
-                    if ret['content'] == ev['content'].rstrip('.'):
-                        counter[et] = counter[et]-1
-            self.assertEquals(counter[et], 0)
+        eq_zone_dict(data['records'], expected)
 
     def test_export_zone_json(self):
-        payload, zone = self.create_zone(nameservers=['ns1.foo.com', 'ns2.foo.com'], soa_edit_api='')
+        payload, zone = self.create_zone(nameservers=['ns1.foo.com.', 'ns2.foo.com.'], soa_edit_api='')
         name = payload['name']
         # export it
         r = self.session.get(
@@ -400,14 +467,14 @@ fred   IN  A      192.168.0.4
         self.assert_success_json(r)
         data = r.json()
         self.assertIn('zone', data)
-        expected_data = [name + '.\t3600\tNS\tns1.foo.com.',
-                         name + '.\t3600\tNS\tns2.foo.com.',
-                         name + '.\t3600\tSOA\ta.misconfigured.powerdns.server. hostmaster.' + name +
-                         '. 0 10800 3600 604800 3600']
+        expected_data = [name + '\t3600\tNS\tns1.foo.com.',
+                         name + '\t3600\tNS\tns2.foo.com.',
+                         name + '\t3600\tSOA\ta.misconfigured.powerdns.server. hostmaster.' + name +
+                         ' 0 10800 3600 604800 3600']
         self.assertEquals(data['zone'].strip().split('\n'), expected_data)
 
     def test_export_zone_text(self):
-        payload, zone = self.create_zone(nameservers=['ns1.foo.com', 'ns2.foo.com'], soa_edit_api='')
+        payload, zone = self.create_zone(nameservers=['ns1.foo.com.', 'ns2.foo.com.'], soa_edit_api='')
         name = payload['name']
         # export it
         r = self.session.get(
@@ -415,10 +482,10 @@ fred   IN  A      192.168.0.4
             headers={'accept': '*/*'}
         )
         data = r.text.strip().split("\n")
-        expected_data = [name + '.\t3600\tNS\tns1.foo.com.',
-                         name + '.\t3600\tNS\tns2.foo.com.',
-                         name + '.\t3600\tSOA\ta.misconfigured.powerdns.server. hostmaster.' + name +
-                         '. 0 10800 3600 604800 3600']
+        expected_data = [name + '\t3600\tNS\tns1.foo.com.',
+                         name + '\t3600\tNS\tns2.foo.com.',
+                         name + '\t3600\tSOA\ta.misconfigured.powerdns.server. hostmaster.' + name +
+                         ' 0 10800 3600 604800 3600']
         self.assertEquals(data, expected_data)
 
     def test_update_zone(self):
@@ -469,14 +536,14 @@ fred   IN  A      192.168.0.4
                     "name": name,
                     "type": "NS",
                     "ttl": 3600,
-                    "content": "ns1.bar.com",
+                    "content": "ns1.bar.com.",
                     "disabled": False
                 },
                 {
                     "name": name,
                     "type": "NS",
                     "ttl": 1800,
-                    "content": "ns2-disabled.bar.com",
+                    "content": "ns2-disabled.bar.com.",
                     "disabled": True
                 }
             ]
@@ -491,7 +558,7 @@ fred   IN  A      192.168.0.4
         r = self.session.get(self.url("/api/v1/servers/localhost/zones/" + name))
         rrset['type'] = rrset['type'].upper()
         data = r.json()['records']
-        recs = [rec for rec in data if rec['type'] == rrset['type'] and rec['name'] == rrset['name']]
+        recs = [rec for rec in data if rec['type'].upper() == rrset['type'].upper() and rec['name'].upper() == rrset['name'].upper()]
         self.assertEquals(recs, rrset['records'])
 
     def test_zone_rr_update_mx(self):
@@ -508,7 +575,7 @@ fred   IN  A      192.168.0.4
                     "name": name,
                     "type": "MX",
                     "ttl": 3600,
-                    "content": "10 mail.example.org",
+                    "content": "10 mail.example.org.",
                     "disabled": False
                 }
             ]
@@ -537,7 +604,7 @@ fred   IN  A      192.168.0.4
                     "name": name,
                     "type": "NS",
                     "ttl": 3600,
-                    "content": "ns9999.example.com",
+                    "content": "ns9999.example.com.",
                     "disabled": False
                 }
             ]
@@ -551,7 +618,7 @@ fred   IN  A      192.168.0.4
                     "name": name,
                     "type": "MX",
                     "ttl": 3600,
-                    "content": "10 mx444.example.com",
+                    "content": "10 mx444.example.com.",
                     "disabled": False
                 }
             ]
@@ -605,7 +672,7 @@ fred   IN  A      192.168.0.4
                     "name": name,
                     "type": "SOA",
                     "ttl": 3600,
-                    "content": "ns1.bar.com hostmaster.foo.org 1 1 1 1 1",
+                    "content": "ns1.bar.com. hostmaster.foo.org. 1 1 1 1 1",
                     "disabled": True
                 }
             ]
@@ -653,7 +720,7 @@ fred   IN  A      192.168.0.4
                     "name": name,
                     "type": "NS",
                     "ttl": 3600,
-                    "content": "ns1.bar.com",
+                    "content": "ns1.bar.com.",
                     "disabled": False
                 }
             ]
@@ -678,7 +745,7 @@ fred   IN  A      192.168.0.4
                     "name": 'blah.'+name,
                     "type": "NS",
                     "ttl": 3600,
-                    "content": "ns1.bar.com",
+                    "content": "ns1.bar.com.",
                     "disabled": False
                 }
             ]
@@ -696,14 +763,14 @@ fred   IN  A      192.168.0.4
         # replace with qname mismatch
         rrset = {
             'changetype': 'replace',
-            'name': 'not-in-zone',
+            'name': 'not-in-zone.',
             'type': 'NS',
             'records': [
                 {
                     "name": name,
                     "type": "NS",
                     "ttl": 3600,
-                    "content": "ns1.bar.com",
+                    "content": "ns1.bar.com.",
                     "disabled": False
                 }
             ]
@@ -716,6 +783,32 @@ fred   IN  A      192.168.0.4
         self.assertEquals(r.status_code, 422)
         self.assertIn('out of zone', r.json()['error'])
 
+    def test_zone_rr_update_restricted_chars(self):
+        payload, zone = self.create_zone()
+        name = payload['name']
+        # replace with qname mismatch
+        rrset = {
+            'changetype': 'replace',
+            'name': 'test:' + name,
+            'type': 'NS',
+            'records': [
+                {
+                    "name": 'test:' + name,
+                    "type": "NS",
+                    "ttl": 3600,
+                    "content": "ns1.bar.com.",
+                    "disabled": False
+                }
+            ]
+        }
+        payload = {'rrsets': [rrset]}
+        r = self.session.patch(
+            self.url("/api/v1/servers/localhost/zones/" + name),
+            data=json.dumps(payload),
+            headers={'content-type': 'application/json'})
+        self.assertEquals(r.status_code, 422)
+        self.assertIn('contains unsupported characters', r.json()['error'])
+
     def test_rrset_unknown_type(self):
         payload, zone = self.create_zone()
         name = payload['name']
@@ -768,7 +861,7 @@ fred   IN  A      192.168.0.4
         name = payload['name']
         rrset = {
             'changetype': 'delete',
-            'name': 'not-in-zone',
+            'name': 'not-in-zone.',
             'type': 'NS'
         }
         payload = {'rrsets': [rrset]}
@@ -876,7 +969,7 @@ fred   IN  A      192.168.0.4
                     "name": name,
                     "type": "NS",
                     "ttl": 3600,
-                    "content": "ns1.bar.com",
+                    "content": "ns1.bar.com.",
                     "disabled": False
                 }
             ]
@@ -901,7 +994,7 @@ fred   IN  A      192.168.0.4
         self.assertEquals(data['comments'], rrset['comments'])
 
     def test_zone_auto_ptr_ipv4(self):
-        revzone = '0.2.192.in-addr.arpa'
+        revzone = '0.2.192.in-addr.arpa.'
         self.create_zone(name=revzone)
         payload, zone = self.create_zone()
         name = payload['name']
@@ -936,12 +1029,12 @@ fred   IN  A      192.168.0.4
             u'disabled': False,
             u'ttl': 3600,
             u'type': u'PTR',
-            u'name': u'2.0.2.192.in-addr.arpa'
+            u'name': u'2.0.2.192.in-addr.arpa.'
         }])
 
     def test_zone_auto_ptr_ipv6(self):
         # 2001:DB8::bb:aa
-        revzone = '8.b.d.0.1.0.0.2.ip6.arpa'
+        revzone = '8.b.d.0.1.0.0.2.ip6.arpa.'
         self.create_zone(name=revzone)
         payload, zone = self.create_zone()
         name = payload['name']
@@ -976,19 +1069,30 @@ fred   IN  A      192.168.0.4
             u'disabled': False,
             u'ttl': 3600,
             u'type': u'PTR',
-            u'name': u'a.a.0.0.b.b.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa'
+            u'name': u'a.a.0.0.b.b.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.'
         }])
 
     def test_search_rr_exact_zone(self):
         name = unique_zone_name()
-        self.create_zone(name=name)
-        r = self.session.get(self.url("/api/v1/servers/localhost/search-data?q=" + name))
+        self.create_zone(name=name, serial=22, soa_edit_api='')
+        r = self.session.get(self.url("/api/v1/servers/localhost/search-data?q=" + name.rstrip('.')))
         self.assert_success_json(r)
         print r.json()
-        self.assertEquals(r.json(), [{u'object_type': u'zone', u'name': name, u'zone_id': name+'.'}])
+        self.assertEquals(r.json(), [
+            {u'object_type': u'zone', u'name': name, u'zone_id': name},
+            {u'content': u'a.misconfigured.powerdns.server. hostmaster.'+name+' 22 10800 3600 604800 3600',
+             u'zone_id': name, u'zone': name, u'object_type': u'record', u'disabled': False,
+             u'ttl': 3600, u'type': u'SOA', u'name': name},
+            {u'content': u'ns1.example.com.',
+             u'zone_id': name, u'zone': name, u'object_type': u'record', u'disabled': False,
+             u'ttl': 3600, u'type': u'NS', u'name': name},
+            {u'content': u'ns2.example.com.',
+             u'zone_id': name, u'zone': name, u'object_type': u'record', u'disabled': False,
+             u'ttl': 3600, u'type': u'NS', u'name': name},
+        ])
 
     def test_search_rr_substring(self):
-        name = 'search-rr-zone.name'
+        name = 'search-rr-zone.name.'
         self.create_zone(name=name)
         r = self.session.get(self.url("/api/v1/servers/localhost/search-data?q=*rr-zone*"))
         self.assert_success_json(r)
@@ -997,7 +1101,7 @@ fred   IN  A      192.168.0.4
         self.assertEquals(len(r.json()), 4)
 
     def test_search_rr_case_insensitive(self):
-        name = 'search-rr-insenszone.name'
+        name = 'search-rr-insenszone.name.'
         self.create_zone(name=name)
         r = self.session.get(self.url("/api/v1/servers/localhost/search-data?q=*rr-insensZONE*"))
         self.assert_success_json(r)
@@ -1015,7 +1119,7 @@ class AuthRootZone(ApiTestCase, AuthZonesHelperMixin):
         self.session.delete(self.url("/api/v1/servers/localhost/zones/=2E"))
 
     def test_create_zone(self):
-        payload, data = self.create_zone(name='', serial=22, soa_edit_api='')
+        payload, data = self.create_zone(name='.', serial=22, soa_edit_api='')
         for k in ('id', 'url', 'name', 'masters', 'kind', 'last_check', 'notified_serial', 'serial', 'soa_edit_api', 'soa_edit', 'account'):
             self.assertIn(k, data)
             if k in payload:
@@ -1024,7 +1128,7 @@ class AuthRootZone(ApiTestCase, AuthZonesHelperMixin):
         # validate generated SOA
         self.assertEquals(
             [r['content'] for r in data['records'] if r['type'] == 'SOA'][0],
-            "a.misconfigured.powerdns.server hostmaster." + payload['name'] + " " + str(payload['serial']) +
+            "a.misconfigured.powerdns.server. hostmaster. " + str(payload['serial']) +
             " 10800 3600 604800 3600"
         )
         # Regression test: verify zone list works
@@ -1039,11 +1143,10 @@ class AuthRootZone(ApiTestCase, AuthZonesHelperMixin):
         for k in ('name', 'kind'):
             self.assertIn(k, data)
             self.assertEquals(data[k], payload[k])
-        self.assertEqual(data['records'][0]['name'], '')
+        self.assertEqual(data['records'][0]['name'], '.')
 
     def test_update_zone(self):
-        payload, zone = self.create_zone(name='')
-        name = ''
+        payload, zone = self.create_zone(name='.')
         zone_id = '=2E'
         # update, set as Master and enable SOA-EDIT-API
         payload = {
@@ -1101,31 +1204,41 @@ class RecursorZones(ApiTestCase):
 
     def test_create_auth_zone(self):
         payload, data = self.create_zone(kind='Native')
-        # return values are normalized
-        payload['name'] += '.'
         for k in payload.keys():
             self.assertEquals(data[k], payload[k])
 
+    def test_create_zone_no_name(self):
+        name = unique_zone_name()
+        payload = {
+            'name': '',
+            'kind': 'Native',
+            'servers': ['8.8.8.8'],
+            'recursion_desired': False,
+        }
+        print payload
+        r = self.session.post(
+            self.url("/api/v1/servers/localhost/zones"),
+            data=json.dumps(payload),
+            headers={'content-type': 'application/json'})
+        self.assertEquals(r.status_code, 422)
+        self.assertIn('is not canonical', r.json()['error'])
+
     def test_create_forwarded_zone(self):
         payload, data = self.create_zone(kind='Forwarded', rd=False, servers=['8.8.8.8'])
         # return values are normalized
         payload['servers'][0] += ':53'
-        payload['name'] += '.'
         for k in payload.keys():
             self.assertEquals(data[k], payload[k])
 
     def test_create_forwarded_rd_zone(self):
-        payload, data = self.create_zone(name='google.com', kind='Forwarded', rd=True, servers=['8.8.8.8'])
+        payload, data = self.create_zone(name='google.com.', kind='Forwarded', rd=True, servers=['8.8.8.8'])
         # return values are normalized
         payload['servers'][0] += ':53'
-        payload['name'] += '.'
         for k in payload.keys():
             self.assertEquals(data[k], payload[k])
 
     def test_create_auth_zone_with_symbols(self):
         payload, data = self.create_zone(name='foo/bar.'+unique_zone_name(), kind='Native')
-        # return values are normalized
-        payload['name'] += '.'
         expected_id = (payload['name'].replace('/', '=2F'))
         for k in payload.keys():
             self.assertEquals(data[k], payload[k])
@@ -1133,7 +1246,7 @@ class RecursorZones(ApiTestCase):
 
     def test_rename_auth_zone(self):
         payload, data = self.create_zone(kind='Native')
-        name = payload['name'] + '.'
+        name = payload['name']
         # now rename it
         payload = {
             'name': 'renamed-'+name,
@@ -1157,7 +1270,7 @@ class RecursorZones(ApiTestCase):
         self.assertNotIn('Content-Type', r.headers)
 
     def test_search_rr_exact_zone(self):
-        name = unique_zone_name() + '.'
+        name = unique_zone_name()
         self.create_zone(name=name, kind='Native')
         r = self.session.get(self.url("/api/v1/servers/localhost/search-data?q=" + name))
         self.assert_success_json(r)
@@ -1165,7 +1278,7 @@ class RecursorZones(ApiTestCase):
         self.assertEquals(r.json(), [{u'type': u'zone', u'name': name, u'zone_id': name}])
 
     def test_search_rr_substring(self):
-        name = 'search-rr-zone.name'
+        name = 'search-rr-zone.name.'
         self.create_zone(name=name, kind='Native')
         r = self.session.get(self.url("/api/v1/servers/localhost/search-data?q=rr-zone"))
         self.assert_success_json(r)
index 494b3be0e725e5e0a11df757e8c3b577c031a80c..5d0bbca2384a3a03f889c7d5ad0297cbcd6c8565 100644 (file)
@@ -1,10 +1,13 @@
 from datetime import datetime
+from pprint import pprint
 import os
 import requests
 import urlparse
 import unittest
+import sqlite3
 
 DAEMON = os.environ.get('DAEMON', 'authoritative')
+SQLITE_DB = os.environ.get('SQLITE_DB', 'pdns.sqlite3')
 
 
 class ApiTestCase(unittest.TestCase):
@@ -30,7 +33,7 @@ class ApiTestCase(unittest.TestCase):
 
 
 def unique_zone_name():
-    return 'test-' + datetime.now().strftime('%d%H%S%M%f') + '.org'
+    return 'test-' + datetime.now().strftime('%d%H%S%M%f') + '.org.'
 
 
 def is_auth():
@@ -39,3 +42,42 @@ def is_auth():
 
 def is_recursor():
     return DAEMON == 'recursor'
+
+
+def eq_zone_dict(rrsets, expected):
+    data_got = {}
+    data_expected = {}
+    for type_, expected_records in expected.iteritems():
+        type_ = str(type_)
+        uses_name = any(['name' in expected_record for expected_record in expected_records])
+        # minify + convert received data
+        data_got[type_] = set((str(rec['name']) if uses_name else '@', str(rec['content']))
+                              for rec in rrsets if rec['type'] == type_)
+        # minify expected data
+        data_expected[type_] = set((str(rec['name']) if uses_name else '@', str(rec['content']))
+                                   for rec in expected_records)
+
+    print "eq_zone_dict: got:"
+    pprint(data_got)
+    print "eq_zone_dict: expected:"
+    pprint(data_expected)
+
+    assert data_got == data_expected, "%r != %r" % (data_got, data_expected)
+
+
+def get_auth_db():
+    """Return Connection to Authoritative backend DB."""
+    return sqlite3.Connection(SQLITE_DB)
+
+
+def get_db_records(zonename, qtype):
+    with get_auth_db() as db:
+        rows = db.execute("""
+            SELECT name, type, content, ttl
+            FROM records
+            WHERE type = ? AND domain_id = (
+                SELECT id FROM domains WHERE name = ?
+            )""", (qtype, zonename.rstrip('.'))).fetchall()
+        recs = [{'name': row[0], 'type': row[1], 'content': row[2], 'ttl': row[3]} for row in rows]
+        print "DB Records:", recs
+        return recs