diff --git a/tools/reconnector/consts.py b/tools/reconnector/consts.py new file mode 100644 index 00000000..7ad6d38c --- /dev/null +++ b/tools/reconnector/consts.py @@ -0,0 +1,2 @@ +PLUGIN_NAME = "reconnector" +VERSION = "1.0.0" diff --git a/tools/reconnector/plugin.py b/tools/reconnector/plugin.py new file mode 100755 index 00000000..c6d72317 --- /dev/null +++ b/tools/reconnector/plugin.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +import sys +from typing import Any + +from consts import PLUGIN_NAME, VERSION +from pyln.client import Plugin +from reconnector_config import Config, register_options + +from reconnector import Reconnector + +pl = Plugin() +register_options(pl) + +rec = Reconnector(pl) + + +@pl.init() +def init( + options: dict[str, Any], + configuration: dict[str, Any], + plugin: Plugin, + **kwargs: dict[str, Any], +) -> None: + cfg = Config(pl, options) + rec.init(cfg) + + pl.log(f"Plugin {PLUGIN_NAME} v{VERSION} initialized") + + +@pl.subscribe("shutdown") +def shutdown(**kwargs: dict[str, Any]) -> None: + pl.log(f"Plugin {PLUGIN_NAME} stopping") + rec.stop() + + pl.log(f"Plugin {PLUGIN_NAME} stopped") + sys.exit(0) + + +pl.run() diff --git a/tools/reconnector/reconnector.py b/tools/reconnector/reconnector.py new file mode 100644 index 00000000..65fe3e68 --- /dev/null +++ b/tools/reconnector/reconnector.py @@ -0,0 +1,97 @@ +from threading import Timer + +from pyln.client import Plugin, RpcError +from reconnector_config import Config + + +class Reconnector: + _pl: Plugin + _pubkey: str + + _timer: Timer | None + _custom_uris: dict[str, str] + + def __init__(self, pl: Plugin) -> None: + self._pl = pl + self._pubkey = "" + self._timer = None + self._custom_uris = {} + + def init(self, cfg: Config) -> None: + self._custom_uris = cfg.custom_uris + self._pubkey = self._pl.rpc.getinfo()["id"] + + # Cancel in case there is a running timer + self.stop() + + self._pl.log(f"Checking for falsy inactive channels every {cfg.check_interval} seconds") + self._timer = Timer(cfg.check_interval, self._check_inactive_channels) + self._timer.start() + + def stop(self) -> None: + if self._timer is not None: + self._timer.cancel() + self._timer = None + + def _check_inactive_channels(self) -> None: + channels = self._pl.rpc.listpeerchannels()["channels"] + + for channel in channels: + if ( + not channel["peer_connected"] + or channel["status"][0] != "CHANNELD_NORMAL:Reconnected, and reestablished." + ): + continue + + channel_info = self._pl.rpc.listchannels(channel["short_channel_id"])["channels"] + if len(channel_info) != 2: + continue + + our_policy = ( + channel_info[0] if channel_info[0]["source"] == self._pubkey else channel_info[1] + ) + + if not our_policy["public"]: + continue + + if not our_policy["active"]: + self._pl.log( + f"Found falsely disabled channel with peer {channel['peer_id']}; reconnecting" + ) + self._reconnect_channel(channel["peer_id"]) + + def _reconnect_channel(self, peer_id: str) -> None: + uris = self._get_node_uris(peer_id) + + # todo: still disconnect and hope they connect to us? + if len(uris) == 0: + self._pl.log(f"Could not find public URI of peer {peer_id}; not reconnecting") + return + + self._pl.rpc.disconnect(peer_id, True) + for uri in uris: + try: + self._pl.rpc.connect(uri) + self._pl.log(f"Reconnected to {peer_id}") + break + + except RpcError as e: + self._pl.log(f"Could not connect to {uri}: {e!s}") + + def _get_node_uris(self, peer_id: str) -> list[str]: + if peer_id in self._custom_uris: + return [self._custom_uris[peer_id]] + + nodes = self._pl.rpc.listnodes(peer_id)["nodes"] + if len(nodes) == 0: + return [] + + addresses = nodes[0]["addresses"] + + # Filter torv2 + addresses = list(filter(lambda address: address["type"] != "torv2", addresses)) + + # Prefer clearnet to Tor connections + addresses.sort(key=lambda address: 1 if "tor" in address["type"] else 0) + + return [f"{peer_id}@{address['address']}:{address['port']}" for address in addresses] diff --git a/tools/reconnector/reconnector_config.py b/tools/reconnector/reconnector_config.py new file mode 100644 index 00000000..c671fc1a --- /dev/null +++ b/tools/reconnector/reconnector_config.py @@ -0,0 +1,62 @@ +import json +from enum import Enum +from typing import Any + +from consts import PLUGIN_NAME +from pyln.client import Plugin + + +class OptionKeys(str, Enum): + CheckInterval = f"{PLUGIN_NAME}-check-interval" + CustomUris = f"{PLUGIN_NAME}-custom-uris" + + +class OptionDefaults(str, Enum): + CheckInterval = "120" + CustomUris = "[]" + + +def register_options(pl: Plugin) -> None: + pl.add_option( + OptionKeys.CheckInterval, + OptionDefaults.CheckInterval, + "interval in seconds to check for disabled channels", + ) + pl.add_option( + OptionKeys.CustomUris, + OptionDefaults.CustomUris, + "list of URIs that should be used instead of the publicly announced ones", + ) + + +class Config: + check_interval: int + custom_uris: dict[str, str] + + def __init__(self, pl: Plugin, configuration: dict[str, Any]) -> None: + self.check_interval = int(configuration[OptionKeys.CheckInterval]) + + try: + self.custom_uris = Config._parse_uris(pl, configuration[OptionKeys.CustomUris]) + except Exception as e: + pl.log(f"Could not decode custom URIs: {e!s}", level="warn") + self.custom_uris = {} + + @staticmethod + def _parse_uris(pl: Plugin, uris_str: str) -> dict[str, str]: + uris_list: list[str] = json.loads(uris_str) + if not isinstance(uris_list, list): + msg = "not a list" + raise TypeError(msg) + + uris: dict[str, str] = {} + + for uri in uris_list: + split = uri.split("@") + if len(split) != 2: + pl.log(f'Ignoring custom URI because of invalid format: "{uri}"') + continue + + uris[split[0].lower()] = uri + + return uris diff --git a/tools/reconnector/test_config.py b/tools/reconnector/test_config.py new file mode 100644 index 00000000..e8cf2413 --- /dev/null +++ b/tools/reconnector/test_config.py @@ -0,0 +1,83 @@ +from unittest.mock import MagicMock + +import pytest +from reconnector_config import Config, OptionKeys + + +class TestConfig: + @pytest.mark.parametrize( + ("params", "expected_interval", "expected_uris"), + [ + ({OptionKeys.CheckInterval: 120, OptionKeys.CustomUris: "[]"}, 120, {}), + ({OptionKeys.CheckInterval: 60, OptionKeys.CustomUris: "[]"}, 60, {}), + ({OptionKeys.CheckInterval: 1, OptionKeys.CustomUris: "[]"}, 1, {}), + ( + { + OptionKeys.CheckInterval: 120, + OptionKeys.CustomUris: '["02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@' + '45.86.229.190:9736"]', + }, + 120, + { + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018": "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736" + }, + ), + ( + { + OptionKeys.CheckInterval: 120, + OptionKeys.CustomUris: '["02D96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@' + '45.86.229.190:9736"]', + }, + 120, + { + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018": "02D96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736" + }, + ), + ( + { + OptionKeys.CheckInterval: 120, + OptionKeys.CustomUris: "[" + '"02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736",' + '"026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2@45.86.229.190:9735"' + "]", + }, + 120, + { + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018": "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736", + "026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2": "026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2@45.86.229.190:9735", + }, + ), + ( + { + OptionKeys.CheckInterval: 120, + OptionKeys.CustomUris: "[" + '"02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736",' + '"026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2"' + "]", + }, + 120, + { + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018": "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736", + }, + ), + ( + { + OptionKeys.CheckInterval: 1, + OptionKeys.CustomUris: '{"some": "otherData"}', + }, + 1, + {}, + ), + ({OptionKeys.CheckInterval: 1, OptionKeys.CustomUris: "notJson"}, 1, {}), + ], + ) + def test_parse( + self, + params: dict[str, str], + expected_interval: int, + expected_uris: dict[str, str], + ) -> None: + cfg = Config(MagicMock(), params) + + assert cfg.check_interval == expected_interval + assert cfg.custom_uris == expected_uris diff --git a/tools/reconnector/test_reconnector.py b/tools/reconnector/test_reconnector.py new file mode 100644 index 00000000..9e73b121 --- /dev/null +++ b/tools/reconnector/test_reconnector.py @@ -0,0 +1,194 @@ +import json +import os +from typing import Any + +import pytest +from reconnector_config import Config, OptionKeys + +from reconnector import Reconnector + + +def cln_con(*args: str) -> dict[str, Any]: + return json.load( + os.popen( + f"docker exec regtest lightning-cli {' '.join(args)}", + ) + ) + + +class RpcCaller: + list_nodes_res: dict[str, Any] + + @staticmethod + def getinfo() -> dict[str, Any]: + return cln_con("getinfo") + + def listnodes(self, _: str) -> dict[str, Any]: + return self.list_nodes_res + + +class RpcPlugin: + rpc = RpcCaller() + + def log(self, msg: str, **_kwargs: dict[str, Any]) -> None: + pass + + +pl = RpcPlugin() +# noinspection PyTypeChecker +cfg = Config(pl, {OptionKeys.CheckInterval: 120, OptionKeys.CustomUris: "[]"}) + + +class TestReconnector: + @pytest.fixture(scope="class", autouse=True) + def rec(self) -> Reconnector: + # noinspection PyTypeChecker + rec = Reconnector(RpcPlugin()) + + yield rec + + rec.stop() + + def test_init(self, rec: Reconnector) -> None: + assert rec._timer is None # noqa: SLF001 + assert rec._pubkey == "" # noqa: SLF001 + assert rec._custom_uris == {} # noqa: SLF001 + + cfg.custom_uris = {"some": "data"} + rec.init(cfg) + + assert rec._timer is not None # noqa: SLF001 + assert rec._timer.interval == cfg.check_interval # noqa: SLF001, SLF001 + + assert rec._pubkey != "" # noqa: SLF001 + assert rec._custom_uris == cfg.custom_uris # noqa: SLF001 + + def test_stop(self, rec: Reconnector) -> None: + rec.init(cfg) + assert rec._timer is not None # noqa: SLF001 + + rec.stop() + assert rec._timer is None # noqa: SLF001 + + @pytest.mark.parametrize( + ("node", "list_nodes", "expected"), + [ + ( + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018", + { + "nodes": [ + { + "addresses": [ + { + "type": "ipv4", + "address": "45.86.229.190", + "port": 9736, + }, + { + "type": "ipv6", + "address": "2a10:1fc0:3::270:a9dc", + "port": 9736, + }, + { + "type": "torv3", + "address": "oo5tkbbpgnqjopdjxepyfavx3yemtylgzul67s7zzzxfeeqpde6yr7yd.onion", + "port": 9736, + }, + ] + } + ] + }, + [ + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736", + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@2a10:1fc0:3::270:a9dc:9736", + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@oo5tkbbpgnqjopdjxepyfavx3yemtylgzul67s7zzzxfeeqpde6yr7yd.onion:9736", + ], + ), + ( + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018", + { + "nodes": [ + { + "addresses": [ + { + "type": "torv3", + "address": "oo5tkbbpgnqjopdjxepyfavx3yemtylgzul67s7zzzxfeeqpde6yr7yd.onion", + "port": 9736, + }, + { + "type": "ipv4", + "address": "45.86.229.190", + "port": 9736, + }, + { + "type": "ipv6", + "address": "2a10:1fc0:3::270:a9dc", + "port": 9736, + }, + ] + } + ] + }, + [ + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736", + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@2a10:1fc0:3::270:a9dc:9736", + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@oo5tkbbpgnqjopdjxepyfavx3yemtylgzul67s7zzzxfeeqpde6yr7yd.onion:9736", + ], + ), + ( + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018", + { + "nodes": [ + { + "addresses": [ + { + "type": "ipv4", + "address": "45.86.229.190", + "port": 9736, + }, + { + "type": "ipv6", + "address": "2a10:1fc0:3::270:a9dc", + "port": 9736, + }, + { + "type": "torv2", + "address": "some.onion", + "port": 9736, + }, + ] + } + ] + }, + [ + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736", + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@2a10:1fc0:3::270:a9dc:9736", + ], + ), + ( + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018", + {"nodes": []}, + [], + ), + ], + ) + def test_get_node_uris( + self, + rec: Reconnector, + node: str, + list_nodes: dict[str, Any], + expected: list[str], + ) -> None: + pl.rpc.list_nodes_res = list_nodes + assert rec._get_node_uris(node) == expected # noqa: SLF001 + + def test_get_node_uris_custom(self, rec: Reconnector) -> None: + node = "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018" + custom_uri = ( + "02d96eadea3d780104449aca5c93461ce67c1564e2e1d73225fa67dd3b997a6018@45.86.229.190:9736" + ) + + rec._custom_uris[node] = custom_uri # noqa: SLF001 + uris = rec._get_node_uris(node) # noqa: SLF001 + + assert uris == [custom_uri]