#!/usr/bin/env python3 """Minimal TCP forwarder used by run_e2e.sh when the Docker daemon is remote. Meshtasticator's interactive simulator always connects to its simulated nodes at localhost: (lib/interactive.py init_communication). On a Gitea Actions runner whose Docker daemon is remote (e.g. DOCKER_HOST=tcp://docker.local:2375) the container-published node ports live on the *daemon* host, not the job's localhost — so we bind 127.0.0.1: in the job and forward to the daemon host, restoring the localhost view the simulator (and our e2e tests) expect. Usage: tcp_forward.py --listen 127.0.0.1:4404 --target docker.local:4404 """ import argparse import socket import threading def _forward(src: socket.socket, dst: socket.socket): try: while True: data = src.recv(65536) if not data: break dst.sendall(data) except OSError: pass finally: try: dst.shutdown(socket.SHUT_WR) except OSError: pass def _handle(client: socket.socket, target_addr): try: upstream = socket.create_connection(target_addr, timeout=10) except OSError: client.close() return t = threading.Thread(target=_forward, args=(client, upstream), daemon=True) t.start() _forward(upstream, client) client.close() upstream.close() def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--listen", required=True, help="local bind, e.g. 127.0.0.1:4404") ap.add_argument("--target", required=True, help="forward target, e.g. docker.local:4404") args = ap.parse_args() lhost, lport = args.listen.rsplit(":", 1) thost, tport = args.target.rsplit(":", 1) target_addr = (thost, int(tport)) listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind((lhost, int(lport))) listener.listen(16) print(f"forwarding {args.listen} -> {args.target}", flush=True) while True: client, _ = listener.accept() threading.Thread(target=_handle, args=(client, target_addr), daemon=True).start() if __name__ == "__main__": main()