diff --git a/hole-punch-interop/impl/python/v0.4.0/Dockerfile b/hole-punch-interop/impl/python/v0.4.0/Dockerfile new file mode 100644 index 000000000..30ed861a5 --- /dev/null +++ b/hole-punch-interop/impl/python/v0.4.0/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y git build-essential iptables libgmp-dev && rm -rf /var/lib/apt/lists/* + +# Upgrade pip and build tools +RUN python -m pip install --upgrade pip setuptools wheel + +WORKDIR /app + +# Copy only pyproject.toml first to leverage Docker cache +COPY pyproject.toml . +RUN pip install --no-cache-dir -e . + +# Copy rest of the code +COPY hole_punch.py . + +# Startup script for iptables + python app +COPY start.sh /start.sh +RUN chmod +x /start.sh +ENTRYPOINT ["/start.sh"] + +VOLUME /results diff --git a/hole-punch-interop/impl/python/v0.4.0/Makefile b/hole-punch-interop/impl/python/v0.4.0/Makefile new file mode 100644 index 000000000..49643f032 --- /dev/null +++ b/hole-punch-interop/impl/python/v0.4.0/Makefile @@ -0,0 +1,16 @@ +image_name := python-v0.4.0 + +.PHONY: all build clean + +all: image.json + +image.json: Dockerfile + IMAGE_NAME=${image_name} ../../../dockerBuildWrapper.sh . + docker image inspect ${image_name} -f "{{.Id}}" | \ + xargs -I {} echo "{\"imageID\": \"{}\"}" > $@ + +build: image.json + +clean: + rm -f image.json + docker rmi ${image_name} || true \ No newline at end of file diff --git a/hole-punch-interop/impl/python/v0.4.0/README.md b/hole-punch-interop/impl/python/v0.4.0/README.md new file mode 100644 index 000000000..837f0d5ca --- /dev/null +++ b/hole-punch-interop/impl/python/v0.4.0/README.md @@ -0,0 +1,12 @@ +# Python Hole-Punch Interop Test (py-libp2p v0.4.0) + +Implements DCUtR hole punching for py-libp2p v0.4.0. + +## Local Testing + +```bash +cd hole-punch-interop/impl/python/v0.4.0 +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +make build \ No newline at end of file diff --git a/hole-punch-interop/impl/python/v0.4.0/__init__.py b/hole-punch-interop/impl/python/v0.4.0/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/hole-punch-interop/impl/python/v0.4.0/hole_punch.py b/hole-punch-interop/impl/python/v0.4.0/hole_punch.py new file mode 100644 index 000000000..28a6156b6 --- /dev/null +++ b/hole-punch-interop/impl/python/v0.4.0/hole_punch.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +Hole-Punch Interop Test for py-libp2p v0.3.x +Supports initiator & receiver roles. +Writes to /results/results.csv +""" + +import asyncio +import json +import logging +import os +import time +from typing import Sequence, Literal + +from libp2p import new_host +from libp2p.abc import IHost +from libp2p.crypto.secp256k1 import create_new_key_pair +from libp2p.peer.id import ID +from libp2p.peer.peerinfo import info_from_p2p_addr +from libp2p.transport.upgrader import TransportUpgrader +from multiaddr import Multiaddr +from libp2p.custom_types import ( + TProtocol, +) + +DCUTR_PROTOCOL = TProtocol("/libp2p/dcutr/1.0.0") +PING_PROTOCOL = TProtocol("/test/ping/1.0.0") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("hole-punch") + +async def create_host(listen_addrs: Sequence[Multiaddr] | None = None) -> IHost: + key_pair = create_new_key_pair() + + host: IHost = new_host( + key_pair=key_pair, + muxer_preference="MPLEX", + listen_addrs=listen_addrs or [Multiaddr("/ip4/0.0.0.0/tcp/0")] + ) + return host + +async def main() -> None: + mode = os.environ["MODE"].lower() + relay_addr_str = os.environ["RELAY_MULTIADDR"] + target_id_str = os.environ["TARGET_ID"] + + # Create and start listening + host = await create_host() + addrs = host.get_addrs() + log.info(f"Listening on: {[str(a) for a in addrs]}") + + # Connect to the relay + relay_ma = Multiaddr(relay_addr_str) + relay_info = info_from_p2p_addr(relay_ma) + await host.connect(relay_info) + log.info(f"Connected to relay: {relay_ma}") + + # Connect to the *target* via the relay (control channel) + target_id = ID(target_id_str.encode()) + relayed_target_ma = Multiaddr(f"{relay_addr_str}/p2p-circuit/p2p/{target_id}") + target_info = info_from_p2p_addr(relayed_target_ma) + await host.connect(target_info) + log.info(f"Connected to target via relay: {relayed_target_ma}") + + if mode == "initiator": + await initiator_role(host, target_id) + else: + await receiver_role(host) + +async def initiator_role(host: IHost, target_id: ID) -> None: + stream = await host.new_stream(target_id, [DCUTR_PROTOCOL]) + + # Send CONNECT + my_addrs = [str(a) for a in host.get_addrs()] + await stream.write(json.dumps({"type": "CONNECT", "addrs": my_addrs}).encode()) + log.info(f"Sent CONNECT with {len(my_addrs)} addrs") + + # Receive SYNC + try: + raw = await asyncio.wait_for(stream.read(4096), timeout=15) + msg = json.loads(raw.decode()) + if msg.get("type") != "SYNC": + raise ValueError("expected SYNC") + peer_addrs = msg.get("addrs", []) + log.info(f"Received SYNC with {len(peer_addrs)} addrs") + except Exception as e: + log.error(f"SYNC failed: {e}") + await write_result("failure") + await stream.close() + return + finally: + await stream.close() + + # Direct dial the first address + if peer_addrs: + try: + direct_ma = Multiaddr(peer_addrs[0]) + direct_info = info_from_p2p_addr(direct_ma) + await host.connect(direct_info) + log.info(f"Direct connection SUCCESS → {direct_ma}") + except Exception as e: + log.warning(f"Direct dial failed: {e}") + await write_result("failure") + return + + # Ping test + try: + ping = await host.new_stream(target_id, [PING_PROTOCOL]) + start = time.time() + await ping.write(b"ping") + resp = await asyncio.wait_for(ping.read(4), timeout=5) + rtt = (time.time() - start) * 1000 + if resp == b"pong": + log.info(f"Ping RTT: {rtt:.1f} ms → SUCCESS") + await write_result("success") + else: + log.error("Ping failed – bad response") + await write_result("failure") + await ping.close() + except Exception as e: + log.error(f"Ping failed: {e}") + await write_result("failure") + +async def receiver_role(host: IHost) -> None: + async def dcutr_handler(stream): + try: + data = await stream.read(4096) + msg = json.loads(data.decode()) + if msg.get("type") != "CONNECT": + return + + my_addrs = [str(a) for a in host.get_addrs()] + await stream.write(json.dumps({"type": "SYNC", "addrs": my_addrs}).encode()) + log.info(f"Sent SYNC with {len(my_addrs)} addrs") + + if msg.get("addrs"): + addr = Multiaddr(msg["addrs"][0]) + info = info_from_p2p_addr(addr) + asyncio.create_task(host.connect(info)) + except Exception as e: + log.error(f"DCUtR handler error: {e}") + finally: + await stream.close() + + async def ping_handler(stream): + try: + data = await stream.read(4) + if data == b"ping": + await stream.write(b"pong") + finally: + await stream.close() + + host.set_stream_handler(DCUTR_PROTOCOL, dcutr_handler) + host.set_stream_handler(PING_PROTOCOL, ping_handler) + log.info("Receiver ready – awaiting initiator…") + await asyncio.Event().wait() + +async def write_result(result: str) -> None: + impl = os.environ.get("TARGET_IMPL", "go") + os.makedirs("/results", exist_ok=True) + line = f'"python-v0.3.x x {impl}-v0.42 (dcutr,tcp,noise)",{result}\n' + with open("/results/results.csv", "a") as f: + f.write(line) + log.info(f"RESULT: {result.upper()}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/hole-punch-interop/impl/python/v0.4.0/pyproject.toml b/hole-punch-interop/impl/python/v0.4.0/pyproject.toml new file mode 100644 index 000000000..2138187ce --- /dev/null +++ b/hole-punch-interop/impl/python/v0.4.0/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "hole-punch-python" +version = "0.4.0" +dependencies = [ + "libp2p @ git+https://github.com/libp2p/py-libp2p.git@v0.4.0" +] \ No newline at end of file diff --git a/hole-punch-interop/impl/python/v0.4.0/start.sh b/hole-punch-interop/impl/python/v0.4.0/start.sh new file mode 100644 index 000000000..e7a332156 --- /dev/null +++ b/hole-punch-interop/impl/python/v0.4.0/start.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Setup NAT +iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +# Start Python app +exec python hole_punch.py diff --git a/hole-punch-interop/versions.ts b/hole-punch-interop/versions.ts index 7f4a0f88b..fc8172b0a 100644 --- a/hole-punch-interop/versions.ts +++ b/hole-punch-interop/versions.ts @@ -19,9 +19,9 @@ function canonicalImagePath(id: string): string { // Drop the patch version const [major, minor, patch] = version.split(".") let versionFolder = `v${major}.${minor}` - if (major === "0" && minor === "0") { - // We're still in the 0.0.x phase, so we use the patch version - versionFolder = `v0.0.${patch}` + if (major === "0" && patch !== undefined) { + // We're still in the 0.x.y phase, so we use the full version + versionFolder = `v${major}.${minor}.${patch}` } // Read the image ID from the JSON file on the filesystem return `./impl/${impl}/${versionFolder}/image.json` diff --git a/hole-punch-interop/versionsInput.json b/hole-punch-interop/versionsInput.json index 7299a87f7..0533cf95d 100644 --- a/hole-punch-interop/versionsInput.json +++ b/hole-punch-interop/versionsInput.json @@ -4,5 +4,11 @@ "transports": ["tcp", "quic"], "secureChannels": [], "muxers": [] + }, + { + "id": "python-v0.4.0", + "transports": ["tcp", "quic"], + "secureChannels": [], + "muxers": [] } ]