Exemple #1
0
 def __init__(self, *, framer=None, loop=None):
     self.framer = framer or self.default_framer()
     self.loop = loop or asyncio.get_event_loop()
     self.logger = logging.getLogger(self.__class__.__name__)
     self.transport = None
     # Set when a connection is made
     self._address = None
     self._proxy_address = None
     # For logger.debug messages
     self.verbosity = 0
     # Cleared when the send socket is full
     self._can_send = Event()
     self._can_send.set()
     self._pm_task = None
     self._task_group = TaskGroup(self.loop)
     # Force-close a connection if a send doesn't succeed in this time
     self.max_send_delay = 60
     # Statistics.  The RPC object also keeps its own statistics.
     self.start_time = time.perf_counter()
     self.errors = 0
     self.send_count = 0
     self.send_size = 0
     self.last_send = self.start_time
     self.recv_count = 0
     self.recv_size = 0
     self.last_recv = self.start_time
     # Bandwidth usage per hour before throttling starts
     self.bw_limit = 2000000
     self.bw_time = self.start_time
     self.bw_charge = 0
     # Concurrency control
     self.max_concurrent = 6
     self._concurrency = Concurrency(self.max_concurrent)
Exemple #2
0
    def __init__(self, config=None):
        self.config = config or {}
        self.db: BaseDatabase = self.config.get('db') or self.database_class(
            os.path.join(self.path, "blockchain.db")
        )
        self.db.ledger = self
        self.headers: BaseHeaders = self.config.get('headers') or self.headers_class(
            os.path.join(self.path, "headers")
        )
        self.network = self.config.get('network') or self.network_class(self)
        self.network.on_header.listen(self.receive_header)
        self.network.on_status.listen(self.process_status_update)

        self.accounts = []
        self.fee_per_byte: int = self.config.get('fee_per_byte', self.default_fee_per_byte)

        self._on_transaction_controller = StreamController()
        self.on_transaction = self._on_transaction_controller.stream
        self.on_transaction.listen(
            lambda e: log.info(
                '(%s) on_transaction: address=%s, height=%s, is_verified=%s, tx.id=%s',
                self.get_id(), e.address, e.tx.height, e.tx.is_verified, e.tx.id
            )
        )

        self._on_address_controller = StreamController()
        self.on_address = self._on_address_controller.stream
        self.on_address.listen(
            lambda e: log.info('(%s) on_address: %s', self.get_id(), e.addresses)
        )

        self._on_header_controller = StreamController()
        self.on_header = self._on_header_controller.stream
        self.on_header.listen(
            lambda change: log.info(
                '%s: added %s header blocks, final height %s',
                self.get_id(), change, self.headers.height
            )
        )

        self._on_ready_controller = StreamController()
        self.on_ready = self._on_ready_controller.stream

        self._tx_cache = pylru.lrucache(100000)
        self._update_tasks = TaskGroup()
        self._utxo_reservation_lock = asyncio.Lock()
        self._header_processing_lock = asyncio.Lock()
        self._address_update_locks: Dict[str, asyncio.Lock] = {}

        self.coin_selection_strategy = None
        self._known_addresses_out_of_sync = set()
Exemple #3
0
    def __init__(self, env, db):
        self.logger = class_logger(__name__, self.__class__.__name__)
        # Initialise the Peer class
        Peer.DEFAULT_PORTS = env.coin.PEER_DEFAULT_PORTS
        self.env = env
        self.db = db

        # Our clearnet and Tor Peers, if any
        sclass = env.coin.SESSIONCLS
        self.myselves = [
            Peer(ident.host, sclass.server_features(env), 'env')
            for ident in env.identities
        ]
        self.server_version_args = sclass.server_version_args()
        # Peers have one entry per hostname.  Once connected, the
        # ip_addr property is either None, an onion peer, or the
        # IP address that was connected to.  Adding a peer will evict
        # any other peers with the same host name or IP address.
        self.peers: typing.Set[Peer] = set()
        self.permit_onion_peer_time = time.time()
        self.proxy = None
        self.group = TaskGroup()
Exemple #4
0
class BaseLedger(metaclass=LedgerRegistry):

    name: str
    symbol: str
    network_name: str

    database_class = BaseDatabase
    account_class = baseaccount.BaseAccount
    network_class = basenetwork.BaseNetwork
    transaction_class = basetransaction.BaseTransaction

    headers_class: Type[BaseHeaders]

    pubkey_address_prefix: bytes
    script_address_prefix: bytes
    extended_public_key_prefix: bytes
    extended_private_key_prefix: bytes

    default_fee_per_byte = 10

    def __init__(self, config=None):
        self.config = config or {}
        self.db: BaseDatabase = self.config.get('db') or self.database_class(
            os.path.join(self.path, "blockchain.db"))
        self.db.ledger = self
        self.headers: BaseHeaders = self.config.get(
            'headers') or self.headers_class(os.path.join(
                self.path, "headers"))
        self.network = self.config.get('network') or self.network_class(self)
        self.network.on_header.listen(self.receive_header)
        self.network.on_status.listen(self.process_status_update)

        self.accounts = []
        self.fee_per_byte: int = self.config.get('fee_per_byte',
                                                 self.default_fee_per_byte)

        self._on_transaction_controller = StreamController()
        self.on_transaction = self._on_transaction_controller.stream
        self.on_transaction.listen(lambda e: log.info(
            '(%s) on_transaction: address=%s, height=%s, is_verified=%s, tx.id=%s',
            self.get_id(), e.address, e.tx.height, e.tx.is_verified, e.tx.id))

        self._on_address_controller = StreamController()
        self.on_address = self._on_address_controller.stream
        self.on_address.listen(lambda e: log.info('(%s) on_address: %s',
                                                  self.get_id(), e.addresses))

        self._on_header_controller = StreamController()
        self.on_header = self._on_header_controller.stream
        self.on_header.listen(lambda change: log.info(
            '%s: added %s header blocks, final height %s', self.get_id(),
            change, self.headers.height))

        self._tx_cache = pylru.lrucache(100000)
        self._update_tasks = TaskGroup()
        self._utxo_reservation_lock = asyncio.Lock()
        self._header_processing_lock = asyncio.Lock()
        self._address_update_locks: Dict[str, asyncio.Lock] = {}

        self.coin_selection_strategy = None
        self._known_addresses_out_of_sync = set()

    @classmethod
    def get_id(cls):
        return '{}_{}'.format(cls.symbol.lower(), cls.network_name.lower())

    @classmethod
    def hash160_to_address(cls, h160):
        raw_address = cls.pubkey_address_prefix + h160
        return Base58.encode(
            bytearray(raw_address + double_sha256(raw_address)[0:4]))

    @staticmethod
    def address_to_hash160(address):
        return Base58.decode(address)[1:21]

    @classmethod
    def is_valid_address(cls, address):
        decoded = Base58.decode_check(address)
        return decoded[0] == cls.pubkey_address_prefix[0]

    @classmethod
    def public_key_to_address(cls, public_key):
        return cls.hash160_to_address(hash160(public_key))

    @staticmethod
    def private_key_to_wif(private_key):
        return b'\x1c' + private_key + b'\x01'

    @property
    def path(self):
        return os.path.join(self.config['data_path'], self.get_id())

    def add_account(self, account: baseaccount.BaseAccount):
        self.accounts.append(account)

    async def _get_account_and_address_info_for_address(self, address):
        match = await self.db.get_address(address=address)
        if match:
            for account in self.accounts:
                if match['account'] == account.public_key.address:
                    return account, match

    async def get_private_key_for_address(self,
                                          address) -> Optional[PrivateKey]:
        match = await self._get_account_and_address_info_for_address(address)
        if match:
            account, address_info = match
            return account.get_private_key(address_info['chain'],
                                           address_info['position'])
        return None

    async def get_public_key_for_address(self, address) -> Optional[PubKey]:
        match = await self._get_account_and_address_info_for_address(address)
        if match:
            account, address_info = match
            return account.get_public_key(address_info['chain'],
                                          address_info['position'])
        return None

    async def get_account_for_address(self, address):
        match = await self._get_account_and_address_info_for_address(address)
        if match:
            return match[0]

    async def get_effective_amount_estimators(
            self, funding_accounts: Iterable[baseaccount.BaseAccount]):
        estimators = []
        for account in funding_accounts:
            utxos = await account.get_utxos()
            for utxo in utxos:
                estimators.append(utxo.get_estimator(self))
        return estimators

    async def get_addresses(self, **constraints):
        self.constraint_account_or_all(constraints)
        addresses = await self.db.get_addresses(**constraints)
        for address in addresses:
            public_key = await self.get_public_key_for_address(
                address['address'])
            address['public_key'] = public_key.extended_key_string()
        return addresses

    def get_address_count(self, **constraints):
        self.constraint_account_or_all(constraints)
        return self.db.get_address_count(**constraints)

    async def get_spendable_utxos(self, amount: int, funding_accounts):
        async with self._utxo_reservation_lock:
            txos = await self.get_effective_amount_estimators(funding_accounts)
            fee = self.transaction_class.output_class.pay_pubkey_hash(
                COIN, NULL_HASH32).get_fee(self)
            selector = CoinSelector(amount, fee)
            spendables = selector.select(txos, self.coin_selection_strategy)
            if spendables:
                await self.reserve_outputs(s.txo for s in spendables)
            return spendables

    def reserve_outputs(self, txos):
        return self.db.reserve_outputs(txos)

    def release_outputs(self, txos):
        return self.db.release_outputs(txos)

    def release_tx(self, tx):
        return self.release_outputs([txi.txo_ref.txo for txi in tx.inputs])

    def constraint_account_or_all(self, constraints):
        if 'accounts' in constraints:
            return
        account = constraints.pop('account', None)
        if account:
            constraints['accounts'] = [account]
        else:
            constraints['accounts'] = self.accounts

    def get_utxos(self, **constraints):
        self.constraint_account_or_all(constraints)
        return self.db.get_utxos(**constraints)

    def get_utxo_count(self, **constraints):
        self.constraint_account_or_all(constraints)
        return self.db.get_utxo_count(**constraints)

    def get_transactions(self, **constraints):
        self.constraint_account_or_all(constraints)
        return self.db.get_transactions(**constraints)

    def get_transaction_count(self, **constraints):
        self.constraint_account_or_all(constraints)
        return self.db.get_transaction_count(**constraints)

    async def get_local_status_and_history(self, address, history=None):
        if not history:
            address_details = await self.db.get_address(address=address)
            history = address_details['history'] or ''
        parts = history.split(':')[:-1]
        return (hexlify(sha256(history.encode())).decode() if history else
                None, list(zip(parts[0::2], map(int, parts[1::2]))))

    @staticmethod
    def get_root_of_merkle_tree(branches, branch_positions, working_branch):
        for i, branch in enumerate(branches):
            other_branch = unhexlify(branch)[::-1]
            other_branch_on_left = bool((branch_positions >> i) & 1)
            if other_branch_on_left:
                combined = other_branch + working_branch
            else:
                combined = working_branch + other_branch
            working_branch = double_sha256(combined)
        return hexlify(working_branch[::-1])

    async def start(self):
        if not os.path.exists(self.path):
            os.mkdir(self.path)
        await asyncio.wait([self.db.open(), self.headers.open()])
        first_connection = self.network.on_connected.first
        asyncio.ensure_future(self.network.start())
        await first_connection
        await self.join_network()
        self.network.on_connected.listen(self.join_network)

    async def join_network(self, *_):
        log.info("Subscribing and updating accounts.")
        async with self._header_processing_lock:
            await self.update_headers()
        await self.subscribe_accounts()
        await self._update_tasks.done.wait()

    async def stop(self):
        self._update_tasks.cancel()
        await self._update_tasks.done.wait()
        await self.network.stop()
        await self.db.close()
        await self.headers.close()

    async def update_headers(self,
                             height=None,
                             headers=None,
                             subscription_update=False):
        rewound = 0
        while True:

            if height is None or height > len(self.headers):
                # sometimes header subscription updates are for a header in the future
                # which can't be connected, so we do a normal header sync instead
                height = len(self.headers)
                headers = None
                subscription_update = False

            if not headers:
                header_response = await self.network.retriable_call(
                    self.network.get_headers, height, 2001)
                headers = header_response['hex']

            if not headers:
                # Nothing to do, network thinks we're already at the latest height.
                return

            added = await self.headers.connect(height, unhexlify(headers))
            if added > 0:
                height += added
                self._on_header_controller.add(
                    BlockHeightEvent(self.headers.height, added))

                if rewound > 0:
                    # we started rewinding blocks and apparently found
                    # a new chain
                    rewound = 0
                    await self.db.rewind_blockchain(height)

                if subscription_update:
                    # subscription updates are for latest header already
                    # so we don't need to check if there are newer / more
                    # on another loop of update_headers(), just return instead
                    return

            elif added == 0:
                # we had headers to connect but none got connected, probably a reorganization
                height -= 1
                rewound += 1
                log.warning(
                    "Blockchain Reorganization: attempting rewind to height %s from starting height %s",
                    height, height + rewound)

            else:
                raise IndexError(
                    "headers.connect() returned negative number ({})".format(
                        added))

            if height < 0:
                raise IndexError(
                    "Blockchain reorganization rewound all the way back to genesis hash. "
                    "Something is very wrong. Maybe you are on the wrong blockchain?"
                )

            if rewound >= 100:
                raise IndexError(
                    "Blockchain reorganization dropped {} headers. This is highly unusual. "
                    "Will not continue to attempt reorganizing. Please, delete the ledger "
                    "synchronization directory inside your wallet directory (folder: '{}') and "
                    "restart the program to synchronize from scratch.".format(
                        rewound, self.get_id()))

            headers = None  # ready to download some more headers

            # if we made it this far and this was a subscription_update
            # it means something went wrong and now we're doing a more
            # robust sync, turn off subscription update shortcut
            subscription_update = False

    async def receive_header(self, response):
        async with self._header_processing_lock:
            header = response[0]
            await self.update_headers(height=header['height'],
                                      headers=header['hex'],
                                      subscription_update=True)

    async def subscribe_accounts(self):
        if self.network.is_connected and self.accounts:
            await asyncio.wait(
                [self.subscribe_account(a) for a in self.accounts])

    async def subscribe_account(self, account: baseaccount.BaseAccount):
        for address_manager in account.address_managers.values():
            await self.subscribe_addresses(
                address_manager, await address_manager.get_addresses())
        await account.ensure_address_gap()

    async def announce_addresses(self,
                                 address_manager: baseaccount.AddressManager,
                                 addresses: List[str]):
        await self.subscribe_addresses(address_manager, addresses)
        await self._on_address_controller.add(
            AddressesGeneratedEvent(address_manager, addresses))

    async def subscribe_addresses(self,
                                  address_manager: baseaccount.AddressManager,
                                  addresses: List[str]):
        if self.network.is_connected and addresses:
            await asyncio.wait([
                self.subscribe_address(address_manager, address)
                for address in addresses
            ])

    async def subscribe_address(self,
                                address_manager: baseaccount.AddressManager,
                                address: str):
        remote_status = await self.network.subscribe_address(address)
        self._update_tasks.add(
            self.update_history(address, remote_status, address_manager))

    def process_status_update(self, update):
        address, remote_status = update
        self._update_tasks.add(self.update_history(address, remote_status))

    async def update_history(
            self,
            address,
            remote_status,
            address_manager: baseaccount.AddressManager = None):

        async with self._address_update_locks.setdefault(
                address, asyncio.Lock()):
            self._known_addresses_out_of_sync.discard(address)

            local_status, local_history = await self.get_local_status_and_history(
                address)

            if local_status == remote_status:
                return True

            remote_history = await self.network.retriable_call(
                self.network.get_history, address)
            remote_history = list(
                map(itemgetter('tx_hash', 'height'), remote_history))
            we_need = set(remote_history) - set(local_history)
            if not we_need:
                return True

            cache_tasks: List[asyncio.Future[BaseTransaction]] = []
            synced_history = StringIO()
            for i, (txid, remote_height) in enumerate(remote_history):
                if i < len(local_history) and local_history[i] == (
                        txid, remote_height) and not cache_tasks:
                    synced_history.write(f'{txid}:{remote_height}:')
                else:
                    check_local = (txid, remote_height) not in we_need
                    cache_tasks.append(
                        asyncio.ensure_future(
                            self.cache_transaction(txid,
                                                   remote_height,
                                                   check_local=check_local)))

            synced_txs = []
            for task in cache_tasks:
                tx = await task

                check_db_for_txos = []
                for txi in tx.inputs:
                    if txi.txo_ref.txo is not None:
                        continue
                    cache_item = self._tx_cache.get(txi.txo_ref.tx_ref.id)
                    if cache_item is not None:
                        if cache_item.tx is None:
                            await cache_item.has_tx.wait()
                        assert cache_item.tx is not None
                        txi.txo_ref = cache_item.tx.outputs[
                            txi.txo_ref.position].ref
                    else:
                        check_db_for_txos.append(txi.txo_ref.id)

                referenced_txos = {} if not check_db_for_txos else {
                    txo.id: txo
                    for txo in await self.db.get_txos(
                        txoid__in=check_db_for_txos, no_tx=True)
                }

                for txi in tx.inputs:
                    if txi.txo_ref.txo is not None:
                        continue
                    referenced_txo = referenced_txos.get(txi.txo_ref.id)
                    if referenced_txo is not None:
                        txi.txo_ref = referenced_txo.ref

                synced_history.write(f'{tx.id}:{tx.height}:')
                synced_txs.append(tx)

            await self.db.save_transaction_io_batch(
                synced_txs, address, self.address_to_hash160(address),
                synced_history.getvalue())
            await asyncio.wait([
                self._on_transaction_controller.add(
                    TransactionEvent(address, tx)) for tx in synced_txs
            ])

            if address_manager is None:
                address_manager = await self.get_address_manager_for_address(
                    address)

            if address_manager is not None:
                await address_manager.ensure_address_gap()

            local_status, local_history = \
                await self.get_local_status_and_history(address, synced_history.getvalue())
            if local_status != remote_status:
                if local_history == remote_history:
                    return True
                log.warning(
                    "Wallet is out of sync after syncing. Remote: %s with %d items, local: %s with %d items",
                    remote_status, len(remote_history), local_status,
                    len(local_history))
                log.warning("local: %s", local_history)
                log.warning("remote: %s", remote_history)
                self._known_addresses_out_of_sync.add(address)
                return False
            else:
                return True

    async def cache_transaction(self, txid, remote_height, check_local=True):
        cache_item = self._tx_cache.get(txid)
        if cache_item is None:
            cache_item = self._tx_cache[txid] = TransactionCacheItem()
        elif cache_item.tx is not None and \
                cache_item.tx.height >= remote_height and \
                (cache_item.tx.is_verified or remote_height < 1):
            return cache_item.tx  # cached tx is already up-to-date

        async with cache_item.lock:

            tx = cache_item.tx

            if tx is None and check_local:
                # check local db
                tx = cache_item.tx = await self.db.get_transaction(txid=txid)

            if tx is None:
                # fetch from network
                _raw = await self.network.retriable_call(
                    self.network.get_transaction, txid, remote_height)
                if _raw:
                    tx = self.transaction_class(unhexlify(_raw))
                    cache_item.tx = tx  # make sure it's saved before caching it

            if tx is None:
                raise ValueError(
                    f'Transaction {txid} was not in database and not on network.'
                )

            await self.maybe_verify_transaction(tx, remote_height)
            return tx

    async def maybe_verify_transaction(self, tx, remote_height):
        tx.height = remote_height
        if 0 < remote_height < len(self.headers):
            merkle = await self.network.retriable_call(self.network.get_merkle,
                                                       tx.id, remote_height)
            merkle_root = self.get_root_of_merkle_tree(merkle['merkle'],
                                                       merkle['pos'], tx.hash)
            header = self.headers[remote_height]
            tx.position = merkle['pos']
            tx.is_verified = merkle_root == header['merkle_root']

    async def get_address_manager_for_address(
            self, address) -> Optional[baseaccount.AddressManager]:
        details = await self.db.get_address(address=address)
        for account in self.accounts:
            if account.id == details['account']:
                return account.address_managers[details['chain']]
        return None

    def broadcast(self, tx):
        # broadcast cant be a retriable call yet
        return self.network.broadcast(hexlify(tx.raw).decode())

    async def wait(self,
                   tx: basetransaction.BaseTransaction,
                   height=-1,
                   timeout=None):
        addresses = set()
        for txi in tx.inputs:
            if txi.txo_ref.txo is not None:
                addresses.add(
                    self.hash160_to_address(
                        txi.txo_ref.txo.script.values['pubkey_hash']))
        for txo in tx.outputs:
            addresses.add(
                self.hash160_to_address(txo.script.values['pubkey_hash']))
        records = await self.db.get_addresses(cols=('address', ),
                                              address__in=addresses)
        _, pending = await asyncio.wait([
            self.on_transaction.where(
                partial(
                    lambda a, e: a == e.address and e.tx.height >= height and e
                    .tx.id == tx.id, address_record['address']))
            for address_record in records
        ],
                                        timeout=timeout)
        if pending:
            raise asyncio.TimeoutError('Timed out waiting for transaction.')
 async def test_cancel_sets_it_done(self):
     group = TaskGroup()
     group.cancel()
     self.assertTrue(group.done.is_set())
Exemple #6
0
class SessionBase(asyncio.Protocol):
    """Base class of networking sessions.

    There is no client / server distinction other than who initiated
    the connection.

    To initiate a connection to a remote server pass host, port and
    proxy to the constructor, and then call create_connection().  Each
    successful call should have a corresponding call to close().

    Alternatively if used in a with statement, the connection is made
    on entry to the block, and closed on exit from the block.
    """

    max_errors = 10

    def __init__(self, *, framer=None, loop=None):
        self.framer = framer or self.default_framer()
        self.loop = loop or asyncio.get_event_loop()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.transport = None
        # Set when a connection is made
        self._address = None
        self._proxy_address = None
        # For logger.debug messages
        self.verbosity = 0
        # Cleared when the send socket is full
        self._can_send = Event()
        self._can_send.set()
        self._pm_task = None
        self._task_group = TaskGroup(self.loop)
        # Force-close a connection if a send doesn't succeed in this time
        self.max_send_delay = 60
        # Statistics.  The RPC object also keeps its own statistics.
        self.start_time = time.perf_counter()
        self.errors = 0
        self.send_count = 0
        self.send_size = 0
        self.last_send = self.start_time
        self.recv_count = 0
        self.recv_size = 0
        self.last_recv = self.start_time
        # Bandwidth usage per hour before throttling starts
        self.bw_limit = 2000000
        self.bw_time = self.start_time
        self.bw_charge = 0
        # Concurrency control
        self.max_concurrent = 6
        self._concurrency = Concurrency(self.max_concurrent)

    async def _update_concurrency(self):
        # A non-positive value means not to limit concurrency
        if self.bw_limit <= 0:
            return
        now = time.perf_counter()
        # Reduce the recorded usage in proportion to the elapsed time
        refund = (now - self.bw_time) * (self.bw_limit / 3600)
        self.bw_charge = max(0, self.bw_charge - int(refund))
        self.bw_time = now
        # Reduce concurrency allocation by 1 for each whole bw_limit used
        throttle = int(self.bw_charge / self.bw_limit)
        target = max(1, self.max_concurrent - throttle)
        current = self._concurrency.max_concurrent
        if target != current:
            self.logger.info(f'changing task concurrency from {current} '
                             f'to {target}')
            await self._concurrency.set_max_concurrent(target)

    def _using_bandwidth(self, size):
        """Called when sending or receiving size bytes."""
        self.bw_charge += size

    async def _limited_wait(self, secs):
        try:
            await asyncio.wait_for(self._can_send.wait(), secs)
        except asyncio.TimeoutError:
            self.abort()
            raise asyncio.TimeoutError(f'task timed out after {secs}s')

    async def _send_message(self, message):
        if not self._can_send.is_set():
            await self._limited_wait(self.max_send_delay)
        if not self.is_closing():
            framed_message = self.framer.frame(message)
            self.send_size += len(framed_message)
            self._using_bandwidth(len(framed_message))
            self.send_count += 1
            self.last_send = time.perf_counter()
            if self.verbosity >= 4:
                self.logger.debug(f'Sending framed message {framed_message}')
            self.transport.write(framed_message)

    def _bump_errors(self):
        self.errors += 1
        if self.errors >= self.max_errors:
            # Don't await self.close() because that is self-cancelling
            self._close()

    def _close(self):
        if self.transport:
            self.transport.close()

    # asyncio framework
    def data_received(self, framed_message):
        """Called by asyncio when a message comes in."""
        if self.verbosity >= 4:
            self.logger.debug(f'Received framed message {framed_message}')
        self.recv_size += len(framed_message)
        self._using_bandwidth(len(framed_message))
        self.framer.received_bytes(framed_message)

    def pause_writing(self):
        """Transport calls when the send buffer is full."""
        if not self.is_closing():
            self._can_send.clear()
            self.transport.pause_reading()

    def resume_writing(self):
        """Transport calls when the send buffer has room."""
        if not self._can_send.is_set():
            self._can_send.set()
            self.transport.resume_reading()

    def connection_made(self, transport):
        """Called by asyncio when a connection is established.

        Derived classes overriding this method must call this first."""
        self.transport = transport
        # This would throw if called on a closed SSL transport.  Fixed
        # in asyncio in Python 3.6.1 and 3.5.4
        peer_address = transport.get_extra_info('peername')
        # If the Socks proxy was used then _address is already set to
        # the remote address
        if self._address:
            self._proxy_address = peer_address
        else:
            self._address = peer_address
        self._pm_task = self.loop.create_task(self._receive_messages())

    def connection_lost(self, exc):
        """Called by asyncio when the connection closes.

        Tear down things done in connection_made."""
        self._address = None
        self.transport = None
        self._task_group.cancel()
        if self._pm_task:
            self._pm_task.cancel()
        # Release waiting tasks
        self._can_send.set()

    # External API
    def default_framer(self):
        """Return a default framer."""
        raise NotImplementedError

    def peer_address(self):
        """Returns the peer's address (Python networking address), or None if
        no connection or an error.

        This is the result of socket.getpeername() when the connection
        was made.
        """
        return self._address

    def peer_address_str(self):
        """Returns the peer's IP address and port as a human-readable
        string."""
        if not self._address:
            return 'unknown'
        ip_addr_str, port = self._address[:2]
        if ':' in ip_addr_str:
            return f'[{ip_addr_str}]:{port}'
        else:
            return f'{ip_addr_str}:{port}'

    def is_closing(self):
        """Return True if the connection is closing."""
        return not self.transport or self.transport.is_closing()

    def abort(self):
        """Forcefully close the connection."""
        if self.transport:
            self.transport.abort()

    # TODO: replace with synchronous_close
    async def close(self, *, force_after=30):
        """Close the connection and return when closed."""
        self._close()
        if self._pm_task:
            with suppress(CancelledError):
                await asyncio.wait([self._pm_task], timeout=force_after)
                self.abort()
                await self._pm_task

    def synchronous_close(self):
        self._close()
        if self._pm_task and not self._pm_task.done():
            self._pm_task.cancel()
Exemple #7
0
class PeerManager:
    """Looks after the DB of peer network servers.

    Attempts to maintain a connection with up to 8 peers.
    Issues a 'peers.subscribe' RPC to them and tells them our data.
    """
    def __init__(self, env, db):
        self.logger = class_logger(__name__, self.__class__.__name__)
        # Initialise the Peer class
        Peer.DEFAULT_PORTS = env.coin.PEER_DEFAULT_PORTS
        self.env = env
        self.db = db

        # Our clearnet and Tor Peers, if any
        sclass = env.coin.SESSIONCLS
        self.myselves = [
            Peer(ident.host, sclass.server_features(env), 'env')
            for ident in env.identities
        ]
        self.server_version_args = sclass.server_version_args()
        # Peers have one entry per hostname.  Once connected, the
        # ip_addr property is either None, an onion peer, or the
        # IP address that was connected to.  Adding a peer will evict
        # any other peers with the same host name or IP address.
        self.peers: typing.Set[Peer] = set()
        self.permit_onion_peer_time = time.time()
        self.proxy = None
        self.group = TaskGroup()

    def _my_clearnet_peer(self):
        """Returns the clearnet peer representing this server, if any."""
        clearnet = [peer for peer in self.myselves if not peer.is_tor]
        return clearnet[0] if clearnet else None

    def _set_peer_statuses(self):
        """Set peer statuses."""
        cutoff = time.time() - STALE_SECS
        for peer in self.peers:
            if peer.bad:
                peer.status = PEER_BAD
            elif peer.last_good > cutoff:
                peer.status = PEER_GOOD
            elif peer.last_good:
                peer.status = PEER_STALE
            else:
                peer.status = PEER_NEVER

    def _features_to_register(self, peer, remote_peers):
        """If we should register ourselves to the remote peer, which has
        reported the given list of known peers, return the clearnet
        identity features to register, otherwise None.
        """
        # Announce ourself if not present.  Don't if disabled, we
        # are a non-public IP address, or to ourselves.
        if not self.env.peer_announce or peer in self.myselves:
            return None
        my = self._my_clearnet_peer()
        if not my or not my.is_public:
            return None
        # Register if no matches, or ports have changed
        for peer in my.matches(remote_peers):
            if peer.tcp_port == my.tcp_port and peer.ssl_port == my.ssl_port:
                return None
        return my.features

    def _permit_new_onion_peer(self):
        """Accept a new onion peer only once per random time interval."""
        now = time.time()
        if now < self.permit_onion_peer_time:
            return False
        self.permit_onion_peer_time = now + random.randrange(0, 1200)
        return True

    async def _import_peers(self):
        """Import hard-coded peers from a file or the coin defaults."""
        imported_peers = self.myselves.copy()
        # Add the hard-coded ones unless only reporting ourself
        if self.env.peer_discovery != self.env.PD_SELF:
            imported_peers.extend(
                Peer.from_real_name(real_name, 'coins.py')
                for real_name in self.env.coin.PEERS)
        await self._note_peers(imported_peers, limit=None)

    async def _detect_proxy(self):
        """Detect a proxy if we don't have one and some time has passed since
        the last attempt.

        If found self.proxy is set to a SOCKSProxy instance, otherwise
        None.
        """
        host = self.env.tor_proxy_host
        if self.env.tor_proxy_port is None:
            ports = [9050, 9150, 1080]
        else:
            ports = [self.env.tor_proxy_port]
        while True:
            self.logger.info(f'trying to detect proxy on "{host}" '
                             f'ports {ports}')
            proxy = await SOCKSProxy.auto_detect_host(host, ports, None)
            if proxy:
                self.proxy = proxy
                self.logger.info(f'detected {proxy}')
                return
            self.logger.info('no proxy detected, will try later')
            await sleep(900)

    async def _note_peers(self,
                          peers,
                          limit=2,
                          check_ports=False,
                          source=None):
        """Add a limited number of peers that are not already present."""
        new_peers = []
        for peer in peers:
            if not peer.is_public or (peer.is_tor and not self.proxy):
                continue

            matches = peer.matches(self.peers)
            if not matches:
                new_peers.append(peer)
            elif check_ports:
                for match in matches:
                    if match.check_ports(peer):
                        self.logger.info(f'ports changed for {peer}')
                        match.retry_event.set()

        if new_peers:
            source = source or new_peers[0].source
            if limit:
                random.shuffle(new_peers)
                use_peers = new_peers[:limit]
            else:
                use_peers = new_peers
            for peer in use_peers:
                self.logger.info(f'accepted new peer {peer} from {source}')
                peer.retry_event = Event()
                self.peers.add(peer)
                await self.group.add(self._monitor_peer(peer))

    async def _monitor_peer(self, peer):
        # Stop monitoring if we were dropped (a duplicate peer)
        while peer in self.peers:
            if await self._should_drop_peer(peer):
                self.peers.discard(peer)
                break
            # Figure out how long to sleep before retrying.  Retry a
            # good connection when it is about to turn stale, otherwise
            # exponentially back off retries.
            if peer.try_count == 0:
                pause = STALE_SECS - WAKEUP_SECS * 2
            else:
                pause = WAKEUP_SECS * 2**peer.try_count
            pending, done = await asyncio.wait([peer.retry_event.wait()],
                                               timeout=pause)
            if done:
                peer.retry_event.clear()

    async def _should_drop_peer(self, peer):
        peer.try_count += 1
        is_good = False
        for kind, port in peer.connection_port_pairs():
            peer.last_try = time.time()

            kwargs = {}
            if kind == 'SSL':
                kwargs['ssl'] = ssl.SSLContext(ssl.PROTOCOL_TLS)

            host = self.env.cs_host(for_rpc=False)
            if isinstance(host, list):
                host = host[0]

            if self.env.force_proxy or peer.is_tor:
                if not self.proxy:
                    return
                kwargs['proxy'] = self.proxy
                kwargs['resolve'] = not peer.is_tor
            elif host:
                # Use our listening Host/IP for outgoing non-proxy
                # connections so our peers see the correct source.
                kwargs['local_addr'] = (host, None)

            peer_text = f'[{peer}:{port} {kind}]'
            try:
                async with Connector(PeerSession, peer.host, port,
                                     **kwargs) as session:
                    await asyncio.wait_for(self._verify_peer(session, peer),
                                           120 if peer.is_tor else 30)
                is_good = True
                break
            except BadPeerError as e:
                self.logger.error(f'{peer_text} marking bad: ({e})')
                peer.mark_bad()
                break
            except RPCError as e:
                self.logger.error(f'{peer_text} RPC error: {e.message} '
                                  f'({e.code})')
            except (OSError, SOCKSError, ConnectionError,
                    asyncio.TimeoutError) as e:
                self.logger.info(f'{peer_text} {e}')

        if is_good:
            now = time.time()
            elapsed = now - peer.last_try
            self.logger.info(f'{peer_text} verified in {elapsed:.1f}s')
            peer.try_count = 0
            peer.last_good = now
            peer.source = 'peer'
            # At most 2 matches if we're a host name, potentially
            # several if we're an IP address (several instances
            # can share a NAT).
            matches = peer.matches(self.peers)
            for match in matches:
                if match.ip_address:
                    if len(matches) > 1:
                        self.peers.remove(match)
                        # Force the peer's monitoring task to exit
                        match.retry_event.set()
                elif peer.host in match.features['hosts']:
                    match.update_features_from_peer(peer)
        else:
            # Forget the peer if long-term unreachable
            if peer.last_good and not peer.bad:
                try_limit = 10
            else:
                try_limit = 3
            if peer.try_count >= try_limit:
                desc = 'bad' if peer.bad else 'unreachable'
                self.logger.info(f'forgetting {desc} peer: {peer}')
                return True
        return False

    async def _verify_peer(self, session, peer):
        if not peer.is_tor:
            address = session.peer_address()
            if address:
                peer.ip_addr = address[0]

        # server.version goes first
        message = 'server.version'
        result = await session.send_request(message, self.server_version_args)
        assert_good(message, result, list)

        # Protocol version 1.1 returns a pair with the version first
        if len(result) != 2 or not all(isinstance(x, str) for x in result):
            raise BadPeerError(f'bad server.version result: {result}')
        server_version, protocol_version = result
        peer.server_version = server_version
        peer.features['server_version'] = server_version
        ptuple = protocol_tuple(protocol_version)

        await asyncio.wait([
            self._send_headers_subscribe(session, peer, ptuple),
            self._send_server_features(session, peer),
            self._send_peers_subscribe(session, peer)
        ])

    async def _send_headers_subscribe(self, session, peer, ptuple):
        message = 'blockchain.headers.subscribe'
        result = await session.send_request(message)
        assert_good(message, result, dict)

        our_height = self.db.db_height
        if ptuple < (1, 3):
            their_height = result.get('block_height')
        else:
            their_height = result.get('height')
        if not isinstance(their_height, int):
            raise BadPeerError(f'invalid height {their_height}')
        if abs(our_height - their_height) > 5:
            raise BadPeerError(f'bad height {their_height:,d} '
                               f'(ours: {our_height:,d})')

        # Check prior header too in case of hard fork.
        check_height = min(our_height, their_height)
        raw_header = await self.db.raw_header(check_height)
        if ptuple >= (1, 4):
            ours = raw_header.hex()
            message = 'blockchain.block.header'
            theirs = await session.send_request(message, [check_height])
            assert_good(message, theirs, str)
            if ours != theirs:
                raise BadPeerError(f'our header {ours} and '
                                   f'theirs {theirs} differ')
        else:
            ours = self.env.coin.electrum_header(raw_header, check_height)
            ours = ours.get('prev_block_hash')
            message = 'blockchain.block.get_header'
            theirs = await session.send_request(message, [check_height])
            assert_good(message, theirs, dict)
            theirs = theirs.get('prev_block_hash')
            if ours != theirs:
                raise BadPeerError(f'our header hash {ours} and '
                                   f'theirs {theirs} differ')

    async def _send_server_features(self, session, peer):
        message = 'server.features'
        features = await session.send_request(message)
        assert_good(message, features, dict)
        hosts = [host.lower() for host in features.get('hosts', {})]
        if self.env.coin.GENESIS_HASH != features.get('genesis_hash'):
            raise BadPeerError('incorrect genesis hash')
        elif peer.host.lower() in hosts:
            peer.update_features(features)
        else:
            raise BadPeerError(f'not listed in own hosts list {hosts}')

    async def _send_peers_subscribe(self, session, peer):
        message = 'server.peers.subscribe'
        raw_peers = await session.send_request(message)
        assert_good(message, raw_peers, list)

        # Check the peers list we got from a remote peer.
        # Each is expected to be of the form:
        #    [ip_addr, hostname, ['v1.0', 't51001', 's51002']]
        # Call add_peer if the remote doesn't appear to know about us.
        try:
            real_names = [' '.join([u[1]] + u[2]) for u in raw_peers]
            peers = [
                Peer.from_real_name(real_name, str(peer))
                for real_name in real_names
            ]
        except Exception:
            raise BadPeerError('bad server.peers.subscribe response')

        await self._note_peers(peers)
        features = self._features_to_register(peer, peers)
        if not features:
            return
        self.logger.info(f'registering ourself with {peer}')
        # We only care to wait for the response
        await session.send_request('server.add_peer', [features])

    #
    # External interface
    #
    async def discover_peers(self):
        """Perform peer maintenance.  This includes

          1) Forgetting unreachable peers.
          2) Verifying connectivity of new peers.
          3) Retrying old peers at regular intervals.
        """
        if self.env.peer_discovery != self.env.PD_ON:
            self.logger.info('peer discovery is disabled')
            return

        self.logger.info(f'beginning peer discovery. Force use of '
                         f'proxy: {self.env.force_proxy}')

        self.group.add(self._detect_proxy())
        self.group.add(self._import_peers())

    def info(self) -> typing.Dict[str, int]:
        """The number of peers."""
        self._set_peer_statuses()
        counter = Counter(peer.status for peer in self.peers)
        return {
            'bad': counter[PEER_BAD],
            'good': counter[PEER_GOOD],
            'never': counter[PEER_NEVER],
            'stale': counter[PEER_STALE],
            'total': len(self.peers),
        }

    async def add_localRPC_peer(self, real_name):
        """Add a peer passed by the admin over LocalRPC."""
        await self._note_peers([Peer.from_real_name(real_name, 'RPC')])

    async def on_add_peer(self, features, source_info):
        """Add a peer (but only if the peer resolves to the source)."""
        if not source_info:
            self.logger.info('ignored add_peer request: no source info')
            return False
        source = source_info[0]
        peers = Peer.peers_from_features(features, source)
        if not peers:
            self.logger.info('ignored add_peer request: no peers given')
            return False

        # Just look at the first peer, require it
        peer = peers[0]
        host = peer.host
        if peer.is_tor:
            permit = self._permit_new_onion_peer()
            reason = 'rate limiting'
        else:
            getaddrinfo = asyncio.get_event_loop().getaddrinfo
            try:
                infos = await getaddrinfo(host, 80, type=socket.SOCK_STREAM)
            except socket.gaierror:
                permit = False
                reason = 'address resolution failure'
            else:
                permit = any(source == info[-1][0] for info in infos)
                reason = 'source-destination mismatch'

        if permit:
            self.logger.info(f'accepted add_peer request from {source} '
                             f'for {host}')
            await self._note_peers([peer], check_ports=True)
        else:
            self.logger.warning(f'rejected add_peer request from {source} '
                                f'for {host} ({reason})')

        return permit

    def on_peers_subscribe(self, is_tor):
        """Returns the server peers as a list of (ip, host, details) tuples.

        We return all peers we've connected to in the last day.
        Additionally, if we don't have onion routing, we return a few
        hard-coded onion servers.
        """
        cutoff = time.time() - STALE_SECS
        recent = [
            peer for peer in self.peers
            if peer.last_good > cutoff and not peer.bad and peer.is_public
        ]
        onion_peers = []

        # Always report ourselves if valid (even if not public)
        peers = {
            myself
            for myself in self.myselves if myself.last_good > cutoff
        }

        # Bucket the clearnet peers and select up to two from each
        buckets = defaultdict(list)
        for peer in recent:
            if peer.is_tor:
                onion_peers.append(peer)
            else:
                buckets[peer.bucket()].append(peer)
        for bucket_peers in buckets.values():
            random.shuffle(bucket_peers)
            peers.update(bucket_peers[:2])

        # Add up to 20% onion peers (but up to 10 is OK anyway)
        random.shuffle(onion_peers)
        max_onion = 50 if is_tor else max(10, len(peers) // 4)

        peers.update(onion_peers[:max_onion])

        return [peer.to_tuple() for peer in peers]

    def proxy_peername(self):
        """Return the peername of the proxy, if there is a proxy, otherwise
        None."""
        return self.proxy.peername if self.proxy else None

    def rpc_data(self):
        """Peer data for the peers RPC method."""
        self._set_peer_statuses()
        descs = ['good', 'stale', 'never', 'bad']

        def peer_data(peer):
            data = peer.serialize()
            data['status'] = descs[peer.status]
            return data

        def peer_key(peer):
            return (peer.bad, -peer.last_good)

        return [peer_data(peer) for peer in sorted(self.peers, key=peer_key)]