* 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
{ "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" },
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!";
+ }
+ }
+ });
}
#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)
{
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()) {
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";
}
{
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"];
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") {
string acl;
vector<string> vec;
-
g_ACL.getCopy().toStringVector(&vec);
for(const auto& s : vec) {
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;
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)
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;
#!/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")
"""
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
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"})
+""")