From: Christian Hofstaedtler Date: Fri, 4 Dec 2015 19:28:16 +0000 (+0100) Subject: API: dot correctness X-Git-Tag: dnsdist-1.0.0-alpha1~109^2~1 X-Git-Url: https://granicus.if.org/sourcecode?a=commitdiff_plain;h=1d6b70f9d72a2554b0be08d19f646ee3899d4225;p=pdns API: dot correctness 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. --- diff --git a/.travis.yml b/.travis.yml index 9839895aa..7c35045ba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -288,7 +288,7 @@ script: ### api ### - cd regression-tests.api - #DNSName: - ./runtests authoritative + - ./runtests authoritative #DNSName: - ./runtests recursor - cd .. diff --git a/docs/markdown/httpapi/api_spec.md b/docs/markdown/httpapi/api_spec.md index 5ef139525..29c1e9aca 100644 --- a/docs/markdown/httpapi/api_spec.md +++ b/docs/markdown/httpapi/api_spec.md @@ -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: ``: `Native`, `Master` or `Slave` Recursor: ``: `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": , "override": "replace", - "domain": "www.cnn.com", + "domain": "www.cnn.com.", "rrtype": "AAAA", "values": ["203.0.113.4", "203.0.113..2"], "until": , @@ -873,7 +879,7 @@ override\_type "type": "Override", "id": , "override": "purge", - "domain": "example.net", + "domain": "example.net.", "created": } diff --git a/pdns/json.cc b/pdns/json.cc index 2124f8d32..f898bb88d 100644 --- a/pdns/json.cc +++ b/pdns/json.cc @@ -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()) { diff --git a/pdns/json.hh b/pdns/json.hh index 774631d8e..da6ca5c89 100644 --- a/pdns/json.hh +++ b/pdns/json.hh @@ -25,6 +25,7 @@ #include #include #include "rapidjson/document.h" +#include "dnsname.hh" std::string returnJsonObject(const std::map& 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); diff --git a/pdns/ws-api.cc b/pdns/ws-api.cc index 0d64d6782..54a62a1b3 100644 --- a/pdns/ws-api.cc +++ b/pdns/ws-api.cc @@ -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"); +} diff --git a/pdns/ws-api.hh b/pdns/ws-api.hh index 7c070636b..6ae4c78fb 100644 --- a/pdns/ws-api.hh +++ b/pdns/ws-api.hh @@ -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& out); diff --git a/pdns/ws-auth.cc b/pdns/ws-auth.cc index 641cde8e9..32590e3fd 100644 --- a/pdns/ws-auth.cc +++ b/pdns/ws-auth.cc @@ -247,7 +247,7 @@ void AuthWebServer::indexfunction(HttpRequest* req, HttpResponse* resp) d_queries.get5()<<", "<< d_queries.get10()<<". Max queries/second: "<"<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& 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& 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 drc(DNSRecordContent::mastermake(rr.qtype.getCode(), 1, rr.content)); - string tmp = drc->serialize(rr.qname); + //shared_ptr 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, vectorjson(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(1, rr))) { throw ApiException("Hosting backend does not support editing records."); @@ -1098,8 +1087,8 @@ static void apiServerSearchData(HttpRequest* req, HttpResponse* resp) { vector domains; vector result_rr; vector result_c; - map zoneIdZone; - map::iterator val; + map zoneIdZone; + map::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 diff --git a/pdns/ws-recursor.cc b/pdns/ws-recursor.cc index 7b3318f14..978e9da1b 100644 --- a/pdns/ws-recursor.cc +++ b/pdns/ws-recursor.cc @@ -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)) { diff --git a/regression-tests.api/runtests.py b/regression-tests.api/runtests.py index 42443e4fa..8d15b319f 100755 --- a/regression-tests.api/runtests.py +++ b/regression-tests.api/runtests.py @@ -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 "" diff --git a/regression-tests.api/test_Zones.py b/regression-tests.api/test_Zones.py index e7aabbe30..138fca456 100644 --- a/regression-tests.api/test_Zones.py +++ b/regression-tests.api/test_Zones.py @@ -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) diff --git a/regression-tests.api/test_helper.py b/regression-tests.api/test_helper.py index 494b3be0e..5d0bbca23 100644 --- a/regression-tests.api/test_helper.py +++ b/regression-tests.api/test_helper.py @@ -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