diff --git a/common/constants.py b/common/constants.py index b333378d..ec297036 100644 --- a/common/constants.py +++ b/common/constants.py @@ -4,6 +4,7 @@ # HTTP HTTP_OK = 200 +HTTP_EMPTY = 204 HTTP_BAD_REQUEST = 400 HTTP_NOT_FOUND = 404 HTTP_SERVICE_UNAVAILABLE = 503 diff --git a/contrib/client/README.md b/contrib/client/README.md index d3542a96..15fbc13b 100644 --- a/contrib/client/README.md +++ b/contrib/client/README.md @@ -26,11 +26,19 @@ Refer to [INSTALL.md](INSTALL.md) The client has four commands: +- `ping`: pings the tower to check if it's online. - `register`: registers your user with the tower. - `add_appointment`: sends a json formatted appointment to the tower. - `get_appointment`: gets json formatted data about an appointment from the tower. - `help`: shows a list of commands or help for a specific command. +### ping +This command is used to check the status of the tower. + +#### Usage + + teos-client ping + ### register This commands serves as registration. It sends your public key to the tower to create a subscription (free at the moment) and returns a number of available appointment slots in the tower. Topping up the subscription can be done by simply sending a register message again. diff --git a/contrib/client/help.py b/contrib/client/help.py index af8c3281..808a7a8f 100644 --- a/contrib/client/help.py +++ b/contrib/client/help.py @@ -3,6 +3,7 @@ def show_usage(): "USAGE: " "\n\tteos-client [global options] command [command options] [arguments]" "\n\nCOMMANDS:" + "\n\tping \t\t\tPings the tower to check if it's online." "\n\tregister \t\tRegisters your user public key with the tower." "\n\tadd_appointment \tRegisters a json formatted appointment with the tower." "\n\tget_appointment \tGets json formatted data about an appointment from the tower." @@ -16,6 +17,17 @@ def show_usage(): ) +def help_ping(): + return ( + "NAME:" + "\n\n\tping" + "\n\nUSAGE:" + "\n\n\tteos-client ping" + "\n\nDESCRIPTION:" + "\n\n\tPings the tower to check if it is online." + ) + + def help_register(): return ( "NAME:" diff --git a/contrib/client/teos_client.py b/contrib/client/teos_client.py index 446cbdfb..032ba62b 100755 --- a/contrib/client/teos_client.py +++ b/contrib/client/teos_client.py @@ -29,6 +29,7 @@ help_get_appointment, help_get_subscription_info, help_register, + help_ping, ) logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -36,6 +37,35 @@ logger = logging.getLogger() +def ping(teos_url): + """ + Pings the tower to check if it is online. + + Args: + teos_url (:obj:`str`): the teos base url. + + Raises: + :obj:`TowerResponseError`: if the tower responded with an error, or the response was invalid. + :obj:`ConnectionError`: if the client cannot connect to the tower. + """ + + ping_endpoint = "{}/ping".format(teos_url) + + logger.info(f"Pinging the Eye of Satoshi at {teos_url}") + + try: + response = requests.get(ping_endpoint) + + if response.status_code == constants.HTTP_EMPTY: + logger.info(f"The Eye of Satoshi is alive") + else: + raise TowerResponseError( + "The server returned an error", status_code=response.status_code, reason=response.reason, data=response, + ) + except ConnectionError: + raise ConnectionError(f"The Eye of Satoshi is down") + + def register(user_id, teos_id, teos_url): """ Registers the user to the tower. @@ -51,8 +81,7 @@ def register(user_id, teos_id, teos_url): Raises: :obj:`InvalidParameter`: if `user_id` is invalid. :obj:`ConnectionError`: if the client cannot connect to the tower. - :obj:`TowerResponseError`: if the tower responded with an error, or the - response was invalid. + :obj:`TowerResponseError`: if the tower responded with an error, or the response was invalid. """ if not is_compressed_pk(user_id): @@ -460,6 +489,9 @@ def main(command, args, command_line_conf): Cryptographer.save_key_file(user_sk.to_der(), "user_sk", config.get("DATA_DIR")) user_id = Cryptographer.get_compressed_pk(user_sk.public_key) + if command == "ping": + ping(teos_url) + if command == "register": if not args: raise InvalidParameter("Cannot register. No tower id was given") @@ -515,6 +547,9 @@ def main(command, args, command_line_conf): if args: command = args.pop(0) + if command == "ping": + sys.exit(help_ping()) + if command == "register": sys.exit(help_register()) @@ -541,7 +576,7 @@ def main(command, args, command_line_conf): def run(): command_line_conf = {} - commands = ["register", "add_appointment", "get_appointment", "get_subscription_info", "help"] + commands = ["ping", "register", "add_appointment", "get_appointment", "get_subscription_info", "help"] try: opts, args = getopt(argv[1:], "h", ["apiconnect=", "apiport=", "help"]) diff --git a/contrib/client/test/test_teos_client.py b/contrib/client/test/test_teos_client.py index a5dc3858..3e962860 100644 --- a/contrib/client/test/test_teos_client.py +++ b/contrib/client/test/test_teos_client.py @@ -28,6 +28,7 @@ teos_url = "http://{}:{}".format(config.get("API_CONNECT"), config.get("API_PORT")) add_appointment_endpoint = "{}/add_appointment".format(teos_url) +ping_endpoint = "{}/ping".format(teos_url) register_endpoint = "{}/register".format(teos_url) get_appointment_endpoint = "{}/get_appointment".format(teos_url) get_all_appointments_endpoint = "{}/get_all_appointments".format(teos_url) @@ -83,6 +84,22 @@ def post_response(): } +@responses.activate +def test_ping(): + # Simulate a ping response with the tower offline + with pytest.raises(ConnectionError): + teos_client.ping(teos_url) + + # Simulate a ping response with the tower online + responses.add(responses.GET, ping_endpoint, status=204) + teos_client.ping(teos_url) + + # Simulate a ping response with the tower erroring + with pytest.raises(TowerResponseError): + responses.replace(responses.GET, ping_endpoint, status=404) + teos_client.ping(teos_url) + + @responses.activate def test_register(): # Simulate a register response diff --git a/teos/api.py b/teos/api.py index af91653c..49627d44 100644 --- a/teos/api.py +++ b/teos/api.py @@ -1,4 +1,6 @@ import grpc +import requests +from time import sleep from google.protobuf import json_format from waitress import serve as wsgi_serve from flask import Flask, request, jsonify @@ -7,7 +9,7 @@ from common.tools import intify from common.exceptions import InvalidParameter from common.appointment import AppointmentStatus -from common.constants import HTTP_OK, HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, HTTP_NOT_FOUND +from common.constants import HTTP_OK, HTTP_EMPTY, HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, HTTP_NOT_FOUND from teos.logger import setup_logging, get_logger from teos.inspector import Inspector, InspectionFailed @@ -63,7 +65,7 @@ def serve(internal_api_endpoint, endpoint, logging_port, min_to_self_delay, auto """ Starts the API. - This method can be handled either form an external WSGI (like gunicorn) or by the Flask development server. + This method can be handled either form an external WSGI (like gunicorn) or by the Waitress. Args: internal_api_endpoint (:obj:`str`): endpoint where the internal api is running (``host:port``). @@ -71,7 +73,7 @@ def serve(internal_api_endpoint, endpoint, logging_port, min_to_self_delay, auto logging_port (:obj:`int`): the port where the logging server can be reached (localhost:logging_port) min_to_self_delay (:obj:`str`): the minimum to_self_delay accepted by the :obj:`Inspector`. auto_run (:obj:`bool`): whether the server should be started by this process. False if run with an external - WSGI. True is run by Flask. + WSGI. True is run by Waitress. Returns: The application object needed by the WSGI server to run if ``auto_run`` is False, :obj:`None` otherwise. @@ -91,6 +93,23 @@ def serve(internal_api_endpoint, endpoint, logging_port, min_to_self_delay, auto return api.app +def wait_until_ready(api_endpoint): + """ + Waits until the API is ready by polling the info endpoint until an HTTP_OK is received. + + Args: + api_endpoint: endpoint where the http api will be running (``host:port``). + """ + + ping_endpoint = f"{api_endpoint}/ping" + if not api_endpoint.startswith("http"): + ping_endpoint = f"http://{ping_endpoint}" + + # Wait for the API + while requests.get(ping_endpoint).status_code != HTTP_EMPTY: + sleep(1) + + class API: """ The :class:`API` is in charge of the interface between the user and the tower. It handles and serves user requests. @@ -118,6 +137,7 @@ def __init__(self, inspector, internal_api_endpoint): # Adds all the routes to the functions listed above. routes = { + "/ping": (self.ping, ["GET"]), "/register": (self.register, ["POST"]), "/add_appointment": (self.add_appointment, ["POST"]), "/get_appointment": (self.get_appointment, ["POST"]), @@ -127,6 +147,19 @@ def __init__(self, inspector, internal_api_endpoint): for url, params in routes.items(): self.app.add_url_rule(url, view_func=params[0], methods=params[1]) + def ping(self): + """ + Ping endpoint. Serves the purpose of checking the status of the tower. + + Returns: + :obj:`tuple`: A tuple containing the response (:obj:`str`) and response code (:obj:`int`). Currently the + response is empty. + """ + + remote_addr = get_remote_addr() + self.logger.info("Received info request", from_addr="{}".format(remote_addr)) + return "", HTTP_EMPTY + def register(self): """ Registers a user by creating a subscription. diff --git a/teos/rpc.py b/teos/rpc.py index ad65b2e9..d4a87831 100644 --- a/teos/rpc.py +++ b/teos/rpc.py @@ -2,6 +2,7 @@ import functools from concurrent import futures from signal import signal, SIGINT +from multiprocessing import Event from teos.tools import ignore_signal from teos.logger import setup_logging, get_logger @@ -34,6 +35,7 @@ def __init__(self, rpc_bind, rpc_port, internal_api_endpoint): self.endpoint = f"{rpc_bind}:{rpc_port}" self.rpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) self.rpc_server.add_insecure_port(self.endpoint) + self.ready = Event() add_TowerServicesServicer_to_server(_RPC(internal_api_endpoint, self.logger), self.rpc_server) def teardown(self): @@ -99,7 +101,7 @@ def stop(self, request, context): return self.stub.stop(request) -def serve(rpc_bind, rpc_port, internal_api_endpoint, logging_port, stop_event): +def serve(rpc_bind, rpc_port, internal_api_endpoint, logging_port, rpc_ready, stop_event): """ Serves the external RPC API at the given endpoint and connects it to the internal api. @@ -121,6 +123,7 @@ def serve(rpc_bind, rpc_port, internal_api_endpoint, logging_port, stop_event): rpc.rpc_server.start() rpc.logger.info(f"Initialized. Serving at {rpc.endpoint}") + rpc_ready.set() stop_event.wait() diff --git a/teos/teosd.py b/teos/teosd.py index 84aa5474..fe9be725 100755 --- a/teos/teosd.py +++ b/teos/teosd.py @@ -1,20 +1,18 @@ import os import daemon +import threading import subprocess -from sys import argv, exit import multiprocessing -import threading - +from sys import argv, exit from getopt import getopt, GetoptError from signal import signal, SIGINT, SIGQUIT, SIGTERM +from common.tools import setup_data_folder from common.config_loader import ConfigLoader from common.cryptographer import Cryptographer -from common.tools import setup_data_folder import teos.api as api import teos.rpc as rpc -from teos.logger import setup_logging, get_logger, serve as serve_logging from teos.help import show_usage from teos.watcher import Watcher from teos.builder import Builder @@ -24,11 +22,12 @@ from teos.gatekeeper import Gatekeeper from teos.internal_api import InternalAPI from teos.chain_monitor import ChainMonitor +from teos.constants import SHUTDOWN_GRACE_TIME from teos.block_processor import BlockProcessor from teos.appointments_dbm import AppointmentsDBM from teos import DATA_DIR, DEFAULT_CONF, CONF_FILE_NAME +from teos.logger import setup_logging, get_logger, serve as serve_logging from teos.tools import can_connect_to_bitcoind, in_correct_network, get_default_rpc_port -from teos.constants import SHUTDOWN_GRACE_TIME parent_pid = os.getpid() @@ -64,9 +63,10 @@ class TeosDaemon: config (:obj:`dict`): the configuration object. sk (:obj:`PrivateKey`): the :obj:`PrivateKey` of the tower. logger (:obj:`Logger `): the logger instance. - logging_port (:obj:`int`): the port where the logging server can be reached (localhost:logging_port) - stop_log_event (:obj:`multiprocessing.Event`): the event to signal a stop to the logging server - logging_process (:obj:`multiprocessing.Process`): the logging server process + logging_port (:obj:`int`): the port where the logging server can be reached (localhost:logging_port). + teosd_ready (:obj:`multiprocessing.Event`): the event to signal that teosd is done bootstrapping. + stop_log_event (:obj:`multiprocessing.Event`): the event to signal a stop to the logging server. + logging_process (:obj:`multiprocessing.Process`): the logging server process. Attributes: stop_command_event (:obj:`threading.Event`): The event that will be set to initiate a graceful shutdown. @@ -88,13 +88,16 @@ class TeosDaemon: rpc_process (:obj:`multiprocessing.Process`): The instance of the internal RPC server; only set if running. """ - def __init__(self, config, sk, logger, logging_port, stop_log_event, logging_process): + def __init__(self, config, sk, logger, logging_port, teosd_ready, stop_log_event, logging_process): self.config = config self.logger = logger self.logging_port = logging_port self.stop_log_event = stop_log_event self.logging_process = logging_process + self.rpc_ready = multiprocessing.Event() + self.teosd_ready = teosd_ready + # event triggered when a ``stop`` command is issued # Using multiprocessing.Event seems to cause a deadlock if event.set() is called in a signal handler that # interrupted event.wait(). This does not happen with threading.Event. @@ -162,6 +165,7 @@ def __init__(self, config, sk, logger, logging_port, stop_log_event, logging_pro self.config.get("RPC_PORT"), self.internal_api_endpoint, self.logging_port, + self.rpc_ready, self.stop_event, ), daemon=True, @@ -302,6 +306,12 @@ def start_services(self, logging_port): ) self.api_proc.start() + self.rpc_ready.wait() + api.wait_until_ready(api_endpoint) + + self.teosd_ready.set() + self.logger.info("TEOS is up and running") + def handle_signals(self, signum, frame): """Handles signals by initiating a graceful shutdown.""" self.logger.debug(f"Signal {signum} received. Stopping") @@ -361,7 +371,7 @@ def start(self): self.teardown() -def main(config): +def main(config, teosd_ready): setup_data_folder(config.get("DATA_DIR")) logging_server_ready = multiprocessing.Event() @@ -397,7 +407,7 @@ def main(config): config["WSGI"] = "waitress" try: - TeosDaemon(config, sk, logger, logging_port.value, stop_logging_server, logging_process).start() + TeosDaemon(config, sk, logger, logging_port.value, teosd_ready, stop_logging_server, logging_process).start() except Exception as e: logger.error("An error occurred: {}. Shutting down".format(e)) stop_logging_server.set() @@ -492,13 +502,14 @@ def run(): exit(e) config = get_config(command_line_conf, data_dir) + teosd_ready = multiprocessing.Event() if config.get("DAEMON"): print("Starting TEOS") with daemon.DaemonContext(): - main(config) + main(config, teosd_ready) else: - main(config) + main(config, teosd_ready) if __name__ == "__main__": diff --git a/test/teos/e2e/conftest.py b/test/teos/e2e/conftest.py index 3672b2ce..928d06e7 100644 --- a/test/teos/e2e/conftest.py +++ b/test/teos/e2e/conftest.py @@ -1,7 +1,6 @@ import os import shutil import pytest -from time import sleep import multiprocessing from grpc import RpcError from multiprocessing import Process @@ -21,23 +20,9 @@ def teosd(run_bitcoind): yield teosd_process, teos_id - # FIXME: This is not ideal, but for some reason stop raises socket being closed on the first try here. - stopped = False - while not stopped: - try: - rpc_client = RPCClient(config.get("RPC_BIND"), config.get("RPC_PORT")) - rpc_client.stop() - stopped = True - except RpcError: - print("failed") - pass - - teosd_process.join() + stop_teosd(teosd_process) shutil.rmtree(".teos") - # FIXME: wait some time, otherwise it might fail when multiple e2e tests are ran in the same session. Not sure why. - sleep(1) - def run_teosd(): sk_file_path = os.path.join(config.get("DATA_DIR"), "teos_sk.der") @@ -49,20 +34,34 @@ def run_teosd(): teos_sk = Cryptographer.load_private_key_der(Cryptographer.load_key_file(sk_file_path)) teos_id = Cryptographer.get_compressed_pk(teos_sk.public_key) + teosd_ready = multiprocessing.Event() # Change the default WSGI for Windows if os.name == "nt": config["WSGI"] = "waitress" - teosd_process = Process(target=main, kwargs={"config": config}) + teosd_process = Process(target=main, kwargs={"config": config, "teosd_ready": teosd_ready}) teosd_process.start() - - # Give it some time to bootstrap - # TODO: we should do better synchronization using an Event - sleep(3) + teosd_ready.wait() return teosd_process, teos_id +def stop_teosd(teosd_process): + # FIXME: This is not ideal, but for some reason stop raises socket being closed on the first try here. + # Looks like this happens if the teosd process is restarted. + stopped = False + while not stopped: + try: + rpc_client = RPCClient(config.get("RPC_BIND"), config.get("RPC_PORT")) + rpc_client.stop() + stopped = True + except RpcError: + print("failed") + pass + + teosd_process.join() + + def build_appointment_data(commitment_tx_id, penalty_tx): appointment_data = {"tx": penalty_tx, "tx_id": commitment_tx_id, "to_self_delay": 20} diff --git a/test/teos/e2e/test_client_e2e.py b/test/teos/e2e/test_client_e2e.py index 36b6346c..e6afd24d 100644 --- a/test/teos/e2e/test_client_e2e.py +++ b/test/teos/e2e/test_client_e2e.py @@ -24,7 +24,7 @@ generate_blocks, config, ) -from test.teos.e2e.conftest import build_appointment_data, run_teosd +from test.teos.e2e.conftest import build_appointment_data, run_teosd, stop_teosd teos_base_endpoint = "http://{}:{}".format(config.get("API_BIND"), config.get("API_PORT")) teos_add_appointment_endpoint = "{}/add_appointment".format(teos_base_endpoint) @@ -505,9 +505,7 @@ def test_appointment_shutdown_teos_trigger_back_online(teosd): add_appointment(teos_id, appointment) # Restart teos - rpc_client = RPCClient(config.get("RPC_BIND"), config.get("RPC_PORT")) - rpc_client.stop() - teosd_process.join() + stop_teosd(teosd_process) teosd_process, _ = run_teosd() @@ -546,10 +544,7 @@ def test_appointment_shutdown_teos_trigger_while_offline(teosd): assert appointment_info.get("appointment") == appointment.to_dict() # Shutdown and trigger - rpc_client = RPCClient(config.get("RPC_BIND"), config.get("RPC_PORT")) - rpc_client.stop() - teosd_process.join() - + stop_teosd(teosd_process) generate_block_with_transactions(commitment_tx) # Restart diff --git a/test/teos/unit/test_api.py b/test/teos/unit/test_api.py index 7cf14427..05735a69 100644 --- a/test/teos/unit/test_api.py +++ b/test/teos/unit/test_api.py @@ -21,6 +21,7 @@ from common.appointment import Appointment, AppointmentStatus from common.constants import ( HTTP_OK, + HTTP_EMPTY, HTTP_NOT_FOUND, HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, @@ -37,6 +38,7 @@ internal_api_endpoint = "{}:{}".format(config.get("INTERNAL_API_HOST"), config.get("INTERNAL_API_PORT")) TEOS_API = "http://{}:{}".format(config.get("API_BIND"), config.get("API_PORT")) +ping_endpoint = "{}/ping".format(TEOS_API) register_endpoint = "{}/register".format(TEOS_API) add_appointment_endpoint = "{}/add_appointment".format(TEOS_API) get_appointment_endpoint = "{}/get_appointment".format(TEOS_API) @@ -91,6 +93,11 @@ def client(app): return app.test_client() +def test_ping(client): + r = client.get(ping_endpoint) + assert r.status_code == HTTP_EMPTY + + def test_register(api, client, monkeypatch): # Tests registering a user within the tower diff --git a/watchtower-plugin/README.md b/watchtower-plugin/README.md index 60686ee4..d6987d45 100644 --- a/watchtower-plugin/README.md +++ b/watchtower-plugin/README.md @@ -8,6 +8,7 @@ commitment transaction is generated. It also keeps a summary of the messages sen The plugin has the following methods: +- `pingtower ip[:port]`: pings a tower to check if it's online. - `registertower tower_id` : registers the user id (compressed public key) with a given tower. - `list_towers`: lists all registered towers. - `gettowerinfo tower_id`: gets all the locally stored data about a given tower. diff --git a/watchtower-plugin/arg_parser.py b/watchtower-plugin/arg_parser.py index 6236c924..78e9b36b 100644 --- a/watchtower-plugin/arg_parser.py +++ b/watchtower-plugin/arg_parser.py @@ -4,6 +4,40 @@ from common.exceptions import InvalidParameter +def parse_ping_arguments(host, port, config): + """ + Parses the arguments of the ping command and checks that they are correct. + + Args: + host (:obj:`str`): the ip or hostname to connect to. + port (:obj:`int`): the port to connect to, optional. + config: (:obj:`dict`): the configuration dictionary. + + Returns: + :obj:`tuple`: the network address. + + Raises: + :obj:`common.exceptions.InvalidParameter`: if any of the parameters is wrong or missing. + """ + + if not isinstance(host, str): + raise InvalidParameter(f"host must be string not {str(host)}") + + # host and port specified + if host and port: + tower_netaddr = f"{host}:{port}" + else: + # host was specified, but no port, defaulting + if ":" not in host: + tower_netaddr = f"{host}:{config.get('DEFAULT_PORT')}" + elif host.endswith(":"): + tower_netaddr = f"{host}{config.get('DEFAULT_PORT')}" + else: + tower_netaddr = host + + return tower_netaddr + + def parse_register_arguments(tower_id, host, port, config): """ Parses the arguments of the register command and checks that they are correct. @@ -11,7 +45,7 @@ def parse_register_arguments(tower_id, host, port, config): Args: tower_id (:obj:`str`): the identifier of the tower to connect to (a compressed public key). host (:obj:`str`): the ip or hostname to connect to, optional. - host (:obj:`int`): the port to connect to, optional. + port (:obj:`int`): the port to connect to, optional. config: (:obj:`dict`): the configuration dictionary. Returns: diff --git a/watchtower-plugin/net/http.py b/watchtower-plugin/net/http.py index 0a6bfe8c..542b9ced 100644 --- a/watchtower-plugin/net/http.py +++ b/watchtower-plugin/net/http.py @@ -93,6 +93,38 @@ def send_appointment(tower_id, tower, appointment_dict, signature): return response +def get_request(endpoint): + """ + Sends a get request to the tower. + + Args: + endpoint (:obj:`str`): the endpoint to send the post request. + + Returns: + :obj:`Response`: a Response object with the obtained data. + + Raises: + :obj:`ConnectionError`: if the client cannot connect to the tower. + :obj:`TowerResponseError`: if the returned status code is not a 200-family code. + """ + + try: + response = requests.get(url=endpoint) + + if response.ok: + return response + else: + raise TowerResponseError( + "The server returned an error", status_code=response.status_code, reason=response.reason, data=response, + ) + + except ConnectionError: + raise TowerConnectionError(f"Cannot connect to {endpoint}. Tower cannot be reached") + + except (InvalidSchema, MissingSchema, InvalidURL): + raise TowerConnectionError(f"Invalid URL. No schema, or invalid schema, found (url={endpoint})") + + def post_request(data, endpoint, tower_id): """ Sends a post request to the tower. diff --git a/watchtower-plugin/test_watchtower.py b/watchtower-plugin/test_watchtower.py index 5240cf02..d9c906e4 100644 --- a/watchtower-plugin/test_watchtower.py +++ b/watchtower-plugin/test_watchtower.py @@ -37,6 +37,7 @@ def __init__(self, tower_sk): # Adds all the routes to the functions listed above. routes = { + "/ping": (self.ping, ["GET"]), "/register": (self.register, ["POST"]), "/add_appointment": (self.add_appointment, ["POST"]), "/get_appointment": (self.get_appointment, ["POST"]), @@ -49,6 +50,14 @@ def __init__(self, tower_sk): logging.getLogger("werkzeug").setLevel(logging.ERROR) os.environ["WERKZEUG_RUN_MAIN"] = "true" + def ping(self): + if mocked_return == "online": + return ping_online() + elif mocked_return == "offline": + return ping_offline() + elif mocked_return == "error": + return ping_error() + def register(self): user_id = request.get_json().get("public_key") @@ -127,6 +136,18 @@ def add_appointment_success(appointment, signature, user, tower_sk): return response, rcode +def ping_online(): + return "", constants.HTTP_EMPTY + + +def ping_offline(): + raise ConnectionError() + + +def ping_error(): + return "", constants.HTTP_NOT_FOUND + + def add_appointment_reject_no_slots(): # This covers non-registered users and users with no available slots @@ -215,6 +236,36 @@ def test_helpme_starts(node_factory): l1.start() +def test_watchtower_ping_offline(node_factory): + global mocked_return + + mocked_return = "offline" + + l1 = node_factory.get_node(options={"plugin": plugin_path}) + r = l1.rpc.pingtower(tower_netaddr, tower_port) + assert r.get("alive") is False + + +def test_watchtower_ping_online(node_factory): + global mocked_return + + mocked_return = "online" + + l1 = node_factory.get_node(options={"plugin": plugin_path}) + r = l1.rpc.pingtower(tower_netaddr, tower_port) + assert r.get("alive") is True + + +def test_watchtower_ping_error(node_factory): + global mocked_return + + mocked_return = "error" + + l1 = node_factory.get_node(options={"plugin": plugin_path}) + r = l1.rpc.pingtower(tower_netaddr, tower_port) + assert r.get("alive") is False + + def test_watchtower(node_factory): """ Tests sending data to a single tower with short connection issue""" diff --git a/watchtower-plugin/watchtower.py b/watchtower-plugin/watchtower.py index 4a21e8b9..88d8550b 100755 --- a/watchtower-plugin/watchtower.py +++ b/watchtower-plugin/watchtower.py @@ -23,7 +23,7 @@ from tower_info import TowerInfo from towers_dbm import TowersDBM from keys import generate_keys, load_keys -from net.http import post_request, process_post_response, add_appointment +from net.http import get_request, post_request, process_post_response, add_appointment DATA_DIR = os.getenv("TOWERS_DATA_DIR", os.path.expanduser("~/.watchtower/")) @@ -178,6 +178,40 @@ def init(options, configuration, plugin): raise IOError(error) +@plugin.method("pingtower", desc="Pings a tower to check if its online") +def ping(plugin, host, port=None): + """ + Pings a tower to check if its online + + Args: + plugin (:obj:`Plugin`): this plugin. + host (:obj:`str`): the ip or hostname to connect to. + port (:obj:`int`): the port to connect to, optional. + """ + + response = {"alive": True} + + try: + tower_netaddr = arg_parser.parse_ping_arguments(host, port, plugin.wt_client.config) + + # Defaulting to http hosts for now + if not tower_netaddr.startswith("http"): + tower_netaddr = "http://" + tower_netaddr + + # Send request to the server. + ping_endpoint = f"{tower_netaddr}/ping" + plugin.log(f"Pinging the Eye of Satoshi at {tower_netaddr}") + get_request(ping_endpoint) + plugin.log(f"The Eye of Satoshi is alive") + + except (InvalidParameter, TowerConnectionError, TowerResponseError) as e: + plugin.log(str(e), level="warn") + response["alive"] = False + response["reason"] = str(e) + + return response + + @plugin.method("registertower", desc="Register your public key (user id) with the tower.") def register(plugin, tower_id, host=None, port=None): """