]>
Commit | Line | Data |
---|---|---|
02bbf9eb | 1 | #!/usr/bin/env python |
56d68fad | 2 | import os.path |
02bbf9eb | 3 | |
80dbd7d2 | 4 | import base64 |
56d68fad | 5 | import json |
02bbf9eb | 6 | import requests |
5e40d2a5 RG |
7 | import socket |
8 | import time | |
02bbf9eb RG |
9 | from dnsdisttests import DNSDistTest |
10 | ||
72bf42cd RG |
11 | class 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 | ||
27 | class 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 |
346 | class 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 |
370 | class 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 |
439 | class 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 |
483 | class 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 |
537 | class 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 |
612 | class 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 |
645 | class 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 |
701 | class 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) |