]> git.ipfire.org Git - thirdparty/pdns.git/blame - regression-tests.dnsdist/test_API.py
Merge pull request #13023 from mind04/pdns-legacy
[thirdparty/pdns.git] / regression-tests.dnsdist / test_API.py
CommitLineData
02bbf9eb 1#!/usr/bin/env python
56d68fad 2import os.path
02bbf9eb 3
80dbd7d2 4import base64
56d68fad 5import json
02bbf9eb 6import requests
5e40d2a5
RG
7import socket
8import time
630eb526 9from dnsdisttests import DNSDistTest, pickAvailablePort
02bbf9eb 10
72bf42cd
RG
11class APITestsBase(DNSDistTest):
12 __test__ = False
57af5b7c 13 _webTimeout = 5.0
630eb526 14 _webServerPort = pickAvailablePort()
02bbf9eb 15 _webServerBasicAuthPassword = 'secret'
2c0392a5 16 _webServerBasicAuthPasswordHashed = '$scrypt$ln=10,p=1,r=8$6DKLnvUYEeXWh3JNOd3iwg==$kSrhdHaRbZ7R74q3lGBqO1xetgxRxhmWzYJ2Qvfm7JM='
02bbf9eb 17 _webServerAPIKey = 'apisecret'
2c0392a5 18 _webServerAPIKeyHashed = '$scrypt$ln=10,p=1,r=8$9v8JxDfzQVyTpBkTbkUqYg==$bDQzAOHeK1G9UvTPypNhrX48w974ZXbFPtRKS34+aso='
412f99ef 19 _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed']
72bf42cd
RG
20 _config_template = """
21 setACL({"127.0.0.1/32", "::1/128"})
22 newServer{address="127.0.0.1:%s", pool={'', 'mypool'}}
23 webserver("127.0.0.1:%s")
cfe95ada 24 setWebserverConfig({password="%s", apiKey="%s"})
72bf42cd 25 """
14325153
RG
26 _expectedMetrics = ['responses', 'servfail-responses', 'queries', 'acl-drops',
27 'frontend-noerror', 'frontend-nxdomain', 'frontend-servfail',
28 'rule-drop', 'rule-nxdomain', 'rule-refused', 'self-answered', 'downstream-timeouts',
29 'downstream-send-errors', 'trunc-failures', 'no-policy', 'latency0-1',
30 'latency1-10', 'latency10-50', 'latency50-100', 'latency100-1000',
31 'latency-slow', 'latency-sum', 'latency-count', 'latency-avg100', 'latency-avg1000',
89b29a4d
RG
32 'latency-avg10000', 'latency-avg1000000', 'latency-tcp-avg100', 'latency-tcp-avg1000',
33 'latency-tcp-avg10000', 'latency-tcp-avg1000000', 'latency-dot-avg100', 'latency-dot-avg1000',
34 'latency-dot-avg10000', 'latency-dot-avg1000000', 'latency-doh-avg100', 'latency-doh-avg1000',
5057253a
RG
35 'latency-doh-avg10000', 'latency-doh-avg1000000', 'latency-doq-avg100', 'latency-doq-avg1000',
36 'latency-doq-avg10000', 'latency-doq-avg1000000','uptime', 'real-memory-usage', 'noncompliant-queries',
14325153
RG
37 'noncompliant-responses', 'rdqueries', 'empty-queries', 'cache-hits',
38 'cache-misses', 'cpu-iowait', 'cpu-steal', 'cpu-sys-msec', 'cpu-user-msec', 'fd-usage', 'dyn-blocked',
39 'dyn-block-nmg-size', 'rule-servfail', 'rule-truncated', 'security-status',
40 'udp-in-csum-errors', 'udp-in-errors', 'udp-noport-errors', 'udp-recvbuf-errors', 'udp-sndbuf-errors',
41 'udp6-in-errors', 'udp6-recvbuf-errors', 'udp6-sndbuf-errors', 'udp6-noport-errors', 'udp6-in-csum-errors',
f089d71c 42 'doh-query-pipe-full', 'doh-response-pipe-full', 'doq-response-pipe-full', 'proxy-protocol-invalid', 'tcp-listen-overflows',
14325153
RG
43 'outgoing-doh-query-pipe-full', 'tcp-query-pipe-full', 'tcp-cross-protocol-query-pipe-full',
44 'tcp-cross-protocol-response-pipe-full']
648edcba
RG
45 _verboseMode = True
46
47 @classmethod
48 def setUpClass(cls):
49 cls.startResponders()
50 cls.startDNSDist()
51 cls.setUpSockets()
52 cls.waitForTCPSocket('127.0.0.1', cls._webServerPort)
53 print("Launching tests..")
72bf42cd
RG
54
55class TestAPIBasics(APITestsBase):
56
55afa518
RG
57 # paths accessible using the API key only
58 _apiOnlyPaths = ['/api/v1/servers/localhost/config', '/api/v1/servers/localhost/config/allow-from', '/api/v1/servers/localhost/statistics']
59 # paths accessible using an API key or basic auth
60 _statsPaths = [ '/jsonstat?command=stats', '/jsonstat?command=dynblocklist', '/api/v1/servers/localhost']
02bbf9eb
RG
61 # paths accessible using basic auth only (list not exhaustive)
62 _basicOnlyPaths = ['/', '/index.html']
72bf42cd 63 __test__ = True
02bbf9eb
RG
64
65 def testBasicAuth(self):
66 """
67 API: Basic Authentication
68 """
55afa518 69 for path in self._basicOnlyPaths + self._statsPaths:
02bbf9eb 70 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
80dbd7d2 71 r = requests.get(url, auth=('whatever', "evilsecret"), timeout=self._webTimeout)
4bfebc93 72 self.assertEqual(r.status_code, 401)
02bbf9eb
RG
73 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
74 self.assertTrue(r)
4bfebc93 75 self.assertEqual(r.status_code, 200)
02bbf9eb
RG
76
77 def testXAPIKey(self):
78 """
79 API: X-Api-Key
80 """
81 headers = {'x-api-key': self._webServerAPIKey}
55afa518 82 for path in self._apiOnlyPaths + self._statsPaths:
02bbf9eb
RG
83 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
84 r = requests.get(url, headers=headers, timeout=self._webTimeout)
85 self.assertTrue(r)
4bfebc93 86 self.assertEqual(r.status_code, 200)
02bbf9eb 87
80dbd7d2
CHB
88 def testWrongXAPIKey(self):
89 """
90 API: Wrong X-Api-Key
91 """
92 headers = {'x-api-key': "evilapikey"}
93 for path in self._apiOnlyPaths + self._statsPaths:
94 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
95 r = requests.get(url, headers=headers, timeout=self._webTimeout)
4bfebc93 96 self.assertEqual(r.status_code, 401)
c563cbe5 97
02bbf9eb
RG
98 def testBasicAuthOnly(self):
99 """
100 API: Basic Authentication Only
101 """
102 headers = {'x-api-key': self._webServerAPIKey}
103 for path in self._basicOnlyPaths:
104 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
105 r = requests.get(url, headers=headers, timeout=self._webTimeout)
4bfebc93 106 self.assertEqual(r.status_code, 401)
02bbf9eb 107
55afa518
RG
108 def testAPIKeyOnly(self):
109 """
110 API: API Key Only
111 """
112 for path in self._apiOnlyPaths:
113 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
114 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
4bfebc93 115 self.assertEqual(r.status_code, 401)
55afa518 116
02bbf9eb
RG
117 def testServersLocalhost(self):
118 """
119 API: /api/v1/servers/localhost
120 """
121 headers = {'x-api-key': self._webServerAPIKey}
122 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost'
123 r = requests.get(url, headers=headers, timeout=self._webTimeout)
124 self.assertTrue(r)
4bfebc93 125 self.assertEqual(r.status_code, 200)
02bbf9eb
RG
126 self.assertTrue(r.json())
127 content = r.json()
128
4bfebc93 129 self.assertEqual(content['daemon_type'], 'dnsdist')
02bbf9eb 130
f8a222ac
RG
131 rule_groups = ['response-rules', 'cache-hit-response-rules', 'self-answered-response-rules', 'rules']
132 for key in ['version', 'acl', 'local', 'servers', 'frontends', 'pools'] + rule_groups:
02bbf9eb
RG
133 self.assertIn(key, content)
134
d18eab67
CH
135 for rule_group in rule_groups:
136 for rule in content[rule_group]:
f8a222ac 137 for key in ['id', 'creationOrder', 'matches', 'rule', 'action', 'uuid']:
d18eab67 138 self.assertIn(key, rule)
f8a222ac 139 for key in ['id', 'creationOrder', 'matches']:
d18eab67 140 self.assertTrue(rule[key] >= 0)
4ace9fe8 141
02bbf9eb
RG
142 for server in content['servers']:
143 for key in ['id', 'latency', 'name', 'weight', 'outstanding', 'qpsLimit',
6a78f305 144 'reuseds', 'state', 'address', 'pools', 'qps', 'queries', 'order', 'sendErrors',
d70f95ac 145 'dropRate', 'responses', 'nonCompliantResponses', 'tcpDiedSendingQuery', 'tcpDiedReadingResponse',
0f06daf9 146 'tcpGaveUp', 'tcpReadTimeouts', 'tcpWriteTimeouts', 'tcpCurrentConnections',
2a5cfdfb 147 'tcpNewConnections', 'tcpReusedConnections', 'tlsResumptions', 'tcpAvgQueriesPerConnection',
da73e6b3 148 'tcpAvgConnectionDuration', 'tcpLatency', 'protocol', 'healthCheckFailures', 'healthCheckFailuresParsing', 'healthCheckFailuresTimeout', 'healthCheckFailuresNetwork', 'healthCheckFailuresMismatch', 'healthCheckFailuresInvalid']:
02bbf9eb
RG
149 self.assertIn(key, server)
150
151 for key in ['id', 'latency', 'weight', 'outstanding', 'qpsLimit', 'reuseds',
d70f95ac 152 'qps', 'queries', 'order', 'tcpLatency', 'responses', 'nonCompliantResponses']:
02bbf9eb
RG
153 self.assertTrue(server[key] >= 0)
154
155 self.assertTrue(server['state'] in ['up', 'down', 'UP', 'DOWN'])
156
157 for frontend in content['frontends']:
fd23f5de 158 for key in ['id', 'address', 'udp', 'tcp', 'type', 'queries', 'nonCompliantQueries']:
02bbf9eb
RG
159 self.assertIn(key, frontend)
160
fd23f5de 161 for key in ['id', 'queries', 'nonCompliantQueries']:
02bbf9eb
RG
162 self.assertTrue(frontend[key] >= 0)
163
4ace9fe8 164 for pool in content['pools']:
27792330 165 for key in ['id', 'name', 'cacheSize', 'cacheEntries', 'cacheHits', 'cacheMisses', 'cacheDeferredInserts', 'cacheDeferredLookups', 'cacheLookupCollisions', 'cacheInsertCollisions', 'cacheTTLTooShorts', 'cacheCleanupCount']:
4ace9fe8
RG
166 self.assertIn(key, pool)
167
27792330 168 for key in ['id', 'cacheSize', 'cacheEntries', 'cacheHits', 'cacheMisses', 'cacheDeferredInserts', 'cacheDeferredLookups', 'cacheLookupCollisions', 'cacheInsertCollisions', 'cacheTTLTooShorts', 'cacheCleanupCount']:
4ace9fe8
RG
169 self.assertTrue(pool[key] >= 0)
170
14325153
RG
171 stats = content['statistics']
172 for key in self._expectedMetrics:
173 self.assertIn(key, stats)
174 self.assertTrue(stats[key] >= 0)
175 for key in stats:
176 self.assertIn(key, self._expectedMetrics)
177
bc6e4c3a
RG
178 def testServersLocalhostPool(self):
179 """
180 API: /api/v1/servers/localhost/pool?name=mypool
181 """
182 headers = {'x-api-key': self._webServerAPIKey}
183 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/pool?name=mypool'
184 r = requests.get(url, headers=headers, timeout=self._webTimeout)
185 self.assertTrue(r)
186 self.assertEqual(r.status_code, 200)
187 self.assertTrue(r.json())
188 content = r.json()
189
190 self.assertIn('stats', content)
191 self.assertIn('servers', content)
192
193 for key in ['name', 'cacheSize', 'cacheEntries', 'cacheHits', 'cacheMisses', 'cacheDeferredInserts', 'cacheDeferredLookups', 'cacheLookupCollisions', 'cacheInsertCollisions', 'cacheTTLTooShorts']:
194 self.assertIn(key, content['stats'])
195
196 for key in ['cacheSize', 'cacheEntries', 'cacheHits', 'cacheMisses', 'cacheDeferredInserts', 'cacheDeferredLookups', 'cacheLookupCollisions', 'cacheInsertCollisions', 'cacheTTLTooShorts']:
197 self.assertTrue(content['stats'][key] >= 0)
198
199 for server in content['servers']:
200 for key in ['id', 'latency', 'name', 'weight', 'outstanding', 'qpsLimit',
201 'reuseds', 'state', 'address', 'pools', 'qps', 'queries', 'order', 'sendErrors',
d70f95ac 202 'dropRate', 'responses', 'nonCompliantResponses', 'tcpDiedSendingQuery', 'tcpDiedReadingResponse',
bc6e4c3a
RG
203 'tcpGaveUp', 'tcpReadTimeouts', 'tcpWriteTimeouts', 'tcpCurrentConnections',
204 'tcpNewConnections', 'tcpReusedConnections', 'tcpAvgQueriesPerConnection',
7f7167e0 205 'tcpAvgConnectionDuration', 'tcpLatency', 'protocol']:
bc6e4c3a
RG
206 self.assertIn(key, server)
207
208 for key in ['id', 'latency', 'weight', 'outstanding', 'qpsLimit', 'reuseds',
d70f95ac 209 'qps', 'queries', 'order', 'tcpLatency', 'responses', 'nonCompliantResponses']:
bc6e4c3a
RG
210 self.assertTrue(server[key] >= 0)
211
212 self.assertTrue(server['state'] in ['up', 'down', 'UP', 'DOWN'])
213
00566cbf
PL
214 def testServersIDontExist(self):
215 """
ef2ea4bf 216 API: /api/v1/servers/idonotexist (should be 404)
00566cbf
PL
217 """
218 headers = {'x-api-key': self._webServerAPIKey}
ef2ea4bf 219 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/idonotexist'
00566cbf 220 r = requests.get(url, headers=headers, timeout=self._webTimeout)
4bfebc93 221 self.assertEqual(r.status_code, 404)
00566cbf 222
02bbf9eb
RG
223 def testServersLocalhostConfig(self):
224 """
225 API: /api/v1/servers/localhost/config
226 """
227 headers = {'x-api-key': self._webServerAPIKey}
228 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/config'
229 r = requests.get(url, headers=headers, timeout=self._webTimeout)
230 self.assertTrue(r)
4bfebc93 231 self.assertEqual(r.status_code, 200)
02bbf9eb
RG
232 self.assertTrue(r.json())
233 content = r.json()
234 values = {}
235 for entry in content:
236 for key in ['type', 'name', 'value']:
237 self.assertIn(key, entry)
238
4bfebc93 239 self.assertEqual(entry['type'], 'ConfigSetting')
02bbf9eb
RG
240 values[entry['name']] = entry['value']
241
242 for key in ['acl', 'control-socket', 'ecs-override', 'ecs-source-prefix-v4',
243 'ecs-source-prefix-v6', 'fixup-case', 'max-outstanding', 'server-policy',
244 'stale-cache-entries-ttl', 'tcp-recv-timeout', 'tcp-send-timeout',
245 'truncate-tc', 'verbose', 'verbose-health-checks']:
246 self.assertIn(key, values)
247
248 for key in ['max-outstanding', 'stale-cache-entries-ttl', 'tcp-recv-timeout',
249 'tcp-send-timeout']:
250 self.assertTrue(values[key] >= 0)
251
252 self.assertTrue(values['ecs-source-prefix-v4'] >= 0 and values['ecs-source-prefix-v4'] <= 32)
253 self.assertTrue(values['ecs-source-prefix-v6'] >= 0 and values['ecs-source-prefix-v6'] <= 128)
254
56d68fad
RG
255 def testServersLocalhostConfigAllowFrom(self):
256 """
257 API: /api/v1/servers/localhost/config/allow-from
258 """
259 headers = {'x-api-key': self._webServerAPIKey}
260 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/config/allow-from'
261 r = requests.get(url, headers=headers, timeout=self._webTimeout)
262 self.assertTrue(r)
4bfebc93 263 self.assertEqual(r.status_code, 200)
56d68fad
RG
264 self.assertTrue(r.json())
265 content = r.json()
266 for key in ['type', 'name', 'value']:
267 self.assertIn(key, content)
268
4bfebc93
CH
269 self.assertEqual(content['name'], 'allow-from')
270 self.assertEqual(content['type'], 'ConfigSetting')
078efd26
RG
271 acl = content['value']
272 expectedACL = ["127.0.0.1/32", "::1/128"]
273 acl.sort()
274 expectedACL.sort()
4bfebc93 275 self.assertEqual(acl, expectedACL)
56d68fad
RG
276
277 def testServersLocalhostConfigAllowFromPut(self):
278 """
279 API: PUT /api/v1/servers/localhost/config/allow-from (should be refused)
280
281 The API is read-only by default, so this should be refused
282 """
283 newACL = ["192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"]
284 payload = json.dumps({"name": "allow-from",
285 "type": "ConfigSetting",
286 "value": newACL})
287 headers = {'x-api-key': self._webServerAPIKey}
288 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/config/allow-from'
289 r = requests.put(url, headers=headers, timeout=self._webTimeout, data=payload)
290 self.assertFalse(r)
4bfebc93 291 self.assertEqual(r.status_code, 405)
56d68fad 292
02bbf9eb
RG
293 def testServersLocalhostStatistics(self):
294 """
295 API: /api/v1/servers/localhost/statistics
296 """
297 headers = {'x-api-key': self._webServerAPIKey}
298 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/statistics'
299 r = requests.get(url, headers=headers, timeout=self._webTimeout)
300 self.assertTrue(r)
4bfebc93 301 self.assertEqual(r.status_code, 200)
02bbf9eb
RG
302 self.assertTrue(r.json())
303 content = r.json()
304 values = {}
305 for entry in content:
306 self.assertIn('type', entry)
307 self.assertIn('name', entry)
308 self.assertIn('value', entry)
4bfebc93 309 self.assertEqual(entry['type'], 'StatisticItem')
02bbf9eb
RG
310 values[entry['name']] = entry['value']
311
14325153 312 for key in self._expectedMetrics:
02bbf9eb
RG
313 self.assertIn(key, values)
314 self.assertTrue(values[key] >= 0)
315
dd46e5e3 316 for key in values:
14325153 317 self.assertIn(key, self._expectedMetrics)
dd46e5e3 318
02bbf9eb
RG
319 def testJsonstatStats(self):
320 """
321 API: /jsonstat?command=stats
322 """
323 headers = {'x-api-key': self._webServerAPIKey}
324 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/jsonstat?command=stats'
325 r = requests.get(url, headers=headers, timeout=self._webTimeout)
326 self.assertTrue(r)
4bfebc93 327 self.assertEqual(r.status_code, 200)
02bbf9eb
RG
328 self.assertTrue(r.json())
329 content = r.json()
330
14325153 331 for key in self._expectedMetrics:
02bbf9eb
RG
332 self.assertIn(key, content)
333 self.assertTrue(content[key] >= 0)
334
335 def testJsonstatDynblocklist(self):
336 """
337 API: /jsonstat?command=dynblocklist
338 """
339 headers = {'x-api-key': self._webServerAPIKey}
340 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/jsonstat?command=dynblocklist'
341 r = requests.get(url, headers=headers, timeout=self._webTimeout)
342 self.assertTrue(r)
4bfebc93 343 self.assertEqual(r.status_code, 200)
02bbf9eb
RG
344
345 content = r.json()
346
347 if content:
477c86a0 348 for key in ['reason', 'seconds', 'blocks', 'action']:
02bbf9eb
RG
349 self.assertIn(key, content)
350
351 for key in ['blocks']:
352 self.assertTrue(content[key] >= 0)
56d68fad 353
72bf42cd
RG
354class TestAPIServerDown(APITestsBase):
355 __test__ = True
36927800
RG
356 _config_template = """
357 setACL({"127.0.0.1/32", "::1/128"})
358 newServer{address="127.0.0.1:%s"}
359 getServer(0):setDown()
fa7e8b5d 360 webserver("127.0.0.1:%s")
cfe95ada 361 setWebserverConfig({password="%s", apiKey="%s"})
36927800
RG
362 """
363
364 def testServerDownNoLatencyLocalhost(self):
365 """
366 API: /api/v1/servers/localhost, no latency for a down server
367 """
368 headers = {'x-api-key': self._webServerAPIKey}
369 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost'
370 r = requests.get(url, headers=headers, timeout=self._webTimeout)
371 self.assertTrue(r)
4bfebc93 372 self.assertEqual(r.status_code, 200)
36927800
RG
373 self.assertTrue(r.json())
374 content = r.json()
375
4bfebc93 376 self.assertEqual(content['servers'][0]['latency'], None)
7f7167e0 377 self.assertEqual(content['servers'][0]['tcpLatency'], None)
36927800 378
72bf42cd
RG
379class TestAPIWritable(APITestsBase):
380 __test__ = True
56d68fad 381 _APIWriteDir = '/tmp'
412f99ef 382 _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed', '_APIWriteDir']
56d68fad
RG
383 _config_template = """
384 setACL({"127.0.0.1/32", "::1/128"})
385 newServer{address="127.0.0.1:%s"}
fa7e8b5d 386 webserver("127.0.0.1:%s")
cfe95ada 387 setWebserverConfig({password="%s", apiKey="%s"})
56d68fad
RG
388 setAPIWritable(true, "%s")
389 """
390
391 def testSetACL(self):
392 """
393 API: Set ACL
394 """
395 headers = {'x-api-key': self._webServerAPIKey}
396 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/api/v1/servers/localhost/config/allow-from'
397 r = requests.get(url, headers=headers, timeout=self._webTimeout)
398 self.assertTrue(r)
4bfebc93 399 self.assertEqual(r.status_code, 200)
56d68fad
RG
400 self.assertTrue(r.json())
401 content = r.json()
078efd26
RG
402 acl = content['value']
403 expectedACL = ["127.0.0.1/32", "::1/128"]
404 acl.sort()
405 expectedACL.sort()
4bfebc93 406 self.assertEqual(acl, expectedACL)
56d68fad
RG
407
408 newACL = ["192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"]
409 payload = json.dumps({"name": "allow-from",
410 "type": "ConfigSetting",
411 "value": newACL})
412 r = requests.put(url, headers=headers, timeout=self._webTimeout, data=payload)
413 self.assertTrue(r)
4bfebc93 414 self.assertEqual(r.status_code, 200)
56d68fad
RG
415 self.assertTrue(r.json())
416 content = r.json()
b4be3cc0
OM
417 acl = content['value']
418 acl.sort()
4bfebc93 419 self.assertEqual(acl, newACL)
56d68fad
RG
420
421 r = requests.get(url, headers=headers, timeout=self._webTimeout)
422 self.assertTrue(r)
4bfebc93 423 self.assertEqual(r.status_code, 200)
56d68fad
RG
424 self.assertTrue(r.json())
425 content = r.json()
b4be3cc0
OM
426 acl = content['value']
427 acl.sort()
4bfebc93 428 self.assertEqual(acl, newACL)
56d68fad
RG
429
430 configFile = self._APIWriteDir + '/' + 'acl.conf'
431 self.assertTrue(os.path.isfile(configFile))
432 fileContent = None
b4f23783 433 with open(configFile, 'rt') as f:
b4be3cc0
OM
434 header = f.readline()
435 body = f.readline()
436
4bfebc93 437 self.assertEqual(header, """-- Generated by the REST API, DO NOT EDIT\n""")
b4be3cc0
OM
438
439 self.assertIn(body, {
440 """setACL({"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"})\n""",
441 """setACL({"192.0.2.0/24", "203.0.113.0/24", "198.51.100.0/24"})\n""",
442 """setACL({"198.51.100.0/24", "192.0.2.0/24", "203.0.113.0/24"})\n""",
443 """setACL({"198.51.100.0/24", "203.0.113.0/24", "192.0.2.0/24"})\n""",
444 """setACL({"203.0.113.0/24", "192.0.2.0/24", "198.51.100.0/24"})\n""",
445 """setACL({"203.0.113.0/24", "198.51.100.0/24", "192.0.2.0/24"})\n"""
446 })
80dbd7d2 447
72bf42cd
RG
448class TestAPICustomHeaders(APITestsBase):
449 __test__ = True
32c97b56
CHB
450 # paths accessible using the API key only
451 _apiOnlyPath = '/api/v1/servers/localhost/config'
452 # paths accessible using basic auth only (list not exhaustive)
453 _basicOnlyPath = '/'
454 _consoleKey = DNSDistTest.generateConsoleKey()
455 _consoleKeyB64 = base64.b64encode(_consoleKey).decode('ascii')
412f99ef 456 _config_params = ['_consoleKeyB64', '_consolePort', '_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed']
32c97b56
CHB
457 _config_template = """
458 setKey("%s")
459 controlSocket("127.0.0.1:%s")
460 setACL({"127.0.0.1/32", "::1/128"})
461 newServer({address="127.0.0.1:%s"})
fa7e8b5d 462 webserver("127.0.0.1:%s")
cfe95ada 463 setWebserverConfig({password="%s", apiKey="%s", customHeaders={["X-Frame-Options"]="", ["X-Custom"]="custom"} })
32c97b56
CHB
464 """
465
466 def testBasicHeaders(self):
467 """
468 API: Basic custom headers
469 """
470
471 url = 'http://127.0.0.1:' + str(self._webServerPort) + self._basicOnlyPath
472
473 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
474 self.assertTrue(r)
4bfebc93
CH
475 self.assertEqual(r.status_code, 200)
476 self.assertEqual(r.headers.get('x-custom'), "custom")
32c97b56
CHB
477 self.assertFalse("x-frame-options" in r.headers)
478
479 def testBasicHeadersUpdate(self):
480 """
481 API: Basic update of custom headers
482 """
483
484 url = 'http://127.0.0.1:' + str(self._webServerPort) + self._basicOnlyPath
485 self.sendConsoleCommand('setWebserverConfig({customHeaders={["x-powered-by"]="dnsdist"}})')
486 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
487 self.assertTrue(r)
4bfebc93
CH
488 self.assertEqual(r.status_code, 200)
489 self.assertEqual(r.headers.get('x-powered-by'), "dnsdist")
32c97b56
CHB
490 self.assertTrue("x-frame-options" in r.headers)
491
72bf42cd
RG
492class TestStatsWithoutAuthentication(APITestsBase):
493 __test__ = True
fa7e8b5d
RG
494 # paths accessible using the API key only
495 _apiOnlyPath = '/api/v1/servers/localhost/config'
496 # paths accessible using basic auth only (list not exhaustive)
497 _basicOnlyPath = '/'
498 _noAuthenticationPaths = [ '/metrics', '/jsonstat?command=dynblocklist' ]
499 _consoleKey = DNSDistTest.generateConsoleKey()
500 _consoleKeyB64 = base64.b64encode(_consoleKey).decode('ascii')
412f99ef 501 _config_params = ['_consoleKeyB64', '_consolePort', '_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed']
fa7e8b5d
RG
502 _config_template = """
503 setKey("%s")
504 controlSocket("127.0.0.1:%s")
505 setACL({"127.0.0.1/32", "::1/128"})
506 newServer({address="127.0.0.1:%s"})
507 webserver("127.0.0.1:%s")
cfe95ada 508 setWebserverConfig({password="%s", apiKey="%s", statsRequireAuthentication=false })
fa7e8b5d
RG
509 """
510
511 def testAuth(self):
512 """
513 API: Stats do not require authentication
514 """
515
516 for path in self._noAuthenticationPaths:
517 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
518
519 r = requests.get(url, timeout=self._webTimeout)
520 self.assertTrue(r)
4bfebc93 521 self.assertEqual(r.status_code, 200)
fa7e8b5d
RG
522
523 # these should still require basic authentication
524 for path in [self._basicOnlyPath]:
525 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
526
527 r = requests.get(url, timeout=self._webTimeout)
4bfebc93 528 self.assertEqual(r.status_code, 401)
fa7e8b5d
RG
529
530 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
531 self.assertTrue(r)
4bfebc93 532 self.assertEqual(r.status_code, 200)
fa7e8b5d
RG
533
534 # these should still require API authentication
535 for path in [self._apiOnlyPath]:
536 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
537
538 r = requests.get(url, timeout=self._webTimeout)
4bfebc93 539 self.assertEqual(r.status_code, 401)
fa7e8b5d
RG
540
541 headers = {'x-api-key': self._webServerAPIKey}
542 r = requests.get(url, headers=headers, timeout=self._webTimeout)
543 self.assertTrue(r)
4bfebc93 544 self.assertEqual(r.status_code, 200)
32c97b56 545
72bf42cd
RG
546class TestAPIAuth(APITestsBase):
547 __test__ = True
80dbd7d2 548 _webServerBasicAuthPasswordNew = 'password'
2c0392a5 549 _webServerBasicAuthPasswordNewHashed = '$scrypt$ln=10,p=1,r=8$yefz8SAuT3lj3moXqUYvmw==$T98/RYMp76ZYNjd7MpAkcVXZEDqpLtrc3tQ52QflVBA='
80dbd7d2 550 _webServerAPIKeyNew = 'apipassword'
2c0392a5 551 _webServerAPIKeyNewHashed = '$scrypt$ln=9,p=1,r=8$y96I9nfkY0LWDQEdSUzWgA==$jiyn9QD36o9d0ADrlqiIBk4AKyQrkD1KYw3CexwtHp4='
80dbd7d2
CHB
552 # paths accessible using the API key only
553 _apiOnlyPath = '/api/v1/servers/localhost/config'
554 # paths accessible using basic auth only (list not exhaustive)
555 _basicOnlyPath = '/'
556 _consoleKey = DNSDistTest.generateConsoleKey()
557 _consoleKeyB64 = base64.b64encode(_consoleKey).decode('ascii')
412f99ef 558 _config_params = ['_consoleKeyB64', '_consolePort', '_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed']
80dbd7d2
CHB
559 _config_template = """
560 setKey("%s")
561 controlSocket("127.0.0.1:%s")
562 setACL({"127.0.0.1/32", "::1/128"})
563 newServer{address="127.0.0.1:%s"}
fa7e8b5d 564 webserver("127.0.0.1:%s")
cfe95ada 565 setWebserverConfig({password="%s", apiKey="%s"})
80dbd7d2
CHB
566 """
567
568 def testBasicAuthChange(self):
569 """
570 API: Basic Authentication updating credentials
571 """
572
80dbd7d2 573 url = 'http://127.0.0.1:' + str(self._webServerPort) + self._basicOnlyPath
cfe95ada 574 self.sendConsoleCommand('setWebserverConfig({{password="{}"}})'.format(self._webServerBasicAuthPasswordNewHashed))
32c97b56 575
80dbd7d2
CHB
576 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPasswordNew), timeout=self._webTimeout)
577 self.assertTrue(r)
4bfebc93 578 self.assertEqual(r.status_code, 200)
80dbd7d2
CHB
579
580 # Make sure the old password is not usable any more
80dbd7d2 581 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
4bfebc93 582 self.assertEqual(r.status_code, 401)
80dbd7d2
CHB
583
584 def testXAPIKeyChange(self):
585 """
586 API: X-Api-Key updating credentials
587 """
588
32c97b56 589 url = 'http://127.0.0.1:' + str(self._webServerPort) + self._apiOnlyPath
412f99ef 590 self.sendConsoleCommand('setWebserverConfig({{apiKey="{}"}})'.format(self._webServerAPIKeyNewHashed))
80dbd7d2
CHB
591
592 headers = {'x-api-key': self._webServerAPIKeyNew}
80dbd7d2
CHB
593 r = requests.get(url, headers=headers, timeout=self._webTimeout)
594 self.assertTrue(r)
4bfebc93 595 self.assertEqual(r.status_code, 200)
80dbd7d2
CHB
596
597 # Make sure the old password is not usable any more
598 headers = {'x-api-key': self._webServerAPIKey}
80dbd7d2 599 r = requests.get(url, headers=headers, timeout=self._webTimeout)
4bfebc93 600 self.assertEqual(r.status_code, 401)
80dbd7d2
CHB
601
602 def testBasicAuthOnlyChange(self):
603 """
604 API: X-Api-Key updated to none (disabled)
605 """
606
32c97b56 607 url = 'http://127.0.0.1:' + str(self._webServerPort) + self._apiOnlyPath
412f99ef 608 self.sendConsoleCommand('setWebserverConfig({{apiKey="{}"}})'.format(self._webServerAPIKeyNewHashed))
80dbd7d2
CHB
609
610 headers = {'x-api-key': self._webServerAPIKeyNew}
80dbd7d2
CHB
611 r = requests.get(url, headers=headers, timeout=self._webTimeout)
612 self.assertTrue(r)
4bfebc93 613 self.assertEqual(r.status_code, 200)
80dbd7d2
CHB
614
615 # now disable apiKey
32c97b56 616 self.sendConsoleCommand('setWebserverConfig({apiKey=""})')
80dbd7d2 617
80dbd7d2 618 r = requests.get(url, headers=headers, timeout=self._webTimeout)
4bfebc93 619 self.assertEqual(r.status_code, 401)
1c90c6bd 620
72bf42cd
RG
621class TestAPIACL(APITestsBase):
622 __test__ = True
1c90c6bd
RG
623 _consoleKey = DNSDistTest.generateConsoleKey()
624 _consoleKeyB64 = base64.b64encode(_consoleKey).decode('ascii')
412f99ef 625 _config_params = ['_consoleKeyB64', '_consolePort', '_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed']
1c90c6bd
RG
626 _config_template = """
627 setKey("%s")
628 controlSocket("127.0.0.1:%s")
629 setACL({"127.0.0.1/32", "::1/128"})
630 newServer{address="127.0.0.1:%s"}
fa7e8b5d 631 webserver("127.0.0.1:%s")
cfe95ada 632 setWebserverConfig({password="%s", apiKey="%s", acl="192.0.2.1"})
1c90c6bd
RG
633 """
634
635 def testACLChange(self):
636 """
637 API: Should be denied by ACL then allowed
638 """
639
640 url = 'http://127.0.0.1:' + str(self._webServerPort) + "/"
641 try:
642 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
643 self.assertTrue(False)
644 except requests.exceptions.ConnectionError as exp:
645 pass
646
647 # reset the ACL
648 self.sendConsoleCommand('setWebserverConfig({acl="127.0.0.1"})')
649
650 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
651 self.assertTrue(r)
4bfebc93 652 self.assertEqual(r.status_code, 200)
88d4fe87 653
80af53eb
RG
654class TestAPIWithoutAuthentication(APITestsBase):
655 __test__ = True
656 _apiPath = '/api/v1/servers/localhost/config'
657 # paths accessible using basic auth only (list not exhaustive)
658 _basicOnlyPath = '/'
659 _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed']
660 _config_template = """
661 setACL({"127.0.0.1/32", "::1/128"})
662 newServer({address="127.0.0.1:%s"})
663 webserver("127.0.0.1:%s")
664 setWebserverConfig({password="%s", apiRequiresAuthentication=false })
665 """
666
667 def testAuth(self):
668 """
669 API: API do not require authentication
670 """
671
672 for path in [self._apiPath]:
673 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
674
675 r = requests.get(url, timeout=self._webTimeout)
676 self.assertTrue(r)
677 self.assertEqual(r.status_code, 200)
678
679 # these should still require basic authentication
680 for path in [self._basicOnlyPath]:
681 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
682
683 r = requests.get(url, timeout=self._webTimeout)
684 self.assertEqual(r.status_code, 401)
685
686 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
687 self.assertTrue(r)
688 self.assertEqual(r.status_code, 200)
689
4bbed5cf
RG
690class TestDashboardWithoutAuthentication(APITestsBase):
691 __test__ = True
692 _basicPath = '/'
693 _config_params = ['_testServerPort', '_webServerPort']
694 _config_template = """
695 setACL({"127.0.0.1/32", "::1/128"})
696 newServer({address="127.0.0.1:%d"})
697 webserver("127.0.0.1:%d")
698 setWebserverConfig({ dashboardRequiresAuthentication=false })
699 """
700 _verboseMode=True
701
702 def testDashboard(self):
703 """
704 API: Dashboard do not require authentication
705 """
706
707 for path in [self._basicPath]:
708 url = 'http://127.0.0.1:' + str(self._webServerPort) + path
709
710 r = requests.get(url, timeout=self._webTimeout)
711 self.assertTrue(r)
712 self.assertEqual(r.status_code, 200)
713
72bf42cd
RG
714class TestCustomLuaEndpoint(APITestsBase):
715 __test__ = True
88d4fe87
RG
716 _config_template = """
717 setACL({"127.0.0.1/32", "::1/128"})
718 newServer{address="127.0.0.1:%s"}
72bf42cd 719 webserver("127.0.0.1:%s")
cfe95ada 720 setWebserverConfig({password="%s"})
88d4fe87
RG
721
722 function customHTTPHandler(req, resp)
723 if req.path ~= '/foo' then
724 resp.status = 500
725 return
726 end
727
728 if req.version ~= 11 then
729 resp.status = 501
730 return
731 end
732
733 if req.method ~= 'GET' then
734 resp.status = 502
735 return
736 end
737
738 local get = req.getvars
739 if get['param'] ~= '42' then
740 resp.status = 503
741 return
742 end
743
744 local headers = req.headers
745 if headers['customheader'] ~= 'foobar' then
746 resp.status = 504
747 return
748 end
749
750 resp.body = 'It works!'
751 resp.status = 200
752 resp.headers = { ['Foo']='Bar'}
753 end
754 registerWebHandler('/foo', customHTTPHandler)
755 """
72bf42cd 756 _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed']
88d4fe87
RG
757
758 def testBasic(self):
759 """
760 Custom Web Handler
761 """
762 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/foo?param=42'
763 headers = {'customheader': 'foobar'}
764 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout, headers=headers)
765 self.assertTrue(r)
4bfebc93
CH
766 self.assertEqual(r.status_code, 200)
767 self.assertEqual(r.content, b'It works!')
768 self.assertEqual(r.headers.get('foo'), "Bar")
5e40d2a5 769
72bf42cd
RG
770class TestWebConcurrentConnections(APITestsBase):
771 __test__ = True
5e40d2a5
RG
772 _maxConns = 2
773
412f99ef 774 _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed', '_maxConns']
5e40d2a5
RG
775 _config_template = """
776 newServer{address="127.0.0.1:%s"}
777 webserver("127.0.0.1:%s")
cfe95ada 778 setWebserverConfig({password="%s", apiKey="%s", maxConcurrentConnections=%d})
5e40d2a5
RG
779 """
780
13e4e845
RG
781 @classmethod
782 def setUpClass(cls):
783 cls.startResponders()
784 cls.startDNSDist()
785 cls.setUpSockets()
786 # do no check if the web server socket is up, because this
787 # might mess up the concurrent connections counter
788
5e40d2a5
RG
789 def testConcurrentConnections(self):
790 """
791 Web: Concurrent connections
792 """
793
794 conns = []
795 # open the maximum number of connections
796 for _ in range(self._maxConns):
797 conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
798 conn.connect(("127.0.0.1", self._webServerPort))
799 conns.append(conn)
800
801 # we now hold all the slots, let's try to establish a new connection
802 url = 'http://127.0.0.1:' + str(self._webServerPort) + "/"
803 self.assertRaises(requests.exceptions.ConnectionError, requests.get, url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
804
805 # free one slot
806 conns[0].close()
807 conns[0] = None
808 time.sleep(1)
809
810 # this should work
811 r = requests.get(url, auth=('whatever', self._webServerBasicAuthPassword), timeout=self._webTimeout)
812 self.assertTrue(r)
4bfebc93 813 self.assertEqual(r.status_code, 200)
6211164a
CHB
814
815class TestAPICustomStatistics(APITestsBase):
816 __test__ = True
817 _maxConns = 2
818
819 _config_params = ['_testServerPort', '_webServerPort', '_webServerBasicAuthPasswordHashed', '_webServerAPIKeyHashed']
820 _config_template = """
821 newServer{address="127.0.0.1:%s"}
822 webserver("127.0.0.1:%s")
b08586c7
CHB
823 declareMetric("my-custom-metric", "counter", "Number of statistics")
824 declareMetric("my-other-metric", "counter", "Another number of statistics")
825 declareMetric("my-gauge", "gauge", "Current memory usage")
6211164a
CHB
826 setWebserverConfig({password="%s", apiKey="%s"})
827 """
828
829 def testCustomStats(self):
830 """
831 API: /jsonstat?command=stats
832 Test custom statistics are exposed
833 """
834 headers = {'x-api-key': self._webServerAPIKey}
835 url = 'http://127.0.0.1:' + str(self._webServerPort) + '/jsonstat?command=stats'
836 r = requests.get(url, headers=headers, timeout=self._webTimeout)
837 self.assertTrue(r)
838 self.assertEqual(r.status_code, 200)
839 self.assertTrue(r.json())
840 content = r.json()
841
842 expected = ['my-custom-metric', 'my-other-metric', 'my-gauge']
843
844 for key in expected:
845 self.assertIn(key, content)
846 self.assertTrue(content[key] >= 0)