Neil Booth
8 years ago
31 changed files with 1483 additions and 271 deletions
@ -0,0 +1,294 @@ |
|||||
|
# 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.''' |
||||
|
|
||||
|
import re |
||||
|
from ipaddress import ip_address |
||||
|
|
||||
|
from lib.util import cachedproperty |
||||
|
|
||||
|
|
||||
|
class Peer(object): |
||||
|
|
||||
|
# Protocol version |
||||
|
VERSION_REGEX = re.compile('[0-9]+(\.[0-9]+)?$') |
||||
|
ATTRS = ('host', 'features', |
||||
|
# metadata |
||||
|
'source', 'ip_addr', 'good_ports', |
||||
|
'last_connect', 'last_try', 'try_count') |
||||
|
PORTS = ('ssl_port', 'tcp_port') |
||||
|
FEATURES = PORTS + ('pruning', 'server_version', |
||||
|
'protocol_min', 'protocol_max') |
||||
|
# This should be set by the application |
||||
|
DEFAULT_PORTS = {} |
||||
|
|
||||
|
def __init__(self, host, features, source='unknown', ip_addr=None, |
||||
|
good_ports=[], last_connect=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) |
||||
|
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 |
||||
|
self.good_ports = good_ports.copy() |
||||
|
self.last_connect = last_connect |
||||
|
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) |
||||
|
|
||||
|
@classmethod |
||||
|
def version_tuple(cls, vstr): |
||||
|
'''Convert a version string, such as "1.2", to a (major_version, |
||||
|
minor_version) pair. |
||||
|
''' |
||||
|
if isinstance(vstr, str) and VERSION_REGEX.match(vstr): |
||||
|
if '.' not in vstr: |
||||
|
vstr += '.0' |
||||
|
else: |
||||
|
vstr = '1.0' |
||||
|
return tuple(int(part) for part in vstr.split('.')) |
||||
|
|
||||
|
def matches(self, peers): |
||||
|
'''Return peers whose host matches the given peer's host or IP |
||||
|
address. This results in our favouring host names over IP |
||||
|
addresses. |
||||
|
''' |
||||
|
candidates = (self.host.lower(), self.ip_addr) |
||||
|
return [peer for peer in peers if peer.host.lower() in candidates] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return self.host |
||||
|
|
||||
|
def update_features(self, features): |
||||
|
'''Update features in-place.''' |
||||
|
tmp = Peer(self.host, features) |
||||
|
self.features = tmp.features |
||||
|
for feature in self.FEATURES: |
||||
|
setattr(self, feature, getattr(tmp, 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(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 not ip: |
||||
|
return True |
||||
|
return not ip.is_multicast and (ip.is_global or ip.is_private) |
||||
|
|
||||
|
@cachedproperty |
||||
|
def is_public(self): |
||||
|
ip = self.ip_address |
||||
|
return self.is_valid and not (ip and ip.is_private) |
||||
|
|
||||
|
@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 |
||||
|
|
||||
|
def _version_string(self, key): |
||||
|
version = self.features.get(key) |
||||
|
return '{:d}.{:d}'.format(*self.version_tuple(version)) |
||||
|
|
||||
|
@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 |
||||
|
|
||||
|
@cachedproperty |
||||
|
def protocol_min(self): |
||||
|
'''Minimum protocol version as a string, e.g., 1.0''' |
||||
|
return self._version_string('protcol_min') |
||||
|
|
||||
|
@cachedproperty |
||||
|
def protocol_max(self): |
||||
|
'''Maximum protocol version as a string, e.g., 1.1''' |
||||
|
return self._version_string('protcol_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, host_override=None): |
||||
|
'''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 = [host_override or 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) |
@ -0,0 +1,181 @@ |
|||||
|
# 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. |
||||
|
# and warranty status of this software. |
||||
|
|
||||
|
'''Socks proxying.''' |
||||
|
|
||||
|
import asyncio |
||||
|
import ipaddress |
||||
|
import logging |
||||
|
import socket |
||||
|
import struct |
||||
|
from functools import partial |
||||
|
|
||||
|
import lib.util as util |
||||
|
|
||||
|
|
||||
|
class Socks(util.LoggedClass): |
||||
|
'''Socks protocol wrapper.''' |
||||
|
|
||||
|
SOCKS5_ERRORS = { |
||||
|
1: 'general SOCKS server failure', |
||||
|
2: 'connection not allowed by ruleset', |
||||
|
3: 'network unreachable', |
||||
|
4: 'host unreachable', |
||||
|
5: 'connection refused', |
||||
|
6: 'TTL expired', |
||||
|
7: 'command not supported', |
||||
|
8: 'address type not supported', |
||||
|
} |
||||
|
|
||||
|
class Error(Exception): |
||||
|
pass |
||||
|
|
||||
|
def __init__(self, loop, sock, host, port): |
||||
|
super().__init__() |
||||
|
self.loop = loop |
||||
|
self.sock = sock |
||||
|
self.host = host |
||||
|
self.port = port |
||||
|
try: |
||||
|
self.ip_address = ipaddress.ip_address(host) |
||||
|
except ValueError: |
||||
|
self.ip_address = None |
||||
|
self.debug = False |
||||
|
|
||||
|
async def _socks4_handshake(self): |
||||
|
if self.ip_address: |
||||
|
# Socks 4 |
||||
|
ip_addr = self.ip_address |
||||
|
host_bytes = b'' |
||||
|
else: |
||||
|
# Socks 4a |
||||
|
ip_addr = ipaddress.ip_address('0.0.0.1') |
||||
|
host_bytes = self.host.encode() + b'\0' |
||||
|
|
||||
|
user_id = '' |
||||
|
data = b'\4\1' + struct.pack('>H', self.port) + ip_addr.packed |
||||
|
data += user_id.encode() + b'\0' + host_bytes |
||||
|
await self.loop.sock_sendall(self.sock, data) |
||||
|
data = await self.loop.sock_recv(self.sock, 8) |
||||
|
if data[0] != 0: |
||||
|
raise self.Error('proxy sent bad initial Socks4 byte') |
||||
|
if data[1] != 0x5a: |
||||
|
raise self.Error('proxy request failed or rejected') |
||||
|
|
||||
|
async def _socks5_handshake(self): |
||||
|
await self.loop.sock_sendall(self.sock, b'\5\1\0') |
||||
|
data = await self.loop.sock_recv(self.sock, 2) |
||||
|
if data[0] != 5: |
||||
|
raise self.Error('proxy sent bad SOCKS5 initial byte') |
||||
|
if data[1] != 0: |
||||
|
raise self.Error('proxy rejected SOCKS5 authentication method') |
||||
|
|
||||
|
if self.ip_address: |
||||
|
if self.ip_address.version == 4: |
||||
|
addr = b'\1' + self.ip_address.packed |
||||
|
else: |
||||
|
addr = b'\4' + self.ip_address.packed |
||||
|
else: |
||||
|
host = self.host.encode() |
||||
|
addr = b'\3' + bytes([len(host)]) + host |
||||
|
|
||||
|
data = b'\5\1\0' + addr + struct.pack('>H', self.port) |
||||
|
await self.loop.sock_sendall(self.sock, data) |
||||
|
data = await self.loop.sock_recv(self.sock, 5) |
||||
|
if data[0] != 5: |
||||
|
raise self.Error('proxy sent bad SOSCK5 response initial byte') |
||||
|
if data[1] != 0: |
||||
|
msg = self.SOCKS5_ERRORS.get(data[1], 'unknown SOCKS5 error {:d}' |
||||
|
.format(data[1])) |
||||
|
raise self.Error(msg) |
||||
|
if data[3] == 1: |
||||
|
addr_len, data = 3, data[4:] |
||||
|
elif data[3] == 3: |
||||
|
addr_len, data = data[4], b'' |
||||
|
elif data[3] == 4: |
||||
|
addr_len, data = 15, data[4:] |
||||
|
data = await self.loop.sock_recv(self.sock, addr_len + 2) |
||||
|
addr = data[:addr_len] |
||||
|
port, = struct.unpack('>H', data[-2:]) |
||||
|
|
||||
|
async def handshake(self): |
||||
|
'''Write the proxy handshake sequence.''' |
||||
|
if self.ip_address and self.ip_address.version == 6: |
||||
|
await self._socks5_handshake() |
||||
|
else: |
||||
|
await self._socks4_handshake() |
||||
|
|
||||
|
if self.debug: |
||||
|
address = (self.host, self.port) |
||||
|
self.log_info('successful connection via proxy to {}' |
||||
|
.format(util.address_string(address))) |
||||
|
|
||||
|
|
||||
|
class SocksProxy(util.LoggedClass): |
||||
|
|
||||
|
def __init__(self, host, port, loop=None): |
||||
|
'''Host can be an IPv4 address, IPv6 address, or a host name.''' |
||||
|
super().__init__() |
||||
|
self.host = host |
||||
|
self.port = port |
||||
|
self.ip_addr = None |
||||
|
self.loop = loop or asyncio.get_event_loop() |
||||
|
|
||||
|
async def create_connection(self, protocol_factory, host, port, ssl=None): |
||||
|
'''All arguments are as to asyncio's create_connection method.''' |
||||
|
if self.port is None: |
||||
|
proxy_ports = [9050, 9150, 1080] |
||||
|
else: |
||||
|
proxy_ports = [self.port] |
||||
|
|
||||
|
for proxy_port in proxy_ports: |
||||
|
address = (self.host, proxy_port) |
||||
|
sock = socket.socket() |
||||
|
sock.setblocking(False) |
||||
|
try: |
||||
|
await self.loop.sock_connect(sock, address) |
||||
|
except OSError as e: |
||||
|
if proxy_port == proxy_ports[-1]: |
||||
|
raise |
||||
|
continue |
||||
|
|
||||
|
socks = Socks(self.loop, sock, host, port) |
||||
|
try: |
||||
|
await socks.handshake() |
||||
|
if self.port is None: |
||||
|
self.ip_addr = sock.getpeername()[0] |
||||
|
self.port = proxy_port |
||||
|
self.logger.info('detected proxy at {} ({})' |
||||
|
.format(util.address_string(address), |
||||
|
self.ip_addr)) |
||||
|
break |
||||
|
except Exception as e: |
||||
|
sock.close() |
||||
|
raise |
||||
|
|
||||
|
hostname = host if ssl else None |
||||
|
return await self.loop.create_connection( |
||||
|
protocol_factory, ssl=ssl, sock=sock, server_hostname=hostname) |
@ -1 +1,5 @@ |
|||||
VERSION = "ElectrumX 0.10.19" |
# Server name and protocol versions |
||||
|
|
||||
|
VERSION = 'ElectrumX 0.11.0' |
||||
|
PROTOCOL_MIN = '1.0' |
||||
|
PROTOCOL_MAX = '1.0' |
||||
|
Loading…
Reference in new issue