Source code for btclib_wallet.fetch.transport

# Copyright (c) The btclib developers
# Distributed under the MIT software license, see the accompanying
# LICENSE file or https://opensource.org/license/mit for the full text.

"""The transports a fetcher does its I/O with, and the network policy of each.

Two seams, one per wire shape. `HttpTransport` is re-exported under
btclib's name with its implementations; `LineTransport` is declared here
and `TlsLineTransport` implements it here.

The HTTP half's implementation is `bitcoin_core_rpc`'s: a bounded read, no
redirect followed and no proxy taken from the environment, in a package
that depends on nothing beyond the standard library. btclib depends on it
for the rpc client and reaches the same transport through it, rather than
keeping a second copy of that bounded-read and redirect policy in step
with the first.

Aliases and not wrappers: `EsploraFetcher` passes `transport=` straight
through to `http_request`, and a caller substituting one for a test needs
the object those two agree on. `HttpTransport` is that seam, and this is
btclib's name for it.

The HTTP implementations differ in how they hold the connection.
`urlopen_transport` is the default: one connection per call, opened and
handed to the node to close. `SessionTransport` keeps one connection per
`(scheme, host, port)` open
across calls instead, which is worth choosing over many calls against one
node -- a walker fetching many transactions, a client polling one --
where the reused connection, and on `https` the reused TLS handshake, is
what the default pays for on every call. It has a `close()` and works as
a context manager; nothing here calls either on a caller's behalf.

What does *not* come through unchanged is the exceptions. `http_request`
raises the package's `FetchError` and `HttpError`, which are not the
classes `btclib.exceptions` declares;
`btclib_wallet.fetch.fetcher.client_errors` is what translates them, and every
call into this module from inside btclib is wrapped in it.

**`LineTransport` is not an alias of anything.** `bitcoin_core_rpc`'s own
transport is HTTP, and the Electrum protocol `btclib.electrum` speaks is
newline-delimited JSON-RPC over a raw TCP or TLS socket, which that
package does not carry. `ElectrumFetcher` (`btclib_wallet.fetch.electrum`) takes
one request line, already terminated by `btclib.electrum.encode_request`'s
own newline, and a timeout in seconds, and returns the answering line
with its delimiter consumed rather than included -- bytes on both sides,
the shape `btclib.electrum`'s own framing already produces and reads, so
the boundary needs no encoding step of its own, the same reason
`HttpTransport` is bytes in and bytes out. A transport that cannot
answer raises `bitcoin_core_rpc`'s own `FetchError` or a subclass of it,
the vocabulary `HttpTransport` already raises through this same package;
`btclib_wallet.fetch.fetcher.client_errors` is what translates it, at every
`ElectrumFetcher` call the way it is at every other backend's.

**`LineTransport` carries no host, so its implementation is an object
that does.** `TlsLineTransport(host, port)` is the callable
`ElectrumFetcher` takes, and that fetcher has no default for it: a
default transport would have to be a default server, which
`ElectrumFetcher`'s own docstring refuses.
"""

from __future__ import annotations

import ssl
from collections.abc import Callable
from math import isfinite
from socket import create_connection
from time import monotonic

from bitcoin_core_rpc import (
    DEFAULT_MAX_BODY_SIZE,
    DEFAULT_TIMEOUT,
    MAX_ERROR_BODY_SIZE,
    FetchError,
    HttpTransport,
    SessionTransport,
    http_request,
    urlopen_transport,
)
from btclib.exceptions import BTClibTypeError, BTClibValueError
from btclib.utils import is_integer

LineTransport = Callable[[bytes, float], bytes]

__all__ = [
    "DEFAULT_MAX_BODY_SIZE",
    "DEFAULT_TIMEOUT",
    "MAX_ERROR_BODY_SIZE",
    "HttpTransport",
    "LineTransport",
    "SessionTransport",
    "TlsLineTransport",
    "http_request",
    "urlopen_transport",
]

# how much one read asks for: a recv allocates what it is asked for, so a
# limit widened for one large answer is not paid on every small one
_READ_CHUNK = 64 * 1024

_MAX_PORT = 65535


def _time_left(deadline: float, where: str) -> float:
    """Return the seconds left before `deadline`, refusing none or fewer.

    What each socket operation is given as its own timeout, so that the
    operations together cannot outlast the one deadline the call took.
    """
    remaining = deadline - monotonic()
    if remaining <= 0:
        raise FetchError(f"{where}: timeout expired before an answer arrived")
    return remaining


def _read_line(
    connection: ssl.SSLSocket, max_line_size: int, where: str, deadline: float
) -> bytes:
    """Return the first line the connection delivers, without its newline.

    Never holds more than one octet past `max_line_size`: each read asks
    for at most what would take the line there, which is what tells a line
    at the limit from one over it. What
    follows the newline -- a notification the server pushed after the
    answer -- is read or not and discarded either way, the connection
    closing behind this call.
    """
    buffer = bytearray()
    while True:
        connection.settimeout(_time_left(deadline, where))
        chunk = connection.recv(min(_READ_CHUNK, max_line_size + 1 - len(buffer)))
        if not chunk:
            raise FetchError(f"{where}: connection closed before a whole line")
        end = chunk.find(b"\n")
        if end != -1:
            buffer.extend(chunk[:end])
            return bytes(buffer)
        buffer.extend(chunk)
        if len(buffer) > max_line_size:
            err_msg = f"{where}: line longer than the max_line_size of {max_line_size}"
            raise FetchError(err_msg)


[docs] class TlsLineTransport: """A `LineTransport` over TLS to one server, verifying its certificate. Constructed with the host and port it connects to, so the instance is the server: `ElectrumFetcher(transport=TlsLineTransport(host, port))` is the whole of naming one. Construction opens nothing. **One connection per call.** Each call connects, performs the TLS handshake, sends the one request line, reads the one line answering it, and closes. A connection kept across calls is what `blockchain.headers.subscribe` rules out: the server then pushes a notification line down that same connection at every new block, and a later call reading the next line would read the notification in place of its own answer. What a kept connection would save -- the connect and the handshake -- is paid on every call instead. Nothing is kept between calls, so concurrent calls share the `ssl.SSLContext` and nothing else. **The network policy**, the one `urlopen_transport` keeps for HTTP: - a bounded read: the line is refused, not truncated, once it passes `max_line_size` octets without a newline. The default is `DEFAULT_MAX_BODY_SIZE`, sized for a whole block as hex, so `blockchain.transaction.get` fits for any transaction a block holds; - a timeout over the whole exchange -- connect, handshake, send and read -- rather than per socket operation: each operation is given what is left of one deadline, so a server dripping a line one octet at a time cannot hold the call open past it; - no proxy from the environment: `socket.create_connection` reads no proxy variable, so the connection goes to the host named here; - TLS verified by default: `context` defaults to `ssl.create_default_context()`, which requires a certificate chaining to the default CA certificates it loads and matching `host`. A server whose certificate those do not trust -- a server of one's own with a self-signed certificate, say -- is reached by passing a `context` that trusts that certificate, `ssl.create_default_context(cafile=...)`, which keeps both checks on. No flag here turns verification off; what a context a caller supplies accepts is that caller's decision, as a transport of their own would be. Plain TCP is not offered: a caller who wants it writes that `LineTransport`. Everything that goes wrong below the answer -- an unresolvable host, a refused connection, a handshake the certificate fails, a timeout, a connection closed before a whole line, a line over the limit -- is `bitcoin_core_rpc`'s `FetchError`, the contract `LineTransport` states, which `ElectrumFetcher` translates into btclib's own. """ def __init__( self, host: str, port: int, *, context: ssl.SSLContext | None = None, max_line_size: int = DEFAULT_MAX_BODY_SIZE, ) -> None: if not isinstance(host, str): raise BTClibTypeError(f"non-string host: {host!r}") if not host: raise BTClibValueError("empty host") try: # what `create_connection` and the handshake both encode the # host with, and a `UnicodeError` rather than an `OSError` there host.encode("idna") except UnicodeError as e: raise BTClibValueError(f"invalid host: {host!r}") from e if not is_integer(port): raise BTClibTypeError(f"non-integer port: {port!r}") if not 0 < port <= _MAX_PORT: raise BTClibValueError(f"invalid port: {port}") if context is not None and not isinstance(context, ssl.SSLContext): raise BTClibTypeError(f"not an ssl.SSLContext: {context!r}") if not is_integer(max_line_size): raise BTClibTypeError(f"non-integer max_line_size: {max_line_size!r}") if max_line_size < 1: raise BTClibValueError(f"invalid max_line_size: {max_line_size}") self._host = host self._port = port self._context = ssl.create_default_context() if context is None else context self._max_line_size = max_line_size @property def host(self) -> str: """Return the host this transport connects to.""" return self._host @property def port(self) -> int: """Return the port this transport connects to.""" return self._port @property def context(self) -> ssl.SSLContext: """Return the context the TLS handshake is verified against.""" return self._context @property def max_line_size(self) -> int: """Return the octets an answering line may hold before it is refused.""" return self._max_line_size def __call__(self, request: bytes, timeout: float) -> bytes: """Send one request line and return the line answering it. `request` is one line, its newline the last octet and the only one: a second would be a second request, whose answer this call would never read. """ if not isinstance(request, bytes): raise BTClibTypeError(f"non-bytes request: {request!r}") if request.count(b"\n") != 1 or not request.endswith(b"\n"): raise BTClibValueError(f"not one newline-terminated line: {request!r}") if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): raise BTClibTypeError(f"non-numeric timeout: {timeout!r}") if not isfinite(timeout) or timeout <= 0: raise BTClibValueError(f"timeout is not a positive number: {timeout}") where = f"{self._host}:{self._port}" deadline = monotonic() + timeout try: with create_connection((self._host, self._port), timeout=timeout) as sock: sock.settimeout(_time_left(deadline, where)) with self._context.wrap_socket( sock, server_hostname=self._host ) as connection: connection.settimeout(_time_left(deadline, where)) connection.sendall(request) return _read_line(connection, self._max_line_size, where, deadline) except OSError as e: # `ssl.SSLError`, `socket.gaierror` and `TimeoutError` all derive # from it, which makes it every way the exchange did not happen raise FetchError(f"no answer from {where}: {e}") from e