Browse Source
Uses Klein to provide HTTP server support. Adds cookie based auth to requests (made JWT token based in later commits). Basic routes are: /unlock, /lock, /display, /create of wallet. Encapsulates WalletDaemon as a Service Add snicker receiver service start, stop Adds yg/maker function as stoppable service. Adds a JMShutdown command to the AMP protocol, allowing a clean shutdown of a long running bot (e.g. maker) by shutting down its message channel connections, without shutting down the entire process. Adds payment(direct send) request, first draftmaster
10 changed files with 712 additions and 23 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,420 @@
|
||||
#! /usr/bin/env python |
||||
|
||||
import datetime |
||||
import os |
||||
import time |
||||
import abc |
||||
import json |
||||
import atexit |
||||
from io import BytesIO |
||||
from twisted.python.log import startLogging |
||||
from twisted.internet import endpoints, reactor, ssl, task |
||||
from twisted.web.server import Site |
||||
from twisted.application.service import Service |
||||
from klein import Klein |
||||
|
||||
from optparse import OptionParser |
||||
from jmbase import get_log |
||||
from jmbitcoin import human_readable_transaction |
||||
from jmclient import Maker, jm_single, load_program_config, \ |
||||
JMClientProtocolFactory, start_reactor, calc_cj_fee, \ |
||||
WalletService, add_base_options, get_wallet_path, direct_send, \ |
||||
open_test_wallet_maybe, wallet_display, SegwitLegacyWallet, \ |
||||
SegwitWallet, get_daemon_serving_params, YieldGeneratorService, \ |
||||
SNICKERReceiverService, SNICKERReceiver, create_wallet, \ |
||||
StorageError, StoragePasswordError |
||||
from jmbase.support import EXIT_ARGERROR, EXIT_FAILURE |
||||
|
||||
jlog = get_log() |
||||
|
||||
# for debugging; twisted.web.server.Request objects do not easily serialize: |
||||
def print_req(request): |
||||
print(request) |
||||
print(request.method) |
||||
print(request.uri) |
||||
print(request.args) |
||||
print(request.path) |
||||
print(request.content) |
||||
print(list(request.requestHeaders.getAllRawHeaders())) |
||||
|
||||
class NotAuthorized(Exception): |
||||
pass |
||||
|
||||
class NoWalletFound(Exception): |
||||
pass |
||||
|
||||
class InvalidRequestFormat(Exception): |
||||
pass |
||||
|
||||
class BackendNotReady(Exception): |
||||
pass |
||||
|
||||
# error class for services which are only |
||||
# started once: |
||||
class ServiceAlreadyStarted(Exception): |
||||
pass |
||||
|
||||
# for the special case of the wallet service: |
||||
class WalletAlreadyUnlocked(Exception): |
||||
pass |
||||
|
||||
class ServiceNotStarted(Exception): |
||||
pass |
||||
|
||||
def get_ssl_context(cert_directory): |
||||
"""Construct an SSL context factory from the user's privatekey/cert. |
||||
TODO: |
||||
Currently just hardcoded for tests. |
||||
""" |
||||
return ssl.DefaultOpenSSLContextFactory(os.path.join(cert_directory, "key.pem"), |
||||
os.path.join(cert_directory, "cert.pem")) |
||||
|
||||
def response(request, succeed=True, status=200, **kwargs): |
||||
""" |
||||
Build the response body as JSON and set the proper content-type |
||||
header. |
||||
""" |
||||
request.setHeader('Content-Type', 'application/json') |
||||
request.setHeader('Access-Control-Allow-Origin', '*') |
||||
request.setResponseCode(status) |
||||
return json.dumps( |
||||
[{'succeed': succeed, 'status': status, **kwargs}]) |
||||
|
||||
class JMWalletDaemon(Service): |
||||
""" This class functions as an HTTP/TLS server, |
||||
with acccess control, allowing a single client(user) |
||||
to control functioning of encapsulated Joinmarket services. |
||||
""" |
||||
|
||||
app = Klein() |
||||
def __init__(self, port): |
||||
""" Port is the port to serve this daemon |
||||
(using HTTP/TLS). |
||||
""" |
||||
# cookie tracks single user's state. |
||||
self.cookie = None |
||||
self.port = port |
||||
# the collection of services which this |
||||
# daemon may switch on and off: |
||||
self.services = {} |
||||
# master single wallet service which we |
||||
# allow the client to start/stop. |
||||
self.services["wallet"] = None |
||||
# label for convenience: |
||||
self.wallet_service = self.services["wallet"] |
||||
# Client may start other services, but only |
||||
# one instance. |
||||
self.services["snicker"] = None |
||||
self.services["maker"] = None |
||||
# ensure shut down does not leave dangling services: |
||||
atexit.register(self.stopService) |
||||
|
||||
def startService(self): |
||||
""" Encapsulates start up actions. |
||||
Here starting the TLS server. |
||||
""" |
||||
super().startService() |
||||
# we do not auto-start any service, including the base |
||||
# wallet service, since the client must actively request |
||||
# that with the appropriate credential (password). |
||||
reactor.listenSSL(self.port, Site(self.app.resource()), |
||||
contextFactory=get_ssl_context(".")) |
||||
|
||||
def stopService(self): |
||||
""" Encapsulates shut down actions. |
||||
""" |
||||
# Currently valid authorization tokens must be removed |
||||
# from the daemon: |
||||
self.cookie = None |
||||
# if the wallet-daemon is shut down, all services |
||||
# it encapsulates must also be shut down. |
||||
for name, service in self.services.items(): |
||||
if service: |
||||
service.stopService() |
||||
super().stopService() |
||||
|
||||
@app.handle_errors(NotAuthorized) |
||||
def not_authorized(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return "Invalid credentials." |
||||
|
||||
@app.handle_errors(NoWalletFound) |
||||
def no_wallet_found(self, request, failure): |
||||
request.setResponseCode(404) |
||||
return "No wallet loaded." |
||||
|
||||
@app.handle_errors(BackendNotReady) |
||||
def backend_not_ready(self, request, failure): |
||||
request.setResponseCode(500) |
||||
return "Backend daemon not available" |
||||
|
||||
@app.handle_errors(InvalidRequestFormat) |
||||
def invalid_request_format(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return "Invalid request format." |
||||
|
||||
@app.handle_errors(ServiceAlreadyStarted) |
||||
def service_already_started(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return "Service already started." |
||||
|
||||
@app.handle_errors(WalletAlreadyUnlocked) |
||||
def wallet_already_unlocked(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return "Wallet already unlocked." |
||||
|
||||
def service_not_started(self, request, failure): |
||||
request.setResponseCode(401) |
||||
return "Service cannot be stopped as it is not running." |
||||
|
||||
def check_cookie(self, request): |
||||
request_cookie = request.getHeader(b"JMCookie") |
||||
if self.cookie != request_cookie: |
||||
jlog.warn("Invalid cookie: " + str( |
||||
request_cookie) + ", request rejected.") |
||||
raise NotAuthorized() |
||||
|
||||
@app.route('/wallet/<string:walletname>/display', methods=['GET']) |
||||
def displaywallet(self, request, walletname): |
||||
print_req(request) |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
print("called display but no wallet loaded") |
||||
raise NoWalletFound() |
||||
else: |
||||
walletinfo = wallet_display(self.wallet_service, False, jsonified=True) |
||||
return response(request, walletname=walletname, walletinfo=walletinfo) |
||||
|
||||
# handling CORS preflight for any route: |
||||
@app.route('/', branch=True, methods=['OPTIONS']) |
||||
def preflight(self, request): |
||||
print_req(request) |
||||
request.setHeader("Access-Control-Allow-Origin", "*") |
||||
request.setHeader("Access-Control-Allow-Methods", "POST") |
||||
# "Cookie" is reserved so we specifically allow our custom cookie using |
||||
# name "JMCookie". |
||||
request.setHeader("Access-Control-Allow-Headers", "Content-Type, JMCookie") |
||||
|
||||
@app.route('/wallet/<string:walletname>/snicker/start', methods=['GET']) |
||||
def start_snicker(self, request, walletname): |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if self.services["snicker"] and self.services["snicker"].isRunning(): |
||||
raise ServiceAlreadyStarted() |
||||
# TODO: allow client to inject acceptance callbacks to Receiver |
||||
self.services["snicker"] = SNICKERReceiverService( |
||||
SNICKERReceiver(self.wallet_service)) |
||||
self.services["snicker"].startService() |
||||
# TODO waiting for startup seems perhaps not needed here? |
||||
return response(request, walletname=walletname) |
||||
|
||||
@app.route('/wallet/<string:walletname>/snicker/stop', methods=['GET']) |
||||
def stop_snicker(self, request, walletname): |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.services["snicker"]: |
||||
raise ServiceNotStarted() |
||||
self.services["snicker"].stopService() |
||||
return response(request, walletname=walletname) |
||||
|
||||
@app.route('/wallet/<string:walletname>/taker/direct-send', methods=['POST']) |
||||
def send_direct(self, request, walletname): |
||||
""" Use the contents of the POST body to do a direct send from |
||||
the active wallet at the chosen mixdepth. |
||||
""" |
||||
assert isinstance(request.content, BytesIO) |
||||
payment_info_json = self.get_POST_body(request, ["mixdepth", "amount_sats", |
||||
"destination"]) |
||||
if not payment_info_json: |
||||
raise InvalidRequestFormat() |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
tx = direct_send(self.wallet_service, payment_info_json["amount_sats"], |
||||
payment_info_json["mixdepth"], |
||||
optin_rbf=payment_info_json["optin_rbf"], |
||||
return_transaction=True) |
||||
return response(request, walletname=walletname, |
||||
txinfo=human_readable_transaction(tx)) |
||||
|
||||
@app.route('/wallet/<string:walletname>/maker/start', methods=['POST']) |
||||
def start_maker(self, request, walletname): |
||||
""" Use the configuration in the POST body to start the yield generator: |
||||
""" |
||||
assert isinstance(request.content, BytesIO) |
||||
config_json = self.get_POST_body(request, ["txfee", "cjfee_a", "cjfee_r", |
||||
"ordertype", "minsize"]) |
||||
if not config_json: |
||||
raise InvalidRequestFormat() |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
|
||||
# daemon must be up before this is started; check: |
||||
daemon_serving_host, daemon_serving_port = get_daemon_serving_params() |
||||
if daemon_serving_port == -1 or daemon_serving_host == "": |
||||
raise BackendNotReady() |
||||
|
||||
self.services["maker"] = YieldGeneratorService(self.wallet_service, |
||||
daemon_serving_host, daemon_serving_port, |
||||
[config_json[x] for x in ["txfee", "cjfee_a", |
||||
"cjfee_r", "ordertype", "minsize"]]) |
||||
self.services["maker"].startService() |
||||
return response(request, walletname=walletname) |
||||
|
||||
@app.route('/wallet/<string:walletname>/maker/stop', methods=['GET']) |
||||
def stop_maker(self, request, walletname): |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
raise NoWalletFound() |
||||
if not self.services["maker"]: |
||||
raise ServiceNotStarted() |
||||
self.services["maker"].stopService() |
||||
return response(request, walletname=walletname) |
||||
|
||||
@app.route('/wallet/<string:walletname>/lock', methods=['GET']) |
||||
def lockwallet(self, request, walletname): |
||||
print_req(request) |
||||
self.check_cookie(request) |
||||
if not self.wallet_service: |
||||
print("called lock but no wallet loaded") |
||||
raise NoWalletFound() |
||||
else: |
||||
self.wallet_service.stopService() |
||||
self.wallet_service = None |
||||
# success status implicit: |
||||
return response(request, walletname=walletname) |
||||
|
||||
def get_POST_body(self, request, keys): |
||||
""" given a request object, retrieve values corresponding |
||||
to keys keys in a dict, assuming they were encoded using JSON. |
||||
If *any* of the keys are not present, return False, else |
||||
returns a dict of those key-value pairs. |
||||
""" |
||||
assert isinstance(request.content, BytesIO) |
||||
json_data = json.loads(request.content.read().decode("utf-8")) |
||||
retval = {} |
||||
for k in keys: |
||||
if k in json_data: |
||||
retval[k] = json_data[k] |
||||
else: |
||||
return False |
||||
return retval |
||||
|
||||
@app.route('/wallet/create', methods=["POST"]) |
||||
def createwallet(self, request): |
||||
print_req(request) |
||||
|
||||
# we only handle one wallet at a time; |
||||
# if there is a currently unlocked wallet, |
||||
# refuse to process the request: |
||||
if self.wallet_service: |
||||
raise WalletAlreadyUnlocked() |
||||
|
||||
request_data = self.get_POST_body(request, |
||||
["walletname", "password", "wallettype"]) |
||||
if not request_data or request_data["wallettype"] not in [ |
||||
"sw", "sw-legacy"]: |
||||
raise InvalidRequestFormat() |
||||
|
||||
wallet_cls = SegwitWallet if request_data[ |
||||
"wallettype"]=="sw" else SegwitLegacyWallet |
||||
|
||||
# use the config's data location combined with the json |
||||
# data to construct the wallet path: |
||||
wallet_root_path = os.path.join(jm_single().datadir, "wallets") |
||||
wallet_name = os.path.join(wallet_root_path, request_data["walletname"]) |
||||
try: |
||||
wallet = create_wallet(wallet_name, request_data["password"], |
||||
4, wallet_cls=wallet_cls) |
||||
except StorageError as e: |
||||
raise NotAuthorized(repr(e)) |
||||
|
||||
# finally, after the wallet is successfully created, we should |
||||
# start the wallet service: |
||||
return self.initialize_wallet_service(request, wallet) |
||||
|
||||
def initialize_wallet_service(self, request, wallet): |
||||
""" Called only when the wallet has loaded correctly, so |
||||
authorization is passed, so set cookie for this wallet |
||||
(currently THE wallet, daemon does not yet support multiple). |
||||
This is maintained for as long as the daemon is active (i.e. |
||||
no expiry currently implemented), or until the user switches |
||||
to a new wallet. |
||||
""" |
||||
self.cookie = request.getHeader(b"JMCookie") |
||||
if self.cookie is None: |
||||
raise NotAuthorized("No cookie") |
||||
|
||||
# the daemon blocks here until the wallet synchronization |
||||
# from the blockchain interface completes; currently this is |
||||
# fine as long as the client handles the response asynchronously: |
||||
self.wallet_service = WalletService(wallet) |
||||
while not self.wallet_service.synced: |
||||
self.wallet_service.sync_wallet(fast=True) |
||||
self.wallet_service.startService() |
||||
# now that the WalletService instance is active and ready to |
||||
# respond to requests, we return the status to the client: |
||||
return response(request, |
||||
walletname=self.wallet_service.get_wallet_name(), |
||||
already_loaded=False) |
||||
|
||||
@app.route('/wallet/<string:walletname>/unlock', methods=['POST']) |
||||
def unlockwallet(self, request, walletname): |
||||
print_req(request) |
||||
assert isinstance(request.content, BytesIO) |
||||
auth_json = self.get_POST_body(request, ["password"]) |
||||
if not auth_json: |
||||
raise InvalidRequestFormat() |
||||
password = auth_json["password"] |
||||
if self.wallet_service is None: |
||||
wallet_path = get_wallet_path(walletname, None) |
||||
try: |
||||
wallet = open_test_wallet_maybe( |
||||
wallet_path, walletname, 4, |
||||
password=password.encode("utf-8"), |
||||
ask_for_password=False) |
||||
except StoragePasswordError: |
||||
raise NotAuthorized("invalid password") |
||||
except StorageError as e: |
||||
# e.g. .lock file exists: |
||||
raise NotAuthorized(repr(e)) |
||||
return self.initialize_wallet_service(request, wallet) |
||||
else: |
||||
print('wallet was already unlocked.') |
||||
return response(request, |
||||
walletname=self.wallet_service.get_wallet_name(), |
||||
already_loaded=True) |
||||
|
||||
def jmwalletd_main(): |
||||
import sys |
||||
parser = OptionParser(usage='usage: %prog [options] [wallet file]') |
||||
parser.add_option('-p', '--port', action='store', type='int', |
||||
dest='port', default=28183, |
||||
help='the port over which to serve RPC, default 28183') |
||||
# TODO: remove the non-relevant base options: |
||||
add_base_options(parser) |
||||
|
||||
(options, args) = parser.parse_args() |
||||
|
||||
load_program_config(config_path=options.datadir) |
||||
|
||||
if jm_single().bc_interface is None: |
||||
jlog.error("Running jmwallet-daemon requires configured " + |
||||
"blockchain source.") |
||||
sys.exit(EXIT_FAILURE) |
||||
jlog.info("Starting jmwalletd on port: " + str(options.port)) |
||||
|
||||
jm_wallet_daemon = JMWalletDaemon(options.port) |
||||
jm_wallet_daemon.startService() |
||||
|
||||
nodaemon = jm_single().config.getint("DAEMON", "no_daemon") |
||||
daemon = True if nodaemon == 1 else False |
||||
if jm_single().config.get("BLOCKCHAIN", "network") in ["regtest", "testnet"]: |
||||
startLogging(sys.stdout) |
||||
start_reactor(jm_single().config.get("DAEMON", "daemon_host"), |
||||
jm_single().config.getint("DAEMON", "daemon_port"), |
||||
None, daemon=daemon) |
||||
|
||||
if __name__ == "__main__": |
||||
jmwalletd_main() |
||||
Loading…
Reference in new issue