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