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