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