Merge bitcoin/bitcoin#29420: test: extend the SOCKS5 Python proxy to actually connect to a destination
Some checks are pending
CI / test each commit (push) Waiting to run
CI / macOS 14 native, arm64, no depends, sqlite only, gui (push) Waiting to run
CI / Win64 native, VS 2022 (push) Waiting to run
CI / ASan + LSan + UBSan + integer, no depends, USDT (push) Waiting to run

57529ac4db test: set P2PConnection.p2p_connected_to_node in peer_connect_helper() (Vasil Dimov)
22cd0e888c test: support WTX INVs from P2PDataStore and fix a comment (Vasil Dimov)
ebe42c00aa test: extend the SOCKS5 Python proxy to actually connect to a destination (Vasil Dimov)
ba621ffb9c test: improve debug log message from P2PConnection::connection_made() (Vasil Dimov)

Pull request description:

  If requested, make the SOCKS5 Python proxy redirect connections to a set of given destinations. Actually act as a real proxy, connecting the client to a destination, except that the destination is not what the client asked for.

  This would enable us to "connect" to Tor addresses from the functional tests.

  Plus a few other minor improvements in the test framework as individual commits.

  ---

  These changes are part of https://github.com/bitcoin/bitcoin/pull/29415 but they make sense on their own and would be good to have them, regardless of the fate of #29415. Also, if this is merged, that would reduce the size of #29415, thus the current standalone PR.

ACKs for top commit:
  jonatack:
    Approach ACK 57529ac4db
  achow101:
    ACK 57529ac4db
  tdb3:
    CR and test ACK 57529ac4db
  mzumsande:
    Code review / tested ACK 57529ac4db

Tree-SHA512: a2892c97bff2d337b37455c409c6136cb62423ce6cc32b197b36f220c1eec9ca046b599135b9a2603c0eb6c1ac4d9795e73831ef0f04378aeea8b245ea733399
This commit is contained in:
Ava Chow 2024-10-29 15:32:18 -04:00
commit 97b790e844
No known key found for this signature in database
GPG key ID: 17565732E08E5E41
4 changed files with 88 additions and 6 deletions

View file

@ -167,3 +167,10 @@ def test_unix_socket():
return False return False
else: else:
return True return True
def format_addr_port(addr, port):
'''Return either "addr:port" or "[addr]:port" based on whether addr looks like an IPv6 address.'''
if ":" in addr:
return f"[{addr}]:{port}"
else:
return f"{addr}:{port}"

View file

@ -188,6 +188,7 @@ class P2PConnection(asyncio.Protocol):
self.on_connection_send_msg = None self.on_connection_send_msg = None
self.recvbuf = b"" self.recvbuf = b""
self.magic_bytes = MAGIC_BYTES[net] self.magic_bytes = MAGIC_BYTES[net]
self.p2p_connected_to_node = dstport != 0
def peer_connect(self, dstaddr, dstport, *, net, timeout_factor, supports_v2_p2p): def peer_connect(self, dstaddr, dstport, *, net, timeout_factor, supports_v2_p2p):
self.peer_connect_helper(dstaddr, dstport, net, timeout_factor) self.peer_connect_helper(dstaddr, dstport, net, timeout_factor)
@ -217,7 +218,12 @@ class P2PConnection(asyncio.Protocol):
def connection_made(self, transport): def connection_made(self, transport):
"""asyncio callback when a connection is opened.""" """asyncio callback when a connection is opened."""
assert not self._transport assert not self._transport
logger.debug("Connected & Listening: %s:%d" % (self.dstaddr, self.dstport)) info = transport.get_extra_info("socket")
us = info.getsockname()
them = info.getpeername()
logger.debug(f"Connected: us={us[0]}:{us[1]}, them={them[0]}:{them[1]}")
self.dstaddr = them[0]
self.dstport = them[1]
self._transport = transport self._transport = transport
# in an inbound connection to the TestNode with P2PConnection as the initiator, [TestNode <---- P2PConnection] # in an inbound connection to the TestNode with P2PConnection as the initiator, [TestNode <---- P2PConnection]
# send the initial handshake immediately # send the initial handshake immediately
@ -803,12 +809,13 @@ class P2PDataStore(P2PInterface):
self.getdata_requests = [] self.getdata_requests = []
def on_getdata(self, message): def on_getdata(self, message):
"""Check for the tx/block in our stores and if found, reply with an inv message.""" """Check for the tx/block in our stores and if found, reply with MSG_TX or MSG_BLOCK."""
for inv in message.inv: for inv in message.inv:
self.getdata_requests.append(inv.hash) self.getdata_requests.append(inv.hash)
if (inv.type & MSG_TYPE_MASK) == MSG_TX and inv.hash in self.tx_store.keys(): invtype = inv.type & MSG_TYPE_MASK
if (invtype == MSG_TX or invtype == MSG_WTX) and inv.hash in self.tx_store.keys():
self.send_message(msg_tx(self.tx_store[inv.hash])) self.send_message(msg_tx(self.tx_store[inv.hash]))
elif (inv.type & MSG_TYPE_MASK) == MSG_BLOCK and inv.hash in self.block_store.keys(): elif invtype == MSG_BLOCK and inv.hash in self.block_store.keys():
self.send_message(msg_block(self.block_store[inv.hash])) self.send_message(msg_block(self.block_store[inv.hash]))
else: else:
logger.debug('getdata message type {} received.'.format(hex(inv.type))) logger.debug('getdata message type {} received.'.format(hex(inv.type)))

View file

@ -4,11 +4,16 @@
# file COPYING or http://www.opensource.org/licenses/mit-license.php. # file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Dummy Socks5 server for testing.""" """Dummy Socks5 server for testing."""
import select
import socket import socket
import threading import threading
import queue import queue
import logging import logging
from .netutil import (
format_addr_port
)
logger = logging.getLogger("TestFramework.socks5") logger = logging.getLogger("TestFramework.socks5")
# Protocol constants # Protocol constants
@ -32,6 +37,42 @@ def recvall(s, n):
n -= len(d) n -= len(d)
return rv return rv
def sendall(s, data):
"""Send all data to a socket, or fail."""
sent = 0
while sent < len(data):
_, wlist, _ = select.select([], [s], [])
if len(wlist) > 0:
n = s.send(data[sent:])
if n == 0:
raise IOError('send() on socket returned 0')
sent += n
def forward_sockets(a, b):
"""Forward data received on socket a to socket b and vice versa, until EOF is received on one of the sockets."""
# Mark as non-blocking so that we do not end up in a deadlock-like situation
# where we block and wait on data from `a` while there is data ready to be
# received on `b` and forwarded to `a`. And at the same time the application
# at `a` is not sending anything because it waits for the data from `b` to
# respond.
a.setblocking(False)
b.setblocking(False)
sockets = [a, b]
done = False
while not done:
rlist, _, xlist = select.select(sockets, [], sockets)
if len(xlist) > 0:
raise IOError('Exceptional condition on socket')
for s in rlist:
data = s.recv(4096)
if data is None or len(data) == 0:
done = True
break
if s == a:
sendall(b, data)
else:
sendall(a, data)
# Implementation classes # Implementation classes
class Socks5Configuration(): class Socks5Configuration():
"""Proxy configuration.""" """Proxy configuration."""
@ -41,6 +82,19 @@ class Socks5Configuration():
self.unauth = False # Support unauthenticated self.unauth = False # Support unauthenticated
self.auth = False # Support authentication self.auth = False # Support authentication
self.keep_alive = False # Do not automatically close connections self.keep_alive = False # Do not automatically close connections
# This function is called whenever a new connection arrives to the proxy
# and it decides where the connection is redirected to. It is passed:
# - the address the client requested to connect to
# - the port the client requested to connect to
# It is supposed to return an object like:
# {
# "actual_to_addr": "127.0.0.1"
# "actual_to_port": 28276
# }
# or None.
# If it returns an object then the connection is redirected to actual_to_addr:actual_to_port.
# If it returns None, or destinations_factory itself is None then the connection is closed.
self.destinations_factory = None
class Socks5Command(): class Socks5Command():
"""Information about an incoming socks5 command.""" """Information about an incoming socks5 command."""
@ -117,6 +171,22 @@ class Socks5Connection():
cmdin = Socks5Command(cmd, atyp, addr, port, username, password) cmdin = Socks5Command(cmd, atyp, addr, port, username, password)
self.serv.queue.put(cmdin) self.serv.queue.put(cmdin)
logger.debug('Proxy: %s', cmdin) logger.debug('Proxy: %s', cmdin)
requested_to_addr = addr.decode("utf-8")
requested_to = format_addr_port(requested_to_addr, port)
if self.serv.conf.destinations_factory is not None:
dest = self.serv.conf.destinations_factory(requested_to_addr, port)
if dest is not None:
logger.debug(f"Serving connection to {requested_to}, will redirect it to "
f"{dest['actual_to_addr']}:{dest['actual_to_port']} instead")
with socket.create_connection((dest["actual_to_addr"], dest["actual_to_port"])) as conn_to:
forward_sockets(self.conn, conn_to)
else:
logger.debug(f"Closing connection to {requested_to}: the destinations factory returned None")
else:
logger.debug(f"Closing connection to {requested_to}: no destinations factory")
# Fall through to disconnect # Fall through to disconnect
except Exception as e: except Exception as e:
logger.exception("socks5 request handling failed.") logger.exception("socks5 request handling failed.")

View file

@ -715,7 +715,6 @@ class TestNode():
if supports_v2_p2p is None: if supports_v2_p2p is None:
supports_v2_p2p = self.use_v2transport supports_v2_p2p = self.use_v2transport
p2p_conn.p2p_connected_to_node = True
if self.use_v2transport: if self.use_v2transport:
kwargs['services'] = kwargs.get('services', P2P_SERVICES) | NODE_P2P_V2 kwargs['services'] = kwargs.get('services', P2P_SERVICES) | NODE_P2P_V2
supports_v2_p2p = self.use_v2transport and supports_v2_p2p supports_v2_p2p = self.use_v2transport and supports_v2_p2p
@ -782,7 +781,6 @@ class TestNode():
self.log.debug("Connecting to %s:%d %s" % (address, port, connection_type)) self.log.debug("Connecting to %s:%d %s" % (address, port, connection_type))
self.addconnection('%s:%d' % (address, port), connection_type, advertise_v2_p2p) self.addconnection('%s:%d' % (address, port), connection_type, advertise_v2_p2p)
p2p_conn.p2p_connected_to_node = False
if supports_v2_p2p is None: if supports_v2_p2p is None:
supports_v2_p2p = self.use_v2transport supports_v2_p2p = self.use_v2transport
if advertise_v2_p2p is None: if advertise_v2_p2p is None: