diff --git a/contrib/client/__init__.py b/contrib/client/__init__.py index b986a85e..5b258b3a 100644 --- a/contrib/client/__init__.py +++ b/contrib/client/__init__.py @@ -13,4 +13,5 @@ "APPOINTMENTS_FOLDER_NAME": {"value": "appointment_receipts", "type": str, "path": True}, "USER_PRIVATE_KEY": {"value": "user_sk.der", "type": str, "path": True}, "TEOS_PUBLIC_KEY": {"value": "teos_pk.der", "type": str, "path": True}, + "SOCKS_PORT": {"value": 9050, "type": int}, } diff --git a/contrib/client/requirements.txt b/contrib/client/requirements.txt index 09b36464..0c8c00dd 100644 --- a/contrib/client/requirements.txt +++ b/contrib/client/requirements.txt @@ -1,3 +1,4 @@ cryptography>=2.8 requests +requests[socks] structlog diff --git a/contrib/client/teos_client.py b/contrib/client/teos_client.py index 446cbdfb..d41f25a8 100755 --- a/contrib/client/teos_client.py +++ b/contrib/client/teos_client.py @@ -36,7 +36,7 @@ logger = logging.getLogger() -def register(user_id, teos_id, teos_url): +def register(user_id, teos_id, teos_url, socks_port=9050): """ Registers the user to the tower. @@ -63,7 +63,7 @@ def register(user_id, teos_id, teos_url): data = {"public_key": user_id} logger.info("Registering in the Eye of Satoshi") - response = process_post_response(post_request(data, register_endpoint)) + response = process_post_response(post_request(data, register_endpoint, socks_port)) available_slots = response.get("available_slots") subscription_expiry = response.get("subscription_expiry") @@ -115,7 +115,7 @@ def create_appointment(appointment_data): return Appointment.from_dict(appointment_data) -def add_appointment(appointment, user_sk, teos_id, teos_url): +def add_appointment(appointment, user_sk, teos_id, teos_url, socks_port=9050): """ Manages the add_appointment command. The life cycle of the function is as follows: - Sign the appointment @@ -144,7 +144,7 @@ def add_appointment(appointment, user_sk, teos_id, teos_url): # Send appointment to the server. logger.info("Sending appointment to the Eye of Satoshi") add_appointment_endpoint = "{}/add_appointment".format(teos_url) - response = process_post_response(post_request(data, add_appointment_endpoint)) + response = process_post_response(post_request(data, add_appointment_endpoint, socks_port)) tower_signature = response.get("signature") start_block = response.get("start_block") @@ -164,7 +164,7 @@ def add_appointment(appointment, user_sk, teos_id, teos_url): return start_block, tower_signature -def get_appointment(locator, user_sk, teos_id, teos_url): +def get_appointment(locator, user_sk, teos_id, teos_url, socks_port=9050): """ Gets information about an appointment from the tower. @@ -195,12 +195,12 @@ def get_appointment(locator, user_sk, teos_id, teos_url): # Send request to the server. get_appointment_endpoint = "{}/get_appointment".format(teos_url) logger.info("Requesting appointment from the Eye of Satoshi") - response = process_post_response(post_request(data, get_appointment_endpoint)) + response = process_post_response(post_request(data, get_appointment_endpoint, socks_port)) return response -def get_subscription_info(user_sk, teos_id, teos_url): +def get_subscription_info(user_sk, teos_id, teos_url, socks_port=9050): """ Gets information about a user's subscription status from the tower. @@ -225,7 +225,7 @@ def get_subscription_info(user_sk, teos_id, teos_url): # Send request to the server. get_subscription_info_endpoint = "{}/get_subscription_info".format(teos_url) logger.info("Requesting subscription information from the Eye of Satoshi") - response = process_post_response(post_request(data, get_subscription_info_endpoint)) + response = process_post_response(post_request(data, get_subscription_info_endpoint, socks_port)) return response @@ -290,7 +290,7 @@ def load_teos_id(teos_pk_path): return teos_id -def post_request(data, endpoint): +def post_request(data, endpoint, socks_port=9050): """ Sends a post request to the tower. @@ -306,7 +306,12 @@ def post_request(data, endpoint): """ try: - return requests.post(url=endpoint, json=data, timeout=5) + if ".onion" in endpoint: + proxies = {"http": f"socks5h://127.0.0.1:{socks_port}", "https": f"socks5h://127.0.0.1:{socks_port}"} + + return requests.post(url=endpoint, json=data, timeout=15, proxies=proxies) + else: + return requests.post(url=endpoint, json=data, timeout=5) except Timeout: message = "Cannot connect to the Eye of Satoshi's API. Connection timeout" @@ -449,6 +454,8 @@ def main(command, args, command_line_conf): if not teos_url.startswith("http"): teos_url = "http://" + teos_url + socks_port = config.get("SOCKS_PORT") + try: if os.path.exists(config.get("USER_PRIVATE_KEY")): logger.debug("Client id found. Loading keys") @@ -468,7 +475,7 @@ def main(command, args, command_line_conf): if not is_compressed_pk(teos_id): raise InvalidParameter("Cannot register. Tower id has invalid format") - available_slots, subscription_expiry = register(user_id, teos_id, teos_url) + available_slots, subscription_expiry = register(user_id, teos_id, teos_url, socks_port) logger.info("Registration succeeded. Available slots: {}".format(available_slots)) logger.info("Subscription expires at block {}".format(subscription_expiry)) @@ -479,7 +486,7 @@ def main(command, args, command_line_conf): teos_id = load_teos_id(config.get("TEOS_PUBLIC_KEY")) appointment_data = parse_add_appointment_args(args) appointment = create_appointment(appointment_data) - start_block, signature = add_appointment(appointment, user_sk, teos_id, teos_url) + start_block, signature = add_appointment(appointment, user_sk, teos_id, teos_url, socks_port) save_appointment_receipt( appointment.to_dict(), start_block, signature, config.get("APPOINTMENTS_FOLDER_NAME") ) @@ -495,7 +502,7 @@ def main(command, args, command_line_conf): sys.exit(help_get_appointment()) teos_id = load_teos_id(config.get("TEOS_PUBLIC_KEY")) - appointment_data = get_appointment(arg_opt, user_sk, teos_id, teos_url) + appointment_data = get_appointment(arg_opt, user_sk, teos_id, teos_url, socks_port) if appointment_data: logger.info(json.dumps(appointment_data, indent=4)) @@ -507,7 +514,7 @@ def main(command, args, command_line_conf): sys.exit(help_get_subscription_info()) teos_id = load_teos_id(config.get("TEOS_PUBLIC_KEY")) - subscription_info = get_subscription_info(user_sk, teos_id, teos_url) + subscription_info = get_subscription_info(user_sk, teos_id, teos_url, socks_port) if subscription_info: logger.info(json.dumps(subscription_info, indent=4)) @@ -533,7 +540,13 @@ def main(command, args, command_line_conf): else: sys.exit(show_usage()) - except (FileNotFoundError, IOError, ConnectionError, ValueError, BasicException,) as e: + except ( + FileNotFoundError, + IOError, + ConnectionError, + ValueError, + BasicException, + ) as e: logger.error(str(e)) except Exception as e: logger.error("Unknown error occurred", error=str(e)) diff --git a/contrib/client/test/test_teos_client.py b/contrib/client/test/test_teos_client.py index a5dc3858..14d08676 100644 --- a/contrib/client/test/test_teos_client.py +++ b/contrib/client/test/test_teos_client.py @@ -363,6 +363,24 @@ def test_post_request(): assert response +@responses.activate +def test_post_request_onion_address(): + onion_url = "http://4e2vhhgmozhi2ncuxk247j5zba3r5f3x33b3vy5z5tvdddwbijc2vhqd.onion:9814" + add_appointment_onion_endpoint = "{}/add_appointment".format(onion_url) + + response = { + "locator": dummy_appointment.to_dict()["locator"], + "signature": Cryptographer.sign(dummy_appointment.serialize(), dummy_teos_sk), + } + + responses.add(responses.POST, add_appointment_onion_endpoint, json=response, status=200) + response = teos_client.post_request(json.dumps(dummy_appointment_data), add_appointment_onion_endpoint) + + assert len(responses.calls) == 1 + assert responses.calls[0].request.url == add_appointment_onion_endpoint + assert response + + def test_post_request_connection_error(): with pytest.raises(ConnectionError): teos_client.post_request(json.dumps(dummy_appointment_data), add_appointment_endpoint) diff --git a/requirements-dev.txt b/requirements-dev.txt index 0b1bed68..cd671feb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,4 @@ flake8 responses riemann-tx grpcio-tools +stem diff --git a/test/teos/e2e/conftest.py b/test/teos/e2e/conftest.py index 3672b2ce..aff7ddcf 100644 --- a/test/teos/e2e/conftest.py +++ b/test/teos/e2e/conftest.py @@ -1,10 +1,12 @@ import os -import shutil import pytest +from shutil import rmtree from time import sleep import multiprocessing from grpc import RpcError from multiprocessing import Process +from stem.control import Controller +from stem.process import launch_tor_with_config from teos.teosd import main from teos.cli.teos_cli import RPCClient @@ -33,7 +35,7 @@ def teosd(run_bitcoind): pass teosd_process.join() - shutil.rmtree(".teos") + 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) @@ -67,3 +69,37 @@ def build_appointment_data(commitment_tx_id, penalty_tx): appointment_data = {"tx": penalty_tx, "tx_id": commitment_tx_id, "to_self_delay": 20} return appointment_data + + +@pytest.fixture(scope="session") +def run_tor(): + dirname = ".test_tor" + + # Run Tor in a separate folder + os.makedirs(dirname, exist_ok=True) + + curr_dir = os.getcwd() + data_dir = f"{curr_dir}/{dirname}" + + tor_process = launch_tor_with_config( + config={ + "SocksPort": "9060", + "ControlPort": "9061", + "DataDirectory": data_dir, + } + ) + + yield + + tor_process.kill() + rmtree(dirname) + + +def create_hidden_service(): + with Controller.from_port(port=9061) as controller: + controller.authenticate() + + hidden_service_dir = os.path.join(controller.get_conf("DataDirectory", "/tmp"), "onion_test") + result = controller.create_hidden_service(hidden_service_dir, 9814, target_port=9814) + + return result.hostname diff --git a/test/teos/e2e/test_client_e2e.py b/test/teos/e2e/test_client_e2e.py index 36b6346c..af51382e 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, create_hidden_service teos_base_endpoint = "http://{}:{}".format(config.get("API_BIND"), config.get("API_PORT")) teos_add_appointment_endpoint = "{}/add_appointment".format(teos_base_endpoint) @@ -559,3 +559,21 @@ def test_appointment_shutdown_teos_trigger_while_offline(teosd): # The appointment should have been moved to the Responder appointment_info = get_appointment_info(teos_id, locator) assert appointment_info.get("status") == AppointmentStatus.DISPUTE_RESPONDED + + +def test_register_request_to_onion_service(teosd, run_tor): + _, teos_id = teosd + + run_tor + sleep(3) + + onion_address = create_hidden_service() + onion_endpoint = f"http://{onion_address}:9814" + + # See if a user can connect to the hidden service. + tmp_user_sk = PrivateKey() + tmp_user_id = Cryptographer.get_compressed_pk(tmp_user_sk.public_key) + + available_slots, subscription_expiry = teos_client.register(tmp_user_id, teos_id, onion_endpoint, socks_port=9060) + + assert available_slots == 100 diff --git a/watchtower-plugin/net/http.py b/watchtower-plugin/net/http.py index 0a6bfe8c..92936e71 100644 --- a/watchtower-plugin/net/http.py +++ b/watchtower-plugin/net/http.py @@ -93,7 +93,7 @@ def send_appointment(tower_id, tower, appointment_dict, signature): return response -def post_request(data, endpoint, tower_id): +def post_request(data, endpoint, tower_id, socks_port=9050): """ Sends a post request to the tower. @@ -110,13 +110,21 @@ def post_request(data, endpoint, tower_id): """ try: - return requests.post(url=endpoint, json=data, timeout=5) + if ".onion" in endpoint: + proxies = {"http": f"socks5h://127.0.0.1:{socks_port}", "https": f"socks5h://127.0.0.1:{socks_port}"} + + return requests.post(url=endpoint, json=data, timeout=15, proxies=proxies) + else: + return requests.post(url=endpoint, json=data, timeout=5) except ConnectTimeout: message = f"Cannot connect to {tower_id}. Connection timeout" except ConnectionError: - message = f"Cannot connect to {tower_id}. Tower cannot be reached" + if ".onion" in endpoint: + message = f"Cannot connect to {tower_id}. Trying to connect to a Tor onion address. Are you running Tor?" + else: + message = f"Cannot connect to {tower_id}. Tower cannot be reached" except (InvalidSchema, MissingSchema, InvalidURL): message = f"Invalid URL. No schema, or invalid schema, found (url={endpoint}, tower_id={tower_id})" diff --git a/watchtower-plugin/requirements.txt b/watchtower-plugin/requirements.txt index 365465cd..09898b1c 100644 --- a/watchtower-plugin/requirements.txt +++ b/watchtower-plugin/requirements.txt @@ -1,5 +1,7 @@ pyln-client +pyln-testing requests +requests[socks] coincurve cryptography>=2.8 pyzbase32 diff --git a/watchtower-plugin/watchtower.py b/watchtower-plugin/watchtower.py index 47029002..f1a0716e 100755 --- a/watchtower-plugin/watchtower.py +++ b/watchtower-plugin/watchtower.py @@ -35,6 +35,7 @@ "APPOINTMENTS_FOLDER_NAME": {"value": "appointment_receipts", "type": str, "path": True}, "TOWERS_DB": {"value": "towers", "type": str, "path": True}, "PRIVATE_KEY": {"value": "sk.der", "type": str, "path": True}, + "SOCKS_PORT": {"value": 9050, "type": int}, } @@ -45,7 +46,7 @@ class WTClient: """ Holds all the data regarding the watchtower client. - Fires an additional tread to take care of retries. + Fires an additional thread to take care of retries. Args: sk (:obj:`PrivateKey): the user private key. Used to sign appointment sent to the towers. @@ -69,6 +70,7 @@ def __init__(self, sk, user_id, config): self.retrier = Retrier(config.get("MAX_RETRIES"), Queue()) self.config = config self.lock = Lock() + self.socks_port = config.get("SOCKS_PORT") # Populate the towers dict with data from the db for tower_id, tower_info in self.db_manager.load_all_tower_records().items(): @@ -170,7 +172,9 @@ def register(plugin, tower_id, host=None, port=None): plugin.log(f"Registering in the Eye of Satoshi (tower_id={tower_id})") - response = process_post_response(post_request(data, register_endpoint, tower_id)) + response = process_post_response( + post_request(data, register_endpoint, tower_id, socks_port=plugin.wt_client.socks_port) + ) available_slots = response.get("available_slots") subscription_expiry = response.get("subscription_expiry") tower_signature = response.get("subscription_signature") @@ -234,7 +238,9 @@ def get_appointment(plugin, tower_id, locator): get_appointment_endpoint = f"{tower_netaddr}/get_appointment" plugin.log(f"Requesting appointment from {tower_id}") - response = process_post_response(post_request(data, get_appointment_endpoint, tower_id)) + response = process_post_response( + post_request(data, get_appointment_endpoint, tower_id, socks_port=plugin.wt_client.socks_port) + ) return response except (InvalidParameter, TowerConnectionError, TowerResponseError) as e: