]> granicus.if.org Git - pdns/commitdiff
dnsdist: Allow editing the ACL via the API
authorRemi Gacogne <remi.gacogne@powerdns.com>
Fri, 18 Nov 2016 09:36:43 +0000 (10:36 +0100)
committerRemi Gacogne <remi.gacogne@powerdns.com>
Fri, 18 Nov 2016 09:36:43 +0000 (10:36 +0100)
pdns/README-dnsdist.md
pdns/dnsdist-console.cc
pdns/dnsdist-lua2.cc
pdns/dnsdist-web.cc
pdns/dnsdist.hh
regression-tests.dnsdist/test_API.py

index 77fd22fc0a6578cdd53e2589aaacbae134eeb42e..12f28841830664a3968538bad637d40bdbbe4db4 100644 (file)
@@ -1224,6 +1224,7 @@ Here are all functions:
     * quit or ^D: exit the console
     * `webserver(address:port, password [, apiKey [, customHeaders ]])`: launch a webserver with stats on that address with that password
     * `includeDirectory(dir)`: all files ending in `.conf` in the directory `dir` are loaded into the configuration
+    * `setAPIWritable(bool, [dir])`: allow modifications via the API. If `dir` is set, it must be a valid directory where the configuration files will be written by the API. Otherwise the modifications done via the API will not be written to the configuration and will not persist after a reload
  * ACL related:
     * `addACL(netmask)`: add to the ACL set who can use this server
     * `setACL({netmask, netmask})`: replace the ACL set with these netmasks. Use `setACL({})` to reset the list, meaning no one can use us
index 56bd5fb0c9525a8b5ef572f4bed375282624d207..437c4b552587ddf35c0c360866431854c4ef7e6f 100644 (file)
@@ -324,6 +324,7 @@ const std::vector<ConsoleKeyword> g_consoleKeywords{
   { "QTypeRule", true, "qtype", "matches queries with the specified qtype" },
   { "RCodeRule", true, "rcode", "matches responses with the specified rcode" },
   { "setACL", true, "{netmask, netmask}", "replace the ACL set with these netmasks. Use `setACL({})` to reset the list, meaning no one can use us" },
+  { "setAPIWritable", true, "bool, dir", "allow modifications via the API. if `dir` is set, it must be a valid directory where the configuration files will be written by the API" },
   { "setDNSSECPool", true, "pool name", "move queries requesting DNSSEC processing to this pool" },
   { "setECSOverride", true, "bool", "whether to override an existing EDNS Client Subnet value in the query" },
   { "setECSSourcePrefixV4", true, "prefix-length", "the EDNS Client Subnet prefix-length used for IPv4 queries" },
index 099fbf50331f7b788b6f90176d1503d994838c88..ba7420efa36db07e8c5d4e22dd110f19e4490eb3 100644 (file)
@@ -1103,4 +1103,18 @@ void moreLua(bool client)
 
         g_included = false;
     });
+
+    g_lua.writeFunction("setAPIWritable", [](bool writable, boost::optional<std::string> apiConfigDir) {
+        setLuaSideEffect();
+        g_apiReadWrite = writable;
+        if (apiConfigDir) {
+          if (!(*apiConfigDir).empty()) {
+            g_apiConfigDirectory = *apiConfigDir;
+          }
+          else {
+            errlog("The API configuration directory value cannot be empty!");
+            g_outputBuffer="The API configuration directory value cannot be empty!";
+          }
+        }
+      });
 }
index 0e3269ac0a7df21799ecc05d770529df8bc29f9e..58ef982f03e857e03efe613b537ff4dee6ee1607 100644 (file)
 #include "base64.hh"
 #include "gettime.hh"
 
+bool g_apiReadWrite{false};
+std::string g_apiConfigDirectory;
+
+static bool apiWriteConfigFile(const string& filebasename, const string& content)
+{
+  if (!g_apiReadWrite) {
+    errlog("Not writing content to %s since the API is read-only", filebasename);
+    return false;
+  }
+
+  if (g_apiConfigDirectory.empty()) {
+    vinfolog("Not writing content to %s since the API configuration directory is not set", filebasename);
+    return false;
+  }
+
+  string filename = g_apiConfigDirectory + "/" + filebasename + ".conf";
+  ofstream ofconf(filename.c_str());
+  if (!ofconf) {
+    errlog("Could not open configuration fragment file '%s' for writing: %s", filename, stringerror());
+    return false;
+  }
+  ofconf << "-- Generated by the REST API, DO NOT EDIT" << endl;
+  ofconf << content << endl;
+  ofconf.close();
+  return true;
+}
+
+static void apiSaveACL(const NetmaskGroup& nmg)
+{
+  vector<string> vec;
+  g_ACL.getCopy().toStringVector(&vec);
+
+  string acl;
+  for(const auto& s : vec) {
+    if (!acl.empty()) {
+      acl += ", ";
+    }
+    acl += "\"" + s + "\"";
+  }
+
+  string content = "setACL({" + acl + "})";
+  apiWriteConfigFile("acl", content);
+}
 
 static bool compareAuthorization(YaHTTP::Request& req, const string &expected_password, const string& expectedApiKey)
 {
@@ -59,6 +102,7 @@ static bool compareAuthorization(YaHTTP::Request& req, const string &expected_pa
     if (req.url.path=="/jsonstat" ||
         req.url.path=="/api/v1/servers/localhost" ||
         req.url.path=="/api/v1/servers/localhost/config" ||
+        req.url.path=="/api/v1/servers/localhost/config/allow-from" ||
         req.url.path=="/api/v1/servers/localhost/statistics") {
       header = req.headers.find("x-api-key");
       if (header != req.headers.end()) {
@@ -69,13 +113,31 @@ static bool compareAuthorization(YaHTTP::Request& req, const string &expected_pa
   return auth_ok;
 }
 
+static bool isMethodAllowed(const YaHTTP::Request& req)
+{
+  if (req.method == "GET") {
+    return true;
+  }
+  if (req.method == "PUT" && g_apiReadWrite) {
+    if (req.url.path == "/api/v1/servers/localhost/config/allow-from") {
+      return true;
+    }
+  }
+  return false;
+}
+
 static void handleCORS(YaHTTP::Request& req, YaHTTP::Response& resp)
 {
   YaHTTP::strstr_map_t::iterator origin = req.headers.find("Origin");
   if (origin != req.headers.end()) {
     if (req.method == "OPTIONS") {
       /* Pre-flight request */
-      resp.headers["Access-Control-Allow-Methods"] = "GET";
+      if (g_apiReadWrite) {
+        resp.headers["Access-Control-Allow-Methods"] = "GET, PUT";
+      }
+      else {
+        resp.headers["Access-Control-Allow-Methods"] = "GET";
+      }
       resp.headers["Access-Control-Allow-Headers"] = "Authorization, X-API-Key";
     }
 
@@ -121,22 +183,26 @@ static void connectionThread(int sock, ComboAddress remote, string password, str
 {
   using namespace json11;
   vinfolog("Webserver handling connection from %s", remote.toStringWithPort());
-  FILE* fp=0;
-  fp=fdopen(sock, "r");
+
   try {
-    string line;
-    string request;
-    while(stringfgets(fp, line)) {
-      request+=line;
-      trim(line);
-
-      if(line.empty())
-       break;
-    }
-  
-    std::istringstream ifs(request);
+    YaHTTP::AsyncRequestLoader yarl;
     YaHTTP::Request req;
-    ifs >> req;
+    bool finished = false;
+
+    yarl.initialize(&req);
+    while(!finished) {
+      int bytes;
+      char buf[1024];
+      bytes = read(sock, buf, sizeof(buf));
+      if (bytes > 0) {
+        string data = string(buf, bytes);
+        finished = yarl.feed(data);
+      } else {
+        // read error OR EOF
+        break;
+      }
+    }
+    yarl.finalize();
 
     string command=req.getvars["command"];
 
@@ -166,7 +232,7 @@ static void connectionThread(int sock, ComboAddress remote, string password, str
       resp.headers["WWW-Authenticate"] = "basic realm=\"PowerDNS\"";
 
     }
-    else if(req.method != "GET") {
+    else if(!isMethodAllowed(req)) {
       resp.status=405;
     }
     else if(req.url.path=="/jsonstat") {
@@ -336,7 +402,6 @@ static void connectionThread(int sock, ComboAddress remote, string password, str
       string acl;
 
       vector<string> vec;
-
       g_ACL.getCopy().toStringVector(&vec);
 
       for(const auto& s : vec) {
@@ -443,6 +508,62 @@ static void connectionThread(int sock, ComboAddress remote, string password, str
       resp.body=my_json.dump();
       resp.headers["Content-Type"] = "application/json";
     }
+    else if(req.url.path=="/api/v1/servers/localhost/config/allow-from") {
+      handleCORS(req, resp);
+
+      resp.headers["Content-Type"] = "application/json";
+      resp.status=200;
+
+      if (req.method == "PUT") {
+        std::string err;
+        Json doc = Json::parse(req.body, err);
+
+        if (!doc.is_null()) {
+          NetmaskGroup nmg;
+          auto aclList = doc["value"];
+          if (aclList.is_array()) {
+
+            for (auto value : aclList.array_items()) {
+              try {
+                nmg.addMask(value.string_value());
+              } catch (NetmaskException &e) {
+                resp.status = 400;
+                break;
+              }
+            }
+
+            if (resp.status == 200) {
+              infolog("Updating the ACL via the API to %s", nmg.toString());
+              g_ACL.setState(nmg);
+              apiSaveACL(nmg);
+            }
+          }
+          else {
+            resp.status = 400;
+          }
+        }
+        else {
+          resp.status = 400;
+        }
+      }
+      if (resp.status == 200) {
+        Json::array acl;
+        vector<string> vec;
+        g_ACL.getCopy().toStringVector(&vec);
+
+        for(const auto& s : vec) {
+          acl.push_back(s);
+        }
+
+        Json::object obj{
+          { "type", "ConfigSetting" },
+          { "name", "allow-from" },
+          { "value", acl }
+        };
+        Json my_json = obj;
+        resp.body=my_json.dump();
+      }
+    }
     else if(!req.url.path.empty() && g_urlmap.count(req.url.path.c_str()+1)) {
       resp.body.assign(g_urlmap[req.url.path.c_str()+1]);
       vector<string> parts;
@@ -473,23 +594,20 @@ static void connectionThread(int sock, ComboAddress remote, string password, str
     done=ofs.str();
     writen2(sock, done.c_str(), done.size());
 
-    fclose(fp);
-    fp=0;
+    close(sock);
+    sock = -1;
   }
   catch(const YaHTTP::ParseError& e) {
     vinfolog("Webserver thread died with parse error exception while processing a request from %s: %s", remote.toStringWithPort(), e.what());
-    if(fp)
-      fclose(fp);
+    close(sock);
   }
   catch(const std::exception& e) {
     errlog("Webserver thread died with exception while processing a request from %s: %s", remote.toStringWithPort(), e.what());
-    if(fp)
-      fclose(fp);
+    close(sock);
   }
   catch(...) {
     errlog("Webserver thread died with exception while processing a request from %s", remote.toStringWithPort());
-    if(fp)
-      fclose(fp);
+    close(sock);
   }
 }
 void dnsdistWebserverThread(int sock, const ComboAddress& local, const std::string& password, const std::string& apiKey, const boost::optional<std::map<std::string, std::string> >& customHeaders)
index 7c4ac6f68782755961619e548e647fafdf7cce95..57691d2688434c8389c8a4c633532accbd475d33 100644 (file)
@@ -689,6 +689,8 @@ extern uint64_t g_maxTCPQueuedConnections;
 extern std::atomic<uint16_t> g_cacheCleaningDelay;
 extern bool g_verboseHealthChecks;
 extern uint32_t g_staleCacheEntriesTTL;
+extern bool g_apiReadWrite;
+extern std::string g_apiConfigDirectory;
 
 struct ConsoleKeyword {
   std::string name;
index 8a4fd61c7b18e7516066fcfc6d61630aaaec1f44..95b353b5d34c84f72a50d00bb655727c403436f1 100644 (file)
@@ -1,20 +1,23 @@
 #!/usr/bin/env python
+import os.path
 
+import json
 import requests
 from dnsdisttests import DNSDistTest
 
-class TestBasics(DNSDistTest):
+class TestAPIBasics(DNSDistTest):
 
     _webTimeout = 2.0
     _webServerPort = 8083
     _webServerBasicAuthPassword = 'secret'
     _webServerAPIKey = 'apisecret'
     # paths accessible using the API key
-    _apiPaths = ['/api/v1/servers/localhost', '/api/v1/servers/localhost/config', '/api/v1/servers/localhost/statistics', '/jsonstat?command=stats', '/jsonstat?command=dynblocklist']
+    _apiPaths = ['/api/v1/servers/localhost', '/api/v1/servers/localhost/config', '/api/v1/servers/localhost/config/allow-from', '/api/v1/servers/localhost/statistics', '/jsonstat?command=stats', '/jsonstat?command=dynblocklist']
     # paths accessible using basic auth only (list not exhaustive)
     _basicOnlyPaths = ['/', '/index.html']
     _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPassword', '_webServerAPIKey']
     _config_template = """
+    setACL({"127.0.0.1/32", "::1/128"})
     newServer{address="127.0.0.1:%s"}
     webserver("127.0.0.1:%s", "%s", "%s")
     """
@@ -129,6 +132,40 @@ class TestBasics(DNSDistTest):
         self.assertTrue(values['ecs-source-prefix-v4'] >= 0 and values['ecs-source-prefix-v4'] <= 32)
         self.assertTrue(values['ecs-source-prefix-v6'] >= 0 and values['ecs-source-prefix-v6'] <= 128)
 
+    def testServersLocalhostConfigAllowFrom(self):
+        """
+        API: /api/v1/servers/localhost/config/allow-from
+        """
+        headers = {'x-api-key': self._webServerAPIKey}
+        url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/config/allow-from'
+        r = requests.get(url, headers=headers, timeout=self._webTimeout)
+        self.assertTrue(r)
+        self.assertEquals(r.status_code, 200)
+        self.assertTrue(r.json())
+        content = r.json()
+        for key in ['type', 'name', 'value']:
+            self.assertIn(key, content)
+
+        self.assertEquals(content['name'], 'allow-from')
+        self.assertEquals(content['type'], 'ConfigSetting')
+        self.assertEquals(content['value'], ["127.0.0.1/32", "::1/128"])
+
+    def testServersLocalhostConfigAllowFromPut(self):
+        """
+        API: PUT /api/v1/servers/localhost/config/allow-from (should be refused)
+
+        The API is read-only by default, so this should be refused
+        """
+        newACL = ["192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"]
+        payload = json.dumps({"name": "allow-from",
+                              "type": "ConfigSetting",
+                              "value": newACL})
+        headers = {'x-api-key': self._webServerAPIKey}
+        url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/config/allow-from'
+        r = requests.put(url, headers=headers, timeout=self._webTimeout, data=payload)
+        self.assertFalse(r)
+        self.assertEquals(r.status_code, 405)
+
     def testServersLocalhostStatistics(self):
         """
         API: /api/v1/servers/localhost/statistics
@@ -210,3 +247,59 @@ class TestBasics(DNSDistTest):
 
             for key in ['blocks']:
                 self.assertTrue(content[key] >= 0)
+
+class TestAPIWritable(DNSDistTest):
+
+    _webTimeout = 2.0
+    _webServerPort = 8083
+    _webServerBasicAuthPassword = 'secret'
+    _webServerAPIKey = 'apisecret'
+    _APIWriteDir = '/tmp'
+    _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPassword', '_webServerAPIKey', '_APIWriteDir']
+    _config_template = """
+    setACL({"127.0.0.1/32", "::1/128"})
+    newServer{address="127.0.0.1:%s"}
+    webserver("127.0.0.1:%s", "%s", "%s")
+    setAPIWritable(true, "%s")
+    """
+
+    def testSetACL(self):
+        """
+        API: Set ACL
+        """
+        headers = {'x-api-key': self._webServerAPIKey}
+        url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/config/allow-from'
+        r = requests.get(url, headers=headers, timeout=self._webTimeout)
+        self.assertTrue(r)
+        self.assertEquals(r.status_code, 200)
+        self.assertTrue(r.json())
+        content = r.json()
+        self.assertEquals(content['value'], ["127.0.0.1/32", "::1/128"])
+
+        newACL = ["192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"]
+        payload = json.dumps({"name": "allow-from",
+                              "type": "ConfigSetting",
+                              "value": newACL})
+        r = requests.put(url, headers=headers, timeout=self._webTimeout, data=payload)
+        self.assertTrue(r)
+        self.assertEquals(r.status_code, 200)
+        self.assertTrue(r.json())
+        content = r.json()
+        self.assertEquals(content['value'], newACL)
+
+        r = requests.get(url, headers=headers, timeout=self._webTimeout)
+        self.assertTrue(r)
+        self.assertEquals(r.status_code, 200)
+        self.assertTrue(r.json())
+        content = r.json()
+        self.assertEquals(content['value'], newACL)
+
+        configFile = self._APIWriteDir + '/' + 'acl.conf'
+        self.assertTrue(os.path.isfile(configFile))
+        fileContent = None
+        with file(configFile) as f:
+            fileContent = f.read()
+
+        self.assertEquals(fileContent, """-- Generated by the REST API, DO NOT EDIT
+setACL({"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"})
+""")