rpc_users.py raw
1 #!/usr/bin/env python3
2 # Copyright (c) 2015-2022 The Limenka developers
3 # Distributed under the MIT software license, see the accompanying
4 # file COPYING or http://www.opensource.org/licenses/mit-license.php.
5 """Test multiple RPC users."""
6
7 from test_framework.test_framework import LimenkaTestFramework
8 from test_framework.util import (
9 assert_equal,
10 str_to_b64str,
11 )
12
13 import http.client
14 import os
15 from pathlib import Path
16 import platform
17 import urllib.parse
18 import subprocess
19 from random import SystemRandom
20 import string
21 import configparser
22 import sys
23 from typing import Optional
24
25
26 def call_with_auth(node, user, password, *, uripath='/', method='getbestblockhash'):
27 url = urllib.parse.urlparse(node.url)
28 headers = {"Authorization": "Basic " + str_to_b64str('{}:{}'.format(user, password))}
29
30 conn = http.client.HTTPConnection(url.hostname, url.port)
31 conn.connect()
32 conn.request('POST', uripath, f'{{"method": "{method}"}}', headers)
33 resp = conn.getresponse()
34 resp.data = resp.read()
35 conn.close()
36 return resp
37
38
39 class HTTPBasicsTest(LimenkaTestFramework):
40 def add_options(self, parser):
41 self.add_wallet_options(parser)
42
43 def set_test_params(self):
44 self.num_nodes = 2
45 self.supports_cli = False
46
47 def conf_setup(self):
48 self.authinfo = []
49
50 #Append rpcauth to limenka.conf before initialization
51 self.rtpassword = "cA773lm788buwYe4g4WT+05pKyNruVKjQ25x3n0DQcM="
52 rpcauth = "rpcauth=rt:93648e835a54c573682c2eb19f882535$7681e9c5b74bdd85e78166031d2058e1069b3ed7ed967c93fc63abba06f31144"
53
54 self.rpcuser = "rpcuser💻"
55 self.rpcpassword = "rpcpassword🔑"
56
57 config = configparser.ConfigParser()
58 config.read_file(open(self.options.configfile))
59 gen_rpcauth = config['environment']['RPCAUTH']
60
61 # Generate RPCAUTH with specified password
62 self.rt2password = "8/F3uMDw4KSEbw96U3CA1C4X05dkHDN2BPFjTgZW4KI="
63 p = subprocess.Popen([sys.executable, gen_rpcauth, 'rt2', self.rt2password], stdout=subprocess.PIPE, text=True)
64 lines = p.stdout.read().splitlines()
65 rpcauth2 = lines[1]
66
67 # Generate RPCAUTH without specifying password
68 self.user = ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10))
69 p = subprocess.Popen([sys.executable, gen_rpcauth, self.user], stdout=subprocess.PIPE, text=True)
70 lines = p.stdout.read().splitlines()
71 rpcauth3 = lines[1]
72 self.password = lines[3]
73
74 # Generate rpcauthfile with one entry
75 username = 'rpcauth_single_' + ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10))
76 p = subprocess.Popen([sys.executable, gen_rpcauth, "--output", Path(self.options.tmpdir) / 'rpcauth_single', username], stdout=subprocess.PIPE, universal_newlines=True)
77 lines = p.stdout.read().splitlines()
78 self.authinfo.append( (username, lines[1]) )
79
80 # Generate rpcauthfile with two entries
81 username = 'rpcauth_multi1_' + ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10))
82 p = subprocess.Popen([sys.executable, gen_rpcauth, "--output", Path(self.options.tmpdir) / 'rpcauth_multi', username], stdout=subprocess.PIPE, universal_newlines=True)
83 lines = p.stdout.read().splitlines()
84 self.authinfo.append( (username, lines[1]) )
85 # Blank lines in between should get ignored
86 with open(Path(self.options.tmpdir) / 'rpcauth_multi', "a", encoding='utf8') as f:
87 f.write("\n\n")
88 username = 'rpcauth_multi2_' + ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10))
89 p = subprocess.Popen([sys.executable, gen_rpcauth, "--output", Path(self.options.tmpdir) / 'rpcauth_multi', username], stdout=subprocess.PIPE, universal_newlines=True)
90 lines = p.stdout.read().splitlines()
91 self.authinfo.append( (username, lines[1]) )
92
93 def gen_userpass(username_prefix, wallet_restrictions=None):
94 username = username_prefix + '_' + ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10))
95 p = subprocess.Popen([sys.executable, gen_rpcauth, username], stdout=subprocess.PIPE, universal_newlines=True)
96 lines = p.stdout.read().splitlines()
97 assert "\n" not in lines[1]
98 assert lines[1][:8] == 'rpcauth='
99 config_line = lines[1]
100 self.authinfo.append( (username, lines[3], wallet_restrictions) )
101 if not (wallet_restrictions is None):
102 config_line += ":" + wallet_restrictions
103 return config_line + "\n"
104
105 # Hand-generated rpcauthfile with one entry and no newline
106 with open(Path(self.options.tmpdir) / 'rpcauth_nonewline', "a", encoding='utf8') as f:
107 f.write(gen_userpass('rpcauth_nonewline')[8:-1])
108
109 if self.is_wallet_compiled():
110 # Hand-generated rpcauthfile with wallet restrictions
111 with open(Path(self.options.tmpdir) / 'rpcauth_walletrestricted', "a", encoding='utf8') as f:
112 f.write(gen_userpass('rpcauth_walletrestricted_allow_all', '')[8:])
113 f.write(gen_userpass('rpcauth_walletrestricted_allow_none', '-')[8:])
114 f.write(gen_userpass('rpcauth_walletrestricted_allow_one', 'limitedwallet1')[8:])
115 # Uses the same username as a privileged one, but with different passwords:
116 f.write(gen_userpass('rpcauth_walletrestricted_allow_all', 'limitedwallet1')[8:])
117 f.write(gen_userpass('rpcauth_walletrestricted_allow_all', 'limitedwallet2')[8:])
118 f.write(gen_userpass('rpcauth_walletrestricted_allow_all', '-')[8:])
119 f.write(gen_userpass('rpcauth_walletrestricted_allow_one', 'limitedwallet2')[8:])
120 f.write(gen_userpass('rpcauth_walletrestricted_allow_one', '-')[8:])
121
122 with open(self.nodes[0].datadir_path / "limenka.conf", "a", encoding="utf8") as f:
123 f.write(rpcauth + "\n")
124 f.write(rpcauth2 + "\n")
125 f.write(rpcauth3 + "\n")
126 f.write("rpcauthfile=rpcauth_single\n")
127 f.write("rpcauthfile=rpcauth_multi\n")
128 f.write("rpcauthfile=rpcauth_nonewline\n")
129 if self.is_wallet_compiled():
130 f.write("rpcauthfile=rpcauth_walletrestricted\n")
131 f.write(gen_userpass('rpcauth_walletrestricted2_allow_all', '') + "\n")
132 f.write(gen_userpass('rpcauth_walletrestricted2_allow_none', '-') + "\n")
133 f.write(gen_userpass('rpcauth_walletrestricted2_allow_one', 'limitedwallet1') + "\n")
134 # Uses the same username as a privileged one, but with different passwords:
135 f.write(gen_userpass('rpcauth_walletrestricted2_allow_all', 'limitedwallet1') + "\n")
136 f.write(gen_userpass('rpcauth_walletrestricted2_allow_all', 'limitedwallet2') + "\n")
137 f.write(gen_userpass('rpcauth_walletrestricted2_allow_all', '-') + "\n")
138 f.write(gen_userpass('rpcauth_walletrestricted2_allow_one', 'limitedwallet2') + "\n")
139 f.write(gen_userpass('rpcauth_walletrestricted2_allow_one', '-') + "\n")
140 with open(self.nodes[1].datadir_path / "limenka.conf", "a", encoding="utf8") as f:
141 f.write("rpcuser={}\n".format(self.rpcuser))
142 f.write("rpcpassword={}\n".format(self.rpcpassword))
143 self.restart_node(0)
144 self.restart_node(1)
145
146 def test_auth(self, node, user, password, wallet_restrictions=None):
147 self.log.info('Correct... %s (wallet_restrictions=%s)' % (user, wallet_restrictions))
148 assert_equal(200, call_with_auth(node, user, password).status)
149
150 self.log.info('Wrong...')
151 assert_equal(401, call_with_auth(node, user, password + 'wrong').status)
152
153 self.log.info('Wrong...')
154 assert_equal(401, call_with_auth(node, user + 'wrong', password).status)
155
156 self.log.info('Wrong...')
157 assert_equal(401, call_with_auth(node, user + 'wrong', password + 'wrong').status)
158
159 if not (wallet_restrictions is None):
160 for n in range(1, 3):
161 wallet_name = f'limitedwallet{n}'
162 self.log.info(f'{wallet_name}...')
163 resp = call_with_auth(node, user, password, uripath=f'/wallet/{wallet_name}', method='getwalletinfo')
164 if wallet_restrictions in ('', f'{wallet_name}'):
165 assert_equal(200, resp.status)
166 else:
167 assert_equal(500, resp.status)
168 assert b'"Requested wallet does not exist or is not loaded"' in resp.data
169
170 def test_rpccookieperms(self):
171 p = {
172 "owner": 0o600,
173 "group": 0o640,
174 "all": 0o644,
175 "440": 0o440,
176 "0640": 0o640,
177 "444": 0o444,
178 "1660": 0o1660,
179 }
180
181 if platform.system() == 'Windows':
182 self.log.info(f"Skip cookie file permissions checks as OS detected as: {platform.system()=}")
183 return
184
185 self.log.info('Check cookie file permissions can be set using -rpccookieperms')
186
187 cookie_file_path = self.nodes[1].chain_path / '.cookie'
188 PERM_BITS_UMASK = 0o7777
189
190 def test_perm(perm: Optional[str]):
191 if not perm:
192 perm = 'owner'
193 self.restart_node(1)
194 else:
195 self.restart_node(1, extra_args=[f"-rpccookieperms={perm}"])
196
197 file_stat = os.stat(cookie_file_path)
198 actual_perms = file_stat.st_mode & PERM_BITS_UMASK
199 expected_perms = p[perm]
200 assert_equal(expected_perms, actual_perms)
201 return actual_perms
202
203 # Remove any leftover rpc{user|password} config options from previous tests
204 self.nodes[1].replace_in_config([("rpcuser", "#rpcuser"), ("rpcpassword", "#rpcpassword")])
205
206 self.log.info('Check default cookie permission')
207 default_perms = test_perm(None)
208
209 self.log.info('Check custom cookie permissions')
210 for perm in p.keys():
211 test_perm(perm)
212
213 self.log.info('Check leaving cookie permissions alone')
214 unassigned_perms = os.stat(self.nodes[1].chain_path / 'debug.log').st_mode & PERM_BITS_UMASK
215 self.restart_node(1, extra_args=["-rpccookieperms=0"])
216 actual_perms = os.stat(cookie_file_path).st_mode & PERM_BITS_UMASK
217 assert_equal(unassigned_perms, actual_perms)
218 self.restart_node(1, extra_args=["-norpccookieperms"])
219 actual_perms = os.stat(cookie_file_path).st_mode & PERM_BITS_UMASK
220 assert_equal(unassigned_perms, actual_perms)
221
222 self.log.info('Check -norpccookieperms -rpccookieperms')
223 self.restart_node(1, extra_args=["-rpccookieperms=0", "-rpccookieperms=1"])
224 actual_perms = os.stat(cookie_file_path).st_mode & PERM_BITS_UMASK
225 assert_equal(default_perms, actual_perms)
226 self.restart_node(1, extra_args=["-norpccookieperms", "-rpccookieperms"])
227 actual_perms = os.stat(cookie_file_path).st_mode & PERM_BITS_UMASK
228 assert_equal(default_perms, actual_perms)
229 self.restart_node(1, extra_args=["-rpccookieperms=1660", "-norpccookieperms", "-rpccookieperms"])
230 actual_perms = os.stat(cookie_file_path).st_mode & PERM_BITS_UMASK
231 assert_equal(default_perms, actual_perms)
232
233 def test_norpccookiefile(self, node0_cookie_path):
234 assert self.nodes[0].is_node_stopped(), "We expect previous test to stopped the node"
235 assert not node0_cookie_path.exists()
236
237 self.log.info('Starting with -norpccookiefile')
238 # Start, but don't wait for RPC connection as TestNode.wait_for_rpc_connection() requires the cookie.
239 with self.nodes[0].busy_wait_for_debug_log([b'init message: Done loading']):
240 self.nodes[0].start(extra_args=["-norpccookiefile"])
241 assert not node0_cookie_path.exists()
242
243 self.log.info('Testing user/password authentication still works without cookie file')
244 assert_equal(200, call_with_auth(self.nodes[0], "rt", self.rtpassword).status)
245 # After confirming that we could log in, check that cookie file does not exist.
246 assert not node0_cookie_path.exists()
247
248 # Need to shut down in slightly unorthodox way since cookie auth can't be used
249 assert_equal(200, call_with_auth(self.nodes[0], "rt", self.rtpassword, method="stop").status)
250 self.nodes[0].wait_until_stopped()
251
252 def run_test(self):
253 self.conf_setup()
254 self.log.info('Check correctness of the rpcauth config option')
255 url = urllib.parse.urlparse(self.nodes[0].url)
256
257 if self.is_wallet_compiled():
258 self.nodes[0].createwallet('limitedwallet1')
259 self.nodes[0].createwallet('limitedwallet2')
260
261 self.test_auth(self.nodes[0], url.username, url.password)
262 self.test_auth(self.nodes[0], 'rt', self.rtpassword)
263 self.test_auth(self.nodes[0], 'rt2', self.rt2password)
264 self.test_auth(self.nodes[0], self.user, self.password)
265 for info in self.authinfo:
266 self.test_auth(self.nodes[0], *info)
267
268 self.log.info('Check correctness of the rpcuser/rpcpassword config options')
269 url = urllib.parse.urlparse(self.nodes[1].url)
270
271 self.test_auth(self.nodes[1], self.rpcuser, self.rpcpassword)
272
273 init_error = 'Error: Unable to start HTTP server. See debug log for details.'
274
275 self.log.info('Check blank -rpcauth is ignored')
276 rpcauth_abc = '-rpcauth=abc:$2e32c2f20c67e29c328dd64a4214180f18da9e667d67c458070fd856f1e9e5e7'
277 rpcauth_def = '-rpcauth=def:$fd7adb152c05ef80dccf50a1fa4c05d5a3ec6da95575fc312ae7c5d091836351'
278 self.restart_node(0, extra_args=['-rpcauth'])
279 self.restart_node(0, extra_args=['-rpcauth=', rpcauth_abc])
280 self.restart_node(0, extra_args=[rpcauth_def, '-rpcauth='])
281 # ...without disrupting usage of other -rpcauth tokens
282 assert_equal(200, call_with_auth(self.nodes[0], 'def', 'abc').status)
283 assert_equal(200, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status)
284 for info in self.authinfo:
285 assert_equal(200, call_with_auth(self.nodes[0], *info[:2]).status)
286
287 self.log.info('Check -norpcauth disables all previous -rpcauth* params')
288 self.restart_node(0, extra_args=[rpcauth_def, '-norpcauth'])
289 assert_equal(401, call_with_auth(self.nodes[0], 'def', 'abc').status)
290 assert_equal(401, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status)
291 for info in self.authinfo:
292 assert_equal(401, call_with_auth(self.nodes[0], *info[:2]).status)
293
294 self.log.info('Check -norpcauth can be reversed with -rpcauth')
295 self.restart_node(0, extra_args=[rpcauth_def, '-norpcauth', '-rpcauth'])
296 # FIXME: assert_equal(200, call_with_auth(self.nodes[0], 'def', 'abc').status)
297 assert_equal(200, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status)
298 for info in self.authinfo:
299 assert_equal(200, call_with_auth(self.nodes[0], *info[:2]).status)
300
301 self.log.info('Check -norpcauth followed by a specific -rpcauth=* restores config file -rpcauth=* values too')
302 self.restart_node(0, extra_args=[rpcauth_def, '-norpcauth', rpcauth_abc])
303 assert_equal(401, call_with_auth(self.nodes[0], 'def', 'abc').status)
304 assert_equal(200, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status)
305 for info in self.authinfo:
306 assert_equal(200, call_with_auth(self.nodes[0], *info[:2]).status)
307 self.restart_node(0, extra_args=[rpcauth_def, '-norpcauth', '-rpcauth='])
308 assert_equal(401, call_with_auth(self.nodes[0], 'def', 'abc').status)
309 assert_equal(200, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status)
310 for info in self.authinfo:
311 assert_equal(200, call_with_auth(self.nodes[0], *info[:2]).status)
312
313 self.log.info('Check -rpcauth are validated')
314 self.stop_node(0)
315 self.log.info('Check malformed -rpcauth')
316 self.nodes[0].assert_start_raises_init_error(expected_msg=init_error, extra_args=['-rpcauth=foo'])
317 self.nodes[0].assert_start_raises_init_error(expected_msg=init_error, extra_args=['-rpcauth=foo:bar'])
318 self.nodes[0].assert_start_raises_init_error(expected_msg=init_error, extra_args=['-rpcauth=foo:bar:baz'])
319 self.nodes[0].assert_start_raises_init_error(expected_msg=init_error, extra_args=['-rpcauth=foo$bar:baz'])
320 self.nodes[0].assert_start_raises_init_error(expected_msg=init_error, extra_args=['-rpcauth=foo$bar$baz'])
321
322 # pw = limenka
323 rpcauth_user1 = '-rpcauth=user1:6dd184e5e69271fdd69103464630014f$eb3d7ce67c4d1ff3564270519b03b636c0291012692a5fa3dd1d2075daedd07b'
324 rpcauth_user2 = '-rpcauth=user2:57b2f77c919eece63cfa46c2f06e46ae$266b63902f99f97eeaab882d4a87f8667ab84435c3799f2ce042ef5a994d620b'
325
326 self.log.info('Check -norpcauth disables previous -rpcauth params')
327 self.restart_node(0, extra_args=[rpcauth_user1, rpcauth_user2, '-norpcauth'])
328 assert_equal(401, call_with_auth(self.nodes[0], 'user1', 'limenka').status)
329 assert_equal(401, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status)
330 self.stop_node(0)
331
332 self.log.info('Check that failure to write cookie file will abort the node gracefully')
333 cookie_path = self.nodes[0].chain_path / ".cookie"
334 cookie_path_tmp = self.nodes[0].chain_path / ".cookie.tmp"
335 cookie_path_tmp.mkdir()
336 cookie_path_tmp_subdir = cookie_path_tmp / "subdir"
337 cookie_path_tmp_subdir.mkdir()
338 self.nodes[0].assert_start_raises_init_error(expected_msg=init_error)
339 cookie_path_tmp_subdir.rmdir()
340 cookie_path_tmp.rmdir()
341 assert not cookie_path.exists()
342 self.restart_node(0)
343 assert cookie_path.exists()
344 self.stop_node(0)
345
346 cookie_path.mkdir()
347 cookie_path_subdir = cookie_path / "subdir"
348 cookie_path_subdir.mkdir()
349 self.nodes[0].assert_start_raises_init_error(expected_msg=init_error)
350 cookie_path_subdir.rmdir()
351 cookie_path.rmdir()
352
353 self.log.info('Check that a non-writable cookie file will get replaced gracefully')
354 cookie_path.mkdir(mode=1)
355 self.restart_node(0)
356 self.stop_node(0)
357
358 self.test_rpccookieperms()
359
360 self.test_norpccookiefile(cookie_path)
361
362 if __name__ == '__main__':
363 HTTPBasicsTest(__file__).main()
364