commit d3fb68575d17ec3e58ad72a1325b38e5267d5b26
parent 2fed2184526e16fda81dc9d9675788426c35543c
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:
2 files changed, 134 insertions(+), 119 deletions(-)
diff --git a/electrum/daemon.py b/electrum/daemon.py
@@ -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)
@@ -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()
@@ -268,14 +383,6 @@ class PayServer(Logger):
return ws
-class AuthenticationError(Exception):
- pass
-
-class AuthenticationInvalidOrMissing(AuthenticationError):
- pass
-
-class AuthenticationCredentialsInvalid(AuthenticationError):
- pass
class Daemon(Logger):
@@ -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
@@ -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:
@@ -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
@@ -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:
diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh
@@ -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