301 lines
10 KiB
Python
301 lines
10 KiB
Python
# Copyright (c) 2017, Neil Booth
|
|
#
|
|
# All rights reserved.
|
|
#
|
|
# The MIT License (MIT)
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining
|
|
# a copy of this software and associated documentation files (the
|
|
# "Software"), to deal in the Software without restriction, including
|
|
# without limitation the rights to use, copy, modify, merge, publish,
|
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
# permit persons to whom the Software is furnished to do so, subject to
|
|
# the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
'''Representation of a peer server.'''
|
|
|
|
from ipaddress import ip_address
|
|
|
|
from torba.server.util import cachedproperty
|
|
import torba.server.util as util
|
|
|
|
from typing import Dict
|
|
|
|
|
|
class Peer(object):
|
|
|
|
# Protocol version
|
|
ATTRS = ('host', 'features',
|
|
# metadata
|
|
'source', 'ip_addr',
|
|
'last_good', 'last_try', 'try_count')
|
|
FEATURES = ('pruning', 'server_version', 'protocol_min', 'protocol_max',
|
|
'ssl_port', 'tcp_port')
|
|
# This should be set by the application
|
|
DEFAULT_PORTS: Dict[str, int] = {}
|
|
|
|
def __init__(self, host, features, source='unknown', ip_addr=None,
|
|
last_good=0, last_try=0, try_count=0):
|
|
'''Create a peer given a host name (or IP address as a string),
|
|
a dictionary of features, and a record of the source.'''
|
|
assert isinstance(host, str)
|
|
assert isinstance(features, dict)
|
|
assert host in features.get('hosts', {})
|
|
self.host = host
|
|
self.features = features.copy()
|
|
# Canonicalize / clean-up
|
|
for feature in self.FEATURES:
|
|
self.features[feature] = getattr(self, feature)
|
|
# Metadata
|
|
self.source = source
|
|
self.ip_addr = ip_addr
|
|
# last_good represents the last connection that was
|
|
# successful *and* successfully verified, at which point
|
|
# try_count is set to 0. Failure to connect or failure to
|
|
# verify increment the try_count.
|
|
self.last_good = last_good
|
|
self.last_try = last_try
|
|
self.try_count = try_count
|
|
# Transient, non-persisted metadata
|
|
self.bad = False
|
|
self.other_port_pairs = set()
|
|
|
|
@classmethod
|
|
def peers_from_features(cls, features, source):
|
|
peers = []
|
|
if isinstance(features, dict):
|
|
hosts = features.get('hosts')
|
|
if isinstance(hosts, dict):
|
|
peers = [Peer(host, features, source=source)
|
|
for host in hosts if isinstance(host, str)]
|
|
return peers
|
|
|
|
@classmethod
|
|
def deserialize(cls, item):
|
|
'''Deserialize from a dictionary.'''
|
|
return cls(**item)
|
|
|
|
def matches(self, peers):
|
|
'''Return peers whose host matches our hostname or IP address.
|
|
Additionally include all peers whose IP address matches our
|
|
hostname if that is an IP address.
|
|
'''
|
|
candidates = (self.host.lower(), self.ip_addr)
|
|
return [peer for peer in peers
|
|
if peer.host.lower() in candidates
|
|
or peer.ip_addr == self.host]
|
|
|
|
def __str__(self):
|
|
return self.host
|
|
|
|
def update_features(self, features):
|
|
'''Update features in-place.'''
|
|
try:
|
|
tmp = Peer(self.host, features)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
self.update_features_from_peer(tmp)
|
|
|
|
def update_features_from_peer(self, peer):
|
|
if peer != self:
|
|
self.features = peer.features
|
|
for feature in self.FEATURES:
|
|
setattr(self, feature, getattr(peer, feature))
|
|
|
|
def connection_port_pairs(self):
|
|
'''Return a list of (kind, port) pairs to try when making a
|
|
connection.'''
|
|
# Use a list not a set - it's important to try the registered
|
|
# ports first.
|
|
pairs = [('SSL', self.ssl_port), ('TCP', self.tcp_port)]
|
|
while self.other_port_pairs:
|
|
pairs.append(self.other_port_pairs.pop())
|
|
return [pair for pair in pairs if pair[1]]
|
|
|
|
def mark_bad(self):
|
|
'''Mark as bad to avoid reconnects but also to remember for a
|
|
while.'''
|
|
self.bad = True
|
|
|
|
def check_ports(self, other):
|
|
'''Remember differing ports in case server operator changed them
|
|
or removed one.'''
|
|
if other.ssl_port != self.ssl_port:
|
|
self.other_port_pairs.add(('SSL', other.ssl_port))
|
|
if other.tcp_port != self.tcp_port:
|
|
self.other_port_pairs.add(('TCP', other.tcp_port))
|
|
return bool(self.other_port_pairs)
|
|
|
|
@cachedproperty
|
|
def is_tor(self):
|
|
return self.host.endswith('.onion')
|
|
|
|
@cachedproperty
|
|
def is_valid(self):
|
|
ip = self.ip_address
|
|
if ip:
|
|
return ((ip.is_global or ip.is_private)
|
|
and not (ip.is_multicast or ip.is_unspecified))
|
|
return util.is_valid_hostname(self.host)
|
|
|
|
@cachedproperty
|
|
def is_public(self):
|
|
ip = self.ip_address
|
|
if ip:
|
|
return self.is_valid and not ip.is_private
|
|
else:
|
|
return self.is_valid and self.host != 'localhost'
|
|
|
|
@cachedproperty
|
|
def ip_address(self):
|
|
'''The host as a python ip_address object, or None.'''
|
|
try:
|
|
return ip_address(self.host)
|
|
except ValueError:
|
|
return None
|
|
|
|
def bucket(self):
|
|
if self.is_tor:
|
|
return 'onion'
|
|
if not self.ip_addr:
|
|
return ''
|
|
return tuple(self.ip_addr.split('.')[:2])
|
|
|
|
def serialize(self):
|
|
'''Serialize to a dictionary.'''
|
|
return {attr: getattr(self, attr) for attr in self.ATTRS}
|
|
|
|
def _port(self, key):
|
|
hosts = self.features.get('hosts')
|
|
if isinstance(hosts, dict):
|
|
host = hosts.get(self.host)
|
|
port = self._integer(key, host)
|
|
if port and 0 < port < 65536:
|
|
return port
|
|
return None
|
|
|
|
def _integer(self, key, d=None):
|
|
d = d or self.features
|
|
result = d.get(key) if isinstance(d, dict) else None
|
|
if isinstance(result, str):
|
|
try:
|
|
result = int(result)
|
|
except ValueError:
|
|
pass
|
|
return result if isinstance(result, int) else None
|
|
|
|
def _string(self, key):
|
|
result = self.features.get(key)
|
|
return result if isinstance(result, str) else None
|
|
|
|
@cachedproperty
|
|
def genesis_hash(self):
|
|
'''Returns None if no SSL port, otherwise the port as an integer.'''
|
|
return self._string('genesis_hash')
|
|
|
|
@cachedproperty
|
|
def ssl_port(self):
|
|
'''Returns None if no SSL port, otherwise the port as an integer.'''
|
|
return self._port('ssl_port')
|
|
|
|
@cachedproperty
|
|
def tcp_port(self):
|
|
'''Returns None if no TCP port, otherwise the port as an integer.'''
|
|
return self._port('tcp_port')
|
|
|
|
@cachedproperty
|
|
def server_version(self):
|
|
'''Returns the server version as a string if known, otherwise None.'''
|
|
return self._string('server_version')
|
|
|
|
@cachedproperty
|
|
def pruning(self):
|
|
'''Returns the pruning level as an integer. None indicates no
|
|
pruning.'''
|
|
pruning = self._integer('pruning')
|
|
if pruning and pruning > 0:
|
|
return pruning
|
|
return None
|
|
|
|
def _protocol_version_string(self, key):
|
|
version_str = self.features.get(key)
|
|
ptuple = util.protocol_tuple(version_str)
|
|
return util.version_string(ptuple)
|
|
|
|
@cachedproperty
|
|
def protocol_min(self):
|
|
'''Minimum protocol version as a string, e.g., 1.0'''
|
|
return self._protocol_version_string('protocol_min')
|
|
|
|
@cachedproperty
|
|
def protocol_max(self):
|
|
'''Maximum protocol version as a string, e.g., 1.1'''
|
|
return self._protocol_version_string('protocol_max')
|
|
|
|
def to_tuple(self):
|
|
'''The tuple ((ip, host, details) expected in response
|
|
to a peers subscription.'''
|
|
details = self.real_name().split()[1:]
|
|
return (self.ip_addr or self.host, self.host, details)
|
|
|
|
def real_name(self):
|
|
'''Real name of this peer as used on IRC.'''
|
|
def port_text(letter, port):
|
|
if port == self.DEFAULT_PORTS.get(letter):
|
|
return letter
|
|
else:
|
|
return letter + str(port)
|
|
|
|
parts = [self.host, 'v' + self.protocol_max]
|
|
if self.pruning:
|
|
parts.append('p{:d}'.format(self.pruning))
|
|
for letter, port in (('s', self.ssl_port), ('t', self.tcp_port)):
|
|
if port:
|
|
parts.append(port_text(letter, port))
|
|
return ' '.join(parts)
|
|
|
|
@classmethod
|
|
def from_real_name(cls, real_name, source):
|
|
'''Real name is a real name as on IRC, such as
|
|
|
|
"erbium1.sytes.net v1.0 s t"
|
|
|
|
Returns an instance of this Peer class.
|
|
'''
|
|
host = 'nohost'
|
|
features = {}
|
|
ports = {}
|
|
for n, part in enumerate(real_name.split()):
|
|
if n == 0:
|
|
host = part
|
|
continue
|
|
if part[0] in ('s', 't'):
|
|
if len(part) == 1:
|
|
port = cls.DEFAULT_PORTS[part[0]]
|
|
else:
|
|
port = part[1:]
|
|
if part[0] == 's':
|
|
ports['ssl_port'] = port
|
|
else:
|
|
ports['tcp_port'] = port
|
|
elif part[0] == 'v':
|
|
features['protocol_max'] = features['protocol_min'] = part[1:]
|
|
elif part[0] == 'p':
|
|
features['pruning'] = part[1:]
|
|
|
|
features.update(ports)
|
|
features['hosts'] = {host: ports}
|
|
|
|
return cls(host, features, source)
|