From 56d68fadee126f7085668530ab24fd8e6f90ebd3 Mon Sep 17 00:00:00 2001 From: Remi Gacogne Date: Fri, 18 Nov 2016 10:36:43 +0100 Subject: [PATCH] dnsdist: Allow editing the ACL via the API --- pdns/README-dnsdist.md | 1 + pdns/dnsdist-console.cc | 1 + pdns/dnsdist-lua2.cc | 14 +++ pdns/dnsdist-web.cc | 168 +++++++++++++++++++++++---- pdns/dnsdist.hh | 2 + regression-tests.dnsdist/test_API.py | 97 +++++++++++++++- 6 files changed, 256 insertions(+), 27 deletions(-) diff --git a/pdns/README-dnsdist.md b/pdns/README-dnsdist.md index 77fd22fc0..12f288418 100644 --- a/pdns/README-dnsdist.md +++ b/pdns/README-dnsdist.md @@ -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 diff --git a/pdns/dnsdist-console.cc b/pdns/dnsdist-console.cc index 56bd5fb0c..437c4b552 100644 --- a/pdns/dnsdist-console.cc +++ b/pdns/dnsdist-console.cc @@ -324,6 +324,7 @@ const std::vector 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" }, diff --git a/pdns/dnsdist-lua2.cc b/pdns/dnsdist-lua2.cc index 099fbf503..ba7420efa 100644 --- a/pdns/dnsdist-lua2.cc +++ b/pdns/dnsdist-lua2.cc @@ -1103,4 +1103,18 @@ void moreLua(bool client) g_included = false; }); + + g_lua.writeFunction("setAPIWritable", [](bool writable, boost::optional 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!"; + } + } + }); } diff --git a/pdns/dnsdist-web.cc b/pdns/dnsdist-web.cc index 0e3269ac0..58ef982f0 100644 --- a/pdns/dnsdist-web.cc +++ b/pdns/dnsdist-web.cc @@ -35,6 +35,49 @@ #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 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 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 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 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 >& customHeaders) diff --git a/pdns/dnsdist.hh b/pdns/dnsdist.hh index 7c4ac6f68..57691d268 100644 --- a/pdns/dnsdist.hh +++ b/pdns/dnsdist.hh @@ -689,6 +689,8 @@ extern uint64_t g_maxTCPQueuedConnections; extern std::atomic 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; diff --git a/regression-tests.dnsdist/test_API.py b/regression-tests.dnsdist/test_API.py index 8a4fd61c7..95b353b5d 100644 --- a/regression-tests.dnsdist/test_API.py +++ b/regression-tests.dnsdist/test_API.py @@ -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"}) +""") -- 2.40.0