tdaemon.py: Add authentication to Watchtower. Define abstract class AuthenticatedServer - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit d3fb68575d17ec3e58ad72a1325b38e5267d5b26 DIR parent 2fed2184526e16fda81dc9d9675788426c35543c HTML Author: ThomasV <thomasv@electrum.org> Date: Tue, 12 May 2020 10:02:22 +0200 daemon.py: Add authentication to Watchtower. Define abstract class AuthenticatedServer Diffstat: M electrum/daemon.py | 249 ++++++++++++++++--------------- M electrum/tests/regtest/regtest.sh | 4 +++- 2 files changed, 134 insertions(+), 119 deletions(-) --- DIR diff --git a/electrum/daemon.py b/electrum/daemon.py t@@ -140,13 +140,137 @@ def get_rpc_credentials(config: SimpleConfig) -> Tuple[str, str]: return rpc_user, rpc_password -class WatchTowerServer(Logger): +class AuthenticationError(Exception): + pass - def __init__(self, network, netaddress): +class AuthenticationInvalidOrMissing(AuthenticationError): + pass + +class AuthenticationCredentialsInvalid(AuthenticationError): + pass + +class AuthenticatedServer(Logger): + + def __init__(self, rpc_user, rpc_password): Logger.__init__(self) + self.rpc_user = rpc_user + self.rpc_password = rpc_password + self.auth_lock = asyncio.Lock() + + async def authenticate(self, headers): + if self.rpc_password == '': + # RPC authentication is disabled + return + auth_string = headers.get('Authorization', None) + if auth_string is None: + raise AuthenticationInvalidOrMissing('CredentialsMissing') + basic, _, encoded = auth_string.partition(' ') + if basic != 'Basic': + raise AuthenticationInvalidOrMissing('UnsupportedType') + encoded = to_bytes(encoded, 'utf8') + credentials = to_string(b64decode(encoded), 'utf8') + username, _, password = credentials.partition(':') + if not (constant_time_compare(username, self.rpc_user) + and constant_time_compare(password, self.rpc_password)): + await asyncio.sleep(0.050) + raise AuthenticationCredentialsInvalid('Invalid Credentials') + + async def handle(self, request): + async with self.auth_lock: + try: + await self.authenticate(request.headers) + except AuthenticationInvalidOrMissing: + return web.Response(headers={"WWW-Authenticate": "Basic realm=Electrum"}, + text='Unauthorized', status=401) + except AuthenticationCredentialsInvalid: + return web.Response(text='Forbidden', status=403) + request = await request.text() + response = await jsonrpcserver.async_dispatch(request, methods=self.methods) + if isinstance(response, jsonrpcserver.response.ExceptionResponse): + self.logger.error(f"error handling request: {request}", exc_info=response.exc) + # this exposes the error message to the client + response.message = str(response.exc) + if response.wanted: + return web.json_response(response.deserialized(), status=response.http_status) + else: + return web.Response() + + +class CommandsServer(AuthenticatedServer): + + def __init__(self, daemon, fd): + rpc_user, rpc_password = get_rpc_credentials(daemon.config) + AuthenticatedServer.__init__(self, rpc_user, rpc_password) + self.daemon = daemon + self.fd = fd + self.config = daemon.config + self.host = self.config.get('rpchost', '127.0.0.1') + self.port = self.config.get('rpcport', 0) + self.app = web.Application() + self.app.router.add_post("/", self.handle) + self.methods = jsonrpcserver.methods.Methods() + self.methods.add(self.ping) + self.methods.add(self.gui) + self.cmd_runner = Commands(config=self.config, network=self.daemon.network, daemon=self.daemon) + for cmdname in known_commands: + self.methods.add(getattr(self.cmd_runner, cmdname)) + self.methods.add(self.run_cmdline) + + async def run(self): + self.runner = web.AppRunner(self.app) + await self.runner.setup() + site = web.TCPSite(self.runner, self.host, self.port) + await site.start() + socket = site._server.sockets[0] + os.write(self.fd, bytes(repr((socket.getsockname(), time.time())), 'utf8')) + os.close(self.fd) + + async def ping(self): + return True + + async def gui(self, config_options): + if self.daemon.gui_object: + if hasattr(self.daemon.gui_object, 'new_window'): + path = self.config.get_wallet_path(use_gui_last_wallet=True) + self.daemon.gui_object.new_window(path, config_options.get('url')) + response = "ok" + else: + response = "error: current GUI does not support multiple windows" + else: + response = "Error: Electrum is running in daemon mode. Please stop the daemon first." + return response + + async def run_cmdline(self, config_options): + cmdname = config_options['cmd'] + cmd = known_commands[cmdname] + # arguments passed to function + args = [config_options.get(x) for x in cmd.params] + # decode json arguments + args = [json_decode(i) for i in args] + # options + kwargs = {} + for x in cmd.options: + kwargs[x] = config_options.get(x) + if cmd.requires_wallet: + kwargs['wallet_path'] = config_options.get('wallet_path') + func = getattr(self.cmd_runner, cmd.name) + # fixme: not sure how to retrieve message in jsonrpcclient + try: + result = await func(*args, **kwargs) + except Exception as e: + result = {'error':str(e)} + return result + + +class WatchTowerServer(AuthenticatedServer): + + def __init__(self, network, netaddress): self.addr = netaddress self.config = network.config self.network = network + watchtower_user = self.config.get('watchtower_user', '') + watchtower_password = self.config.get('watchtower_password', '') + AuthenticatedServer.__init__(self, watchtower_user, watchtower_password) self.lnwatcher = network.local_watchtower self.app = web.Application() self.app.router.add_post("/", self.handle) t@@ -154,15 +278,6 @@ class WatchTowerServer(Logger): self.methods.add(self.get_ctn) self.methods.add(self.add_sweep_tx) - async def handle(self, request): - request = await request.text() - self.logger.info(f'{request}') - response = await jsonrpcserver.async_dispatch(request, methods=self.methods) - if response.wanted: - return web.json_response(response.deserialized(), status=response.http_status) - else: - return web.Response() - async def run(self): self.runner = web.AppRunner(self.app) await self.runner.setup() t@@ -268,14 +383,6 @@ class PayServer(Logger): return ws -class AuthenticationError(Exception): - pass - -class AuthenticationInvalidOrMissing(AuthenticationError): - pass - -class AuthenticationCredentialsInvalid(AuthenticationError): - pass class Daemon(Logger): t@@ -284,7 +391,6 @@ class Daemon(Logger): @profiler def __init__(self, config: SimpleConfig, fd=None, *, listen_jsonrpc=True): Logger.__init__(self) - self.auth_lock = asyncio.Lock() self.running = False self.running_lock = threading.Lock() self.config = config t@@ -301,10 +407,12 @@ class Daemon(Logger): # path -> wallet; make sure path is standardized. self._wallets = {} # type: Dict[str, Abstract_Wallet] daemon_jobs = [] - # Setup JSONRPC server + # Setup commands server + self.commands_server = None if listen_jsonrpc: - daemon_jobs.append(self.start_jsonrpc(config, fd)) - # request server + self.commands_server = CommandsServer(self, fd) + daemon_jobs.append(self.commands_server.run()) + # pay server self.pay_server = None payserver_address = self.config.get_netaddress('payserver_address') if not config.get('offline') and payserver_address: t@@ -338,80 +446,6 @@ class Daemon(Logger): finally: self.logger.info("taskgroup stopped.") - async def authenticate(self, headers): - if self.rpc_password == '': - # RPC authentication is disabled - return - auth_string = headers.get('Authorization', None) - if auth_string is None: - raise AuthenticationInvalidOrMissing('CredentialsMissing') - basic, _, encoded = auth_string.partition(' ') - if basic != 'Basic': - raise AuthenticationInvalidOrMissing('UnsupportedType') - encoded = to_bytes(encoded, 'utf8') - credentials = to_string(b64decode(encoded), 'utf8') - username, _, password = credentials.partition(':') - if not (constant_time_compare(username, self.rpc_user) - and constant_time_compare(password, self.rpc_password)): - await asyncio.sleep(0.050) - raise AuthenticationCredentialsInvalid('Invalid Credentials') - - async def handle(self, request): - async with self.auth_lock: - try: - await self.authenticate(request.headers) - except AuthenticationInvalidOrMissing: - return web.Response(headers={"WWW-Authenticate": "Basic realm=Electrum"}, - text='Unauthorized', status=401) - except AuthenticationCredentialsInvalid: - return web.Response(text='Forbidden', status=403) - request = await request.text() - response = await jsonrpcserver.async_dispatch(request, methods=self.methods) - if isinstance(response, jsonrpcserver.response.ExceptionResponse): - self.logger.error(f"error handling request: {request}", exc_info=response.exc) - # this exposes the error message to the client - response.message = str(response.exc) - if response.wanted: - return web.json_response(response.deserialized(), status=response.http_status) - else: - return web.Response() - - async def start_jsonrpc(self, config: SimpleConfig, fd): - self.app = web.Application() - self.app.router.add_post("/", self.handle) - self.rpc_user, self.rpc_password = get_rpc_credentials(config) - self.methods = jsonrpcserver.methods.Methods() - self.methods.add(self.ping) - self.methods.add(self.gui) - self.cmd_runner = Commands(config=self.config, network=self.network, daemon=self) - for cmdname in known_commands: - self.methods.add(getattr(self.cmd_runner, cmdname)) - self.methods.add(self.run_cmdline) - self.host = config.get('rpchost', '127.0.0.1') - self.port = config.get('rpcport', 0) - self.runner = web.AppRunner(self.app) - await self.runner.setup() - site = web.TCPSite(self.runner, self.host, self.port) - await site.start() - socket = site._server.sockets[0] - os.write(fd, bytes(repr((socket.getsockname(), time.time())), 'utf8')) - os.close(fd) - - async def ping(self): - return True - - async def gui(self, config_options): - if self.gui_object: - if hasattr(self.gui_object, 'new_window'): - path = self.config.get_wallet_path(use_gui_last_wallet=True) - self.gui_object.new_window(path, config_options.get('url')) - response = "ok" - else: - response = "error: current GUI does not support multiple windows" - else: - response = "Error: Electrum is running in daemon mode. Please stop the daemon first." - return response - def load_wallet(self, path, password, *, manual_upgrades=True) -> Optional[Abstract_Wallet]: path = standardize_path(path) # wizard will be launched if we return t@@ -466,27 +500,6 @@ class Daemon(Logger): wallet.stop() return True - async def run_cmdline(self, config_options): - cmdname = config_options['cmd'] - cmd = known_commands[cmdname] - # arguments passed to function - args = [config_options.get(x) for x in cmd.params] - # decode json arguments - args = [json_decode(i) for i in args] - # options - kwargs = {} - for x in cmd.options: - kwargs[x] = config_options.get(x) - if cmd.requires_wallet: - kwargs['wallet_path'] = config_options.get('wallet_path') - func = getattr(self.cmd_runner, cmd.name) - # fixme: not sure how to retrieve message in jsonrpcclient - try: - result = await func(*args, **kwargs) - except Exception as e: - result = {'error':str(e)} - return result - def run_daemon(self): self.running = True try: DIR diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh t@@ -318,8 +318,10 @@ fi if [[ $1 == "configure_test_watchtower" ]]; then # carol is the watchtower of bob $carol setconfig -o run_local_watchtower true + $carol setconfig -o watchtower_user wtuser + $carol setconfig -o watchtower_password wtpassword $carol setconfig -o watchtower_address 127.0.0.1:12345 - $bob setconfig -o watchtower_url http://127.0.0.1:12345 + $bob setconfig -o watchtower_url http://wtuser:wtpassword@127.0.0.1:12345 fi if [[ $1 == "watchtower" ]]; then