# # Store information about servers. Filter and select based on their protocol support, etc. # import random import json from .constants import DEFAULT_PORTS class ServerInfo(dict): """ Information to be stored on a server. Originally based on IRC data published to a channel. """ FIELDS = ["nickname", "hostname", "ports", "version", "pruning_limit"] # pylint: disable=too-many-arguments def __init__( self, nickname_or_dict, hostname=None, ports=None, version=None, pruning_limit=None, ip_addr=None, ): if not hostname and not ports: # promote a dict, or similar super().__init__(nickname_or_dict) return self["nickname"] = nickname_or_dict or None self["hostname"] = hostname self["ip_addr"] = ip_addr or None # For 'ports', take # - a number (int), assumed to be TCP port, OR # - a list of codes, OR # - a string to be split apart. # Keep version and pruning limit separate # if isinstance(ports, int): ports = [f"t{int(ports)}"] elif isinstance(ports, str): ports = ports.split() # check we don't have junk in the ports list for p in ports.copy(): if p[0] == "v": version = p[1:] ports.remove(p) elif p[0] == "p": try: pruning_limit = int(p[1:]) except ValueError: # ignore junk pass ports.remove(p) assert ports, "Must have at least one port/protocol" self["ports"] = ports self["version"] = version self["pruning_limit"] = int(pruning_limit or 0) @classmethod def from_response(cls, response_list): # Create a list of servers based on data from response from Stratum. # Give this the response to: "server.peers.subscribe" # # [... # "91.63.237.12", # "electrum3.hachre.de", # [ "v1.0", "p10000", "t", "s" ] # ...] # rv = [] for params in response_list: ip_addr, hostname, ports = params if ip_addr == hostname: ip_addr = None rv.append(ServerInfo(None, hostname, ports, ip_addr=ip_addr)) return rv @classmethod def from_dict(cls, d): n = d.pop("nickname", None) h = d.pop("hostname") p = d.pop("ports") rv = cls(n, h, p) rv.update(d) return rv @property def protocols(self): rv = set(i[0] for i in self["ports"]) assert "p" not in rv, "pruning limit got in there" assert "v" not in rv, "version got in there" return rv @property def pruning_limit(self): return self.get("pruning_limit", 100) @property def hostname(self): return self.get("hostname") def get_port(self, for_protocol): """ Return (hostname, port number, ssl) pair for the protocol. Assuming only one port per host. """ assert len(for_protocol) == 1, "expect single letter code" use_ssl = for_protocol == "s" if "port" in self: return self["hostname"], int(self["port"]), use_ssl rv = next(i for i in self["ports"] if i[0] == for_protocol) port = None if len(rv) >= 2: try: port = int(rv[1:]) except BaseException: pass port = port or DEFAULT_PORTS[for_protocol] return self["hostname"], port, use_ssl @property def is_onion(self): return self["hostname"].lower().endswith(".onion") def select(self, protocol="s", is_onion=None, min_prune=0): # predicate function for selection based on features/properties return ( (protocol in self.protocols) and (self.is_onion == is_onion if is_onion is not None else True) and (self.pruning_limit >= min_prune) ) def __repr__(self): # pylint: disable=consider-using-f-string return ''.format( **self ) def __str__(self): # used as a dict key in a few places. return self["hostname"].lower() def __hash__(self): # this one-line allows use as a set member, which is really handy! return hash(self["hostname"].lower()) class KnownServers(dict): """ Store a list of known servers and their port numbers, etc. - can add single entries - can read from a CSV for seeding/bootstrap - can read from IRC channel to find current hosts We are a dictionary, with key being the hostname (in lowercase) of the server. """ def from_json(self, fname): """ Read contents of a CSV containing a list of servers. """ with open(fname, "rt", encoding="utf-8") as fp: for row in json.load(fp): nn = ServerInfo.from_dict(row) self[str(nn)] = nn def add_single(self, hostname, ports, nickname=None, **kws): """ Explicitly add a single entry. Hostname is a FQDN and ports is either a single int (assumed to be TCP port) or Electrum protocol/port number specification with spaces in between. """ nickname = nickname or hostname self[hostname.lower()] = ServerInfo(nickname, hostname, ports, **kws) def add_peer_response(self, response_list): # Update with response from Stratum (lacks the nickname value tho): # # "91.63.237.12", # "electrum3.hachre.de", # [ "v1.0", "p10000", "t", "s" ] # additions = set() for params in response_list: ip_addr, hostname, ports = params if ip_addr == hostname: ip_addr = None g = self.get(hostname.lower()) nickname = g["nickname"] if g else None here = ServerInfo(nickname, hostname, ports, ip_addr=ip_addr) self[str(here)] = here if not g: additions.add(str(here)) return additions def save_json(self, fname="servers.json"): """ Write out to a CSV file. """ rows = sorted(self.keys()) with open(fname, "wt", encoding="utf-8") as fp: json.dump([self[k] for k in rows], fp, indent=1) def dump(self): return "\n".join(repr(i) for i in self.values()) def select(self, **kws): """ Find all servers with indicated protocol support. Shuffled. Filter by TOR support, and pruning level. """ lst = [i for i in self.values() if i.select(**kws)] random.shuffle(lst) return lst