From 02945d9ae429c36f19fc5c311d8af3c94f00b789 Mon Sep 17 00:00:00 2001 From: Christian Hofstaedtler Date: Fri, 7 Feb 2014 02:53:05 +0100 Subject: [PATCH] Recursor: add Zone REST API --- pdns/ws-recursor.cc | 220 +++++++++++++++++++++++++++++ regression-tests.api/.gitignore | 2 + regression-tests.api/runtests.py | 27 ++++ regression-tests.api/test_Zones.py | 98 ++++++++++++- 4 files changed, 341 insertions(+), 6 deletions(-) diff --git a/pdns/ws-recursor.cc b/pdns/ws-recursor.cc index 4bef015a99..53bbbf486a 100644 --- a/pdns/ws-recursor.cc +++ b/pdns/ws-recursor.cc @@ -106,6 +106,224 @@ static void apiServerConfigAllowFrom(HttpRequest* req, HttpResponse* resp) resp->setBody(document); } +static void fillZone(const string& zonename, HttpResponse* resp) +{ + SyncRes::domainmap_t::const_iterator iter = t_sstorage->domainmap->find(zonename); + if (iter == t_sstorage->domainmap->end()) + throw ApiException("Could not find domain '"+zonename+"'"); + + Document doc; + doc.SetObject(); + + const SyncRes::AuthDomain& zone = iter->second; + + // id is the canonical lookup key, which doesn't actually match the name (in some cases) + string zoneId = apiZoneNameToId(iter->first); + doc.AddMember("id", zoneId.c_str(), doc.GetAllocator()); + string url = "/servers/localhost/zones/" + zoneId; + Value jurl(url.c_str(), doc.GetAllocator()); // copy + doc.AddMember("url", jurl, doc.GetAllocator()); + doc.AddMember("name", iter->first.c_str(), doc.GetAllocator()); + doc.AddMember("kind", zone.d_servers.empty() ? "Native" : "Forwarded", doc.GetAllocator()); + Value servers; + servers.SetArray(); + BOOST_FOREACH(const ComboAddress& server, zone.d_servers) { + Value value(server.toStringWithPort().c_str(), doc.GetAllocator()); + servers.PushBack(value, doc.GetAllocator()); + } + doc.AddMember("servers", servers, doc.GetAllocator()); + bool rd = zone.d_servers.empty() ? false : zone.d_rdForward; + doc.AddMember("recursion_desired", rd, doc.GetAllocator()); + + Value records; + records.SetArray(); + BOOST_FOREACH(const SyncRes::AuthDomain::records_t::value_type& rr, zone.d_records) { + Value object; + object.SetObject(); + Value jname(rr.qname.c_str(), doc.GetAllocator()); // copy + object.AddMember("name", jname, doc.GetAllocator()); + Value jtype(rr.qtype.getName().c_str(), doc.GetAllocator()); // copy + object.AddMember("type", jtype, doc.GetAllocator()); + object.AddMember("ttl", rr.ttl, doc.GetAllocator()); + object.AddMember("priority", rr.priority, doc.GetAllocator()); + Value jcontent(rr.content.c_str(), doc.GetAllocator()); // copy + object.AddMember("content", jcontent, doc.GetAllocator()); + records.PushBack(object, doc.GetAllocator()); + } + doc.AddMember("records", records, doc.GetAllocator()); + + resp->setBody(doc); +} + +static void doCreateZone(const Value& document) +{ + if (::arg()["experimental-api-config-dir"].empty()) { + throw ApiException("Config Option \"experimental-api-config-dir\" must be set"); + } + + string zonename = stringFromJson(document, "name"); + // TODO: better validation of zonename + if(zonename.empty()) + throw ApiException("Zone name empty"); + + if (zonename[zonename.size()-1] != '.') { + zonename += "."; + } + + string kind = toUpper(stringFromJson(document, "kind")); + bool rd = boolFromJson(document, "recursion_desired"); + string confbasename = "zone-" + apiZoneNameToId(zonename); + + if (kind == "NATIVE") { + if (rd) + throw ApiException("kind=Native and recursion_desired are mutually exclusive"); + + string zonefilename = ::arg()["experimental-api-config-dir"] + "/" + confbasename + ".zone"; + ofstream ofzone(zonefilename.c_str()); + if (!ofzone) { + throw ApiException("Could not open '"+zonefilename+"' for writing: "+stringerror()); + } + ofzone << "; Generated by pdns-recursor REST API, DO NOT EDIT" << endl; + ofzone << zonename << "\tIN\tSOA\tlocal.zone.\thostmaster."<method == "POST") { + if (::arg()["experimental-api-config-dir"].empty()) { + throw ApiException("Config Option \"experimental-api-config-dir\" must be set"); + } + + Document document; + req->json(document); + + string zonename = stringFromJson(document, "name"); + if (zonename[zonename.size()-1] != '.') { + zonename += "."; + } + + SyncRes::domainmap_t::const_iterator iter = t_sstorage->domainmap->find(zonename); + if (iter != t_sstorage->domainmap->end()) + throw ApiException("Zone already exists"); + + doCreateZone(document); + reloadAuthAndForwards(); + fillZone(zonename, resp); + return; + } + + if(req->method != "GET") + throw HttpMethodNotAllowedException(); + + Document doc; + doc.SetArray(); + + BOOST_FOREACH(const SyncRes::domainmap_t::value_type& val, *t_sstorage->domainmap) { + const SyncRes::AuthDomain& zone = val.second; + Value jdi; + jdi.SetObject(); + // id is the canonical lookup key, which doesn't actually match the name (in some cases) + string zoneId = apiZoneNameToId(val.first); + jdi.AddMember("id", zoneId.c_str(), doc.GetAllocator()); + string url = "/servers/localhost/zones/" + zoneId; + Value jurl(url.c_str(), doc.GetAllocator()); // copy + jdi.AddMember("url", jurl, doc.GetAllocator()); + jdi.AddMember("name", val.first.c_str(), doc.GetAllocator()); + jdi.AddMember("kind", zone.d_servers.empty() ? "Native" : "Forwarded", doc.GetAllocator()); + Value servers; + servers.SetArray(); + BOOST_FOREACH(const ComboAddress& server, zone.d_servers) { + Value value(server.toStringWithPort().c_str(), doc.GetAllocator()); + servers.PushBack(value, doc.GetAllocator()); + } + jdi.AddMember("servers", servers, doc.GetAllocator()); + bool rd = zone.d_servers.empty() ? false : zone.d_rdForward; + jdi.AddMember("recursion_desired", rd, doc.GetAllocator()); + doc.PushBack(jdi, doc.GetAllocator()); + } + resp->setBody(doc); +} + +static void apiServerZoneDetail(HttpRequest* req, HttpResponse* resp) +{ + string zonename = apiZoneIdToName(req->path_parameters["id"]); + zonename += "."; + + SyncRes::domainmap_t::const_iterator iter = t_sstorage->domainmap->find(zonename); + if (iter == t_sstorage->domainmap->end()) + throw ApiException("Could not find domain '"+zonename+"'"); + + if(req->method == "PUT") { + Document document; + req->json(document); + + doDeleteZone(zonename); + doCreateZone(document); + reloadAuthAndForwards(); + fillZone(zonename, resp); + } + else if(req->method == "DELETE") { + if (!doDeleteZone(zonename)) { + throw ApiException("Deleting domain failed"); + } + + reloadAuthAndForwards(); + // empty body on success + resp->body = ""; + } else if(req->method == "GET") { + fillZone(zonename, resp); + } else { + throw HttpMethodNotAllowedException(); + } +} + RecursorWebServer::RecursorWebServer(FDMultiplexer* fdm) { RecursorControlParser rcp; // inits @@ -123,6 +341,8 @@ RecursorWebServer::RecursorWebServer(FDMultiplexer* fdm) d_ws->registerApiHandler("/servers/localhost/config", &apiServerConfig); d_ws->registerApiHandler("/servers/localhost/search-log", &apiServerSearchLog); d_ws->registerApiHandler("/servers/localhost/statistics", &apiServerStatistics); + d_ws->registerApiHandler("/servers/localhost/zones/", &apiServerZoneDetail); + d_ws->registerApiHandler("/servers/localhost/zones", &apiServerZones); d_ws->registerApiHandler("/servers/localhost", &apiServerDetail); d_ws->registerApiHandler("/servers", &apiServer); diff --git a/regression-tests.api/.gitignore b/regression-tests.api/.gitignore index 1fd0d77023..55788404d2 100644 --- a/regression-tests.api/.gitignore +++ b/regression-tests.api/.gitignore @@ -6,3 +6,5 @@ /named.conf /*.pyc acl.conf +recursor.conf +rec-conf.d diff --git a/regression-tests.api/runtests.py b/regression-tests.api/runtests.py index 92c5de1911..ab187f194c 100755 --- a/regression-tests.api/runtests.py +++ b/regression-tests.api/runtests.py @@ -4,6 +4,7 @@ import os import requests +import shutil import subprocess import sys import tempfile @@ -26,6 +27,26 @@ ACL_LIST_TPL = """ ::1 """ +REC_EXAMPLE_COM_CONF_TPL = """ +# Generated by runtests.py +auth-zones+=example.com=../regression-tests/zones/example.com +""" + +REC_CONF_TPL = """ +# Generated by runtests.py +auth-zones= +forward-zones= +forward-zones-recurse= +experimental-api-config-dir=%(conf_dir)s +include-dir=%(conf_dir)s +""" + +def ensure_empty_dir(name): + if os.path.exists(name): + shutil.rmtree(name) + os.mkdir(name) + + wait = ('--wait' in sys.argv) if wait: sys.argv.remove('--wait') @@ -61,8 +82,14 @@ if daemon == 'authoritative': pdnscmd = ("../pdns/pdns_server --daemon=no --local-port=5300 --socket-dir=./ --no-shuffle --launch=gsqlite3 --gsqlite3-dnssec --send-root-referral --allow-2136-from=127.0.0.0/8 --experimental-rfc2136=yes --cache-ttl=0 --no-config --gsqlite3-database="+SQLITE_DB+" --experimental-json-interface=yes --webserver=yes --webserver-port="+WEBPORT+" --webserver-address=127.0.0.1 --query-logging --webserver-password="+WEBPASSWORD).split() else: + conf_dir = 'rec-conf.d' + ensure_empty_dir(conf_dir) with open('acl.list', 'w') as acl_list: acl_list.write(ACL_LIST_TPL) + with open('recursor.conf', 'w') as recursor_conf: + recursor_conf.write(REC_CONF_TPL % locals()) + with open(conf_dir+'/example.com..conf', 'w') as conf_file: + conf_file.write(REC_EXAMPLE_COM_CONF_TPL) pdnscmd = ("../pdns/pdns_recursor --daemon=no --socket-dir=. --config-dir=. --allow-from-file=acl.list --local-port=5555 --experimental-json-interface=yes --experimental-webserver=yes --experimental-webserver-port="+WEBPORT+" --experimental-webserver-address=127.0.0.1 --experimental-webserver-password="+WEBPASSWORD).split() diff --git a/regression-tests.api/test_Zones.py b/regression-tests.api/test_Zones.py index b4a5479fd8..7e98a8ffe9 100644 --- a/regression-tests.api/test_Zones.py +++ b/regression-tests.api/test_Zones.py @@ -1,21 +1,29 @@ import json import requests import unittest -from test_helper import ApiTestCase, unique_zone_name, isRecursor +from test_helper import ApiTestCase, unique_zone_name, isAuth, isRecursor -@unittest.skipIf(isRecursor(), "Not implemented yet") -class Servers(ApiTestCase): +class Zones(ApiTestCase): def test_ListZones(self): r = self.session.get(self.url("/servers/localhost/zones")) self.assertSuccessJson(r) domains = r.json() - example_com = [domain for domain in domains if domain['name'] == u'example.com'] + example_com = [domain for domain in domains if domain['name'] in ('example.com', 'example.com.')] self.assertEquals(len(example_com), 1) example_com = example_com[0] - for k in ('id', 'url', 'name', 'masters', 'kind', 'last_check', 'notified_serial', 'serial'): - self.assertIn(k, example_com) + required_fields = ['id', 'url', 'name', 'kind'] + if isAuth(): + required_fields = required_fields + ['masters', 'last_check', 'notified_serial', 'serial'] + elif isRecursor(): + required_fields = required_fields + ['recursion_desired', 'servers'] + for field in required_fields: + self.assertIn(field, example_com) + + +@unittest.skipIf(not isAuth(), "Not applicable") +class AuthZones(ApiTestCase): def create_zone(self, name=None, nameservers=None): if name is None: @@ -202,3 +210,81 @@ class Servers(ApiTestCase): data=json.dumps(payload), headers={'content-type': 'application/json'}) self.assertSuccessJson(r) + + +@unittest.skipIf(not isRecursor(), "Not applicable") +class RecursorZones(ApiTestCase): + + def test_CreateAuthZone(self): + payload = { + 'name': unique_zone_name(), + 'kind': 'Native', + 'recursion_desired': False + } + r = self.session.post( + self.url("/servers/localhost/zones"), + data=json.dumps(payload), + headers={'content-type': 'application/json'}) + self.assertSuccessJson(r) + data = r.json() + # return values are normalized + payload['name'] += '.' + for k in payload.keys(): + self.assertEquals(data[k], payload[k]) + + def test_CreateForwardedZone(self): + payload = { + 'name': unique_zone_name(), + 'kind': 'Forwarded', + 'servers': ['8.8.8.8'], + 'recursion_desired': False + } + r = self.session.post( + self.url("/servers/localhost/zones"), + data=json.dumps(payload), + headers={'content-type': 'application/json'}) + self.assertSuccessJson(r) + data = r.json() + # return values are normalized + payload['servers'][0] += ':53' + payload['name'] += '.' + for k in payload.keys(): + self.assertEquals(data[k], payload[k]) + + def test_CreateForwardedRDZone(self): + payload = { + 'name': 'google.com', + 'kind': 'Forwarded', + 'servers': ['8.8.8.8'], + 'recursion_desired': True + } + r = self.session.post( + self.url("/servers/localhost/zones"), + data=json.dumps(payload), + headers={'content-type': 'application/json'}) + self.assertSuccessJson(r) + data = r.json() + # return values are normalized + payload['servers'][0] += ':53' + payload['name'] += '.' + for k in payload.keys(): + self.assertEquals(data[k], payload[k]) + + def test_CreateAuthZoneWithSymbols(self): + payload = { + 'name': 'foo/bar.'+unique_zone_name(), + 'kind': 'Native', + 'recursion_desired': False + } + r = self.session.post( + self.url("/servers/localhost/zones"), + data=json.dumps(payload), + headers={'content-type': 'application/json'}) + self.assertSuccessJson(r) + data = r.json() + # return values are normalized + payload['name'] += '.' + expected_id = (payload['name'].replace('/', '=47')) + for k in payload.keys(): + self.assertEquals(data[k], payload[k]) + self.assertEquals(data['id'], expected_id) -- 2.47.2