]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
Recursor: add Zone REST API 1271/head
authorChristian Hofstaedtler <christian@hofstaedtler.name>
Fri, 7 Feb 2014 01:53:05 +0000 (02:53 +0100)
committerChristian Hofstaedtler <christian@hofstaedtler.name>
Fri, 7 Feb 2014 14:11:16 +0000 (15:11 +0100)
pdns/ws-recursor.cc
regression-tests.api/.gitignore
regression-tests.api/runtests.py
regression-tests.api/test_Zones.py

index 4bef015a998328d898afd0587ee251ef96116679..53bbbf486a074d74bee3f05466b1d4a9b153e972 100644 (file)
@@ -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."<<zonename<<" 1 1 1 1 1" << endl;
+    ofzone.close();
+
+    apiWriteConfigFile(confbasename, "auth-zones+=" + zonename + "=" + zonefilename);
+  } else if (kind == "FORWARDED") {
+    const Value &servers = document["servers"];
+    if (kind == "FORWARDED" && (!servers.IsArray() || servers.Size() == 0))
+      throw ApiException("Need at least one upstream server when forwarding");
+
+    string serverlist;
+    if (servers.IsArray()) {
+      for (SizeType i = 0; i < servers.Size(); ++i) {
+        if (!serverlist.empty()) {
+          serverlist += ";";
+        }
+        serverlist += servers[i].GetString();
+      }
+    }
+
+    if (rd) {
+      apiWriteConfigFile(confbasename, "forward-zones-recurse+=" + zonename + "=" + serverlist);
+    } else {
+      apiWriteConfigFile(confbasename, "forward-zones+=" + zonename + "=" + serverlist);
+    }
+  } else {
+    throw ApiException("invalid kind");
+  }
+}
+
+static bool doDeleteZone(const string& zonename)
+{
+  if (::arg()["experimental-api-config-dir"].empty()) {
+    throw ApiException("Config Option \"experimental-api-config-dir\" must be set");
+  }
+
+  string filename;
+
+  // this one must exist
+  filename = ::arg()["experimental-api-config-dir"] + "/zone-" + apiZoneNameToId(zonename) + ".conf";
+  if (unlink(filename.c_str()) != 0) {
+    return false;
+  }
+
+  // .zone file is optional
+  filename = ::arg()["experimental-api-config-dir"] + "/zone-" + apiZoneNameToId(zonename) + ".zone";
+  unlink(filename.c_str());
+
+  return true;
+}
+
+static void apiServerZones(HttpRequest* req, HttpResponse* resp)
+{
+  if (req->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/<id>", &apiServerZoneDetail);
+  d_ws->registerApiHandler("/servers/localhost/zones", &apiServerZones);
   d_ws->registerApiHandler("/servers/localhost", &apiServerDetail);
   d_ws->registerApiHandler("/servers", &apiServer);
 
index 1fd0d7702399cde3eb87450efe930c26dda4e532..55788404d2e3aebdba4c9c003fadbc9c2a771baa 100644 (file)
@@ -6,3 +6,5 @@
 /named.conf
 /*.pyc
 acl.conf
+recursor.conf
+rec-conf.d
index 92c5de19116ba46dbf7866436d87ca10c175a548..ab187f194c17b4a2318f5bbc65d41cf795c9952e 100755 (executable)
@@ -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()
 
index b4a5479fd80b4c54724664edd788b162a516bf3a..7e98a8ffe9107b08b536f332f74596cc19620bd3 100644 (file)
@@ -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)