From 1d0b87b509035fba1834d5223299ef8ca7c5a3d9 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 26 Mar 2026 15:03:23 +0400 Subject: [PATCH] v9: multi-destination tunnel support (parspack, mequ, alipi) Server: - /tunnel/ routes: parspack (185.208.174.152:22), mequ (188.213.68.133:2022), alipi (10.66.66.2:22) - /tunnel without dest defaults to parspack Client (tunnel.py): - --destination / -d flag to pick target - Lists available destinations in --help Co-Authored-By: Claude Opus 4.6 (1M context) --- chat.py | 34 ++++++++++++++++++++------ tunnel.py | 73 +++++++++++++++++++++++++++++++++++++------------------ 2 files changed, 75 insertions(+), 32 deletions(-) diff --git a/chat.py b/chat.py index debca90..73559b1 100644 --- a/chat.py +++ b/chat.py @@ -25,8 +25,12 @@ import html import urllib.parse PORT = 9999 -VERSION = "8" -TUNNEL_TARGET = ("185.208.174.152", 22) +VERSION = "9" +TUNNEL_TARGETS = { + "parspack": ("185.208.174.152", 22), + "mequ": ("188.213.68.133", 2022), + "alipi": ("10.66.66.2", 22), +} MAX_FILE_SIZE = 1 * 1024 * 1024 # 1 MB per file MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total @@ -380,10 +384,10 @@ def ws_make_frame(opcode, data): return frame + data -async def handle_ws_tunnel(ws_reader, ws_writer): - """Bridge WebSocket frames <-> raw TCP to TUNNEL_TARGET.""" +async def handle_ws_tunnel(ws_reader, ws_writer, target): + """Bridge WebSocket frames <-> raw TCP to target (host, port).""" try: - ssh_reader, ssh_writer = await asyncio.open_connection(*TUNNEL_TARGET) + ssh_reader, ssh_writer = await asyncio.open_connection(*target) except Exception as e: ws_writer.write(ws_make_frame(0x8, struct.pack("!H", 1011) + str(e).encode()[:123])) await ws_writer.drain() @@ -569,8 +573,22 @@ async def handle_http(reader, writer, first_line): writer.close() return - # GET /tunnel — WebSocket upgrade for SSH tunnel - if method == "GET" and path == "/tunnel": + # GET /tunnel/ — WebSocket upgrade for SSH tunnel + if method == "GET" and path.startswith("/tunnel"): + # Parse destination: /tunnel/parspack, /tunnel/mequ, or /tunnel (default: parspack) + parts = path.strip("/").split("/") + dest_name = parts[1] if len(parts) > 1 else "parspack" + target = TUNNEL_TARGETS.get(dest_name) + if not target: + names = ", ".join(TUNNEL_TARGETS.keys()) + err = f"Unknown destination '{dest_name}'. Available: {names}".encode() + writer.write(b"HTTP/1.1 404 Not Found\r\n") + writer.write(f"Content-Length: {len(err)}\r\n".encode()) + writer.write(b"\r\n") + writer.write(err) + await writer.drain() + writer.close() + return ws_key = headers.get("sec-websocket-key", "") if not ws_key: writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") @@ -586,7 +604,7 @@ async def handle_http(reader, writer, first_line): f"Sec-WebSocket-Accept: {accept}\r\n" f"\r\n".encode()) await writer.drain() - await handle_ws_tunnel(reader, writer) + await handle_ws_tunnel(reader, writer, target) return # 404 diff --git a/tunnel.py b/tunnel.py index 01afb98..2dc37ab 100644 --- a/tunnel.py +++ b/tunnel.py @@ -3,11 +3,17 @@ SSH-over-WebSocket tunnel client. No dependencies beyond stdlib. Usage: - python3 tunnel.py [-p PORT] [--dns DNS_SERVER] + python3 tunnel.py -d [-p PORT] [--dns DNS_SERVER] + +Destinations: + parspack 185.208.174.152:22 (default) + mequ 188.213.68.133:2022 + alipi 10.66.66.2:22 Examples: - python3 tunnel.py nginx-0651fe8398-manpache.apps.ir-central1.arvancaas.ir -p 1212 - python3 tunnel.py nginx-0651fe8398-manpache.apps.ir-central1.arvancaas.ir -p 1212 --dns 8.8.8.8 + python3 tunnel.py nginx-0651fe8398-manpache.apps.ir-central1.arvancaas.ir -d parspack -p 1212 + python3 tunnel.py nginx-0651fe8398-manpache.apps.ir-central1.arvancaas.ir -d mequ -p 1213 + python3 tunnel.py nginx-0651fe8398-manpache.apps.ir-central1.arvancaas.ir -d alipi -p 1214 --dns 8.8.8.8 ssh -p 1212 user@localhost """ @@ -21,6 +27,13 @@ import struct import sys +DESTINATIONS = { + "parspack": "185.208.174.152:22", + "mequ": "188.213.68.133:2022", + "alipi": "10.66.66.2:22", +} + + def ws_make_frame(opcode, data, masked=True): """Build a WebSocket frame (client frames must be masked).""" frame = bytes([0x80 | opcode]) @@ -54,10 +67,10 @@ async def ws_read_frame(reader): return opcode, data -async def ws_handshake(reader, writer, host): +async def ws_handshake(reader, writer, host, dest): """Perform WebSocket handshake, return True on success.""" key = base64.b64encode(os.urandom(16)).decode() - req = (f"GET /tunnel HTTP/1.1\r\n" + req = (f"GET /tunnel/{dest} HTTP/1.1\r\n" f"Host: {host}\r\n" f"Upgrade: websocket\r\n" f"Connection: Upgrade\r\n" @@ -69,7 +82,16 @@ async def ws_handshake(reader, writer, host): status_line = (await reader.readline()).decode().strip() if "101" not in status_line: + # Read remaining response + body = b"" + while True: + line = await reader.readline() + if not line or line == b"\r\n": + break + rest = await asyncio.wait_for(reader.read(1024), timeout=1.0) if "404" in status_line else b"" print(f"Handshake failed: {status_line}") + if rest: + print(f" {rest.decode(errors='replace')}") return False # consume headers @@ -83,16 +105,15 @@ async def ws_handshake(reader, writer, host): def resolve_with_dns(hostname, dns_server): """Resolve hostname using a specific DNS server via raw UDP query.""" - # Build a minimal DNS A-record query import random txn_id = random.randint(0, 65535) - flags = 0x0100 # standard query, recursion desired + flags = 0x0100 header = struct.pack("!HHHHHH", txn_id, flags, 1, 0, 0, 0) query = b"" for label in hostname.split("."): query += bytes([len(label)]) + label.encode() query += b"\x00" - query += struct.pack("!HH", 1, 1) # A record, IN class + query += struct.pack("!HH", 1, 1) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(5) @@ -102,19 +123,15 @@ def resolve_with_dns(hostname, dns_server): finally: sock.close() - # Parse answer — skip header (12 bytes) + question section pos = 12 - # skip question while resp[pos] != 0: pos += resp[pos] + 1 - pos += 5 # null byte + qtype(2) + qclass(2) + pos += 5 - # read first answer ans_count = struct.unpack("!H", resp[6:8])[0] if ans_count == 0: raise RuntimeError(f"DNS {dns_server} returned no answers for {hostname}") - # skip name (could be pointer) if resp[pos] & 0xC0 == 0xC0: pos += 2 else: @@ -124,12 +141,12 @@ def resolve_with_dns(hostname, dns_server): rtype, rclass, ttl, rdlen = struct.unpack("!HHIH", resp[pos:pos+10]) pos += 10 - if rtype == 1 and rdlen == 4: # A record + if rtype == 1 and rdlen == 4: return socket.inet_ntoa(resp[pos:pos+4]) raise RuntimeError(f"DNS {dns_server}: unexpected record type {rtype}") -async def handle_local_client(local_reader, local_writer, host, use_tls, resolved_ip=None): +async def handle_local_client(local_reader, local_writer, host, dest, use_tls, resolved_ip=None): """Handle one SSH connection by tunneling it over WebSocket.""" ctx = None if use_tls: @@ -148,12 +165,12 @@ async def handle_local_client(local_reader, local_writer, host, use_tls, resolve local_writer.close() return - if not await ws_handshake(ws_reader, ws_writer, host): + if not await ws_handshake(ws_reader, ws_writer, host, dest): local_writer.close() ws_writer.close() return - print(f" tunnel established → {host}") + print(f" tunnel established → {host} → {dest} ({DESTINATIONS[dest]})") async def local_to_ws(): try: @@ -177,9 +194,9 @@ async def handle_local_client(local_reader, local_writer, host, use_tls, resolve try: while True: op, data = await ws_read_frame(ws_reader) - if op == 0x8: # close + if op == 0x8: break - if op == 0x9: # ping → pong + if op == 0x9: ws_writer.write(ws_make_frame(0xA, data)) await ws_writer.drain() continue @@ -197,18 +214,26 @@ async def handle_local_client(local_reader, local_writer, host, use_tls, resolve async def main(): import argparse - parser = argparse.ArgumentParser(description="SSH over WebSocket tunnel") + parser = argparse.ArgumentParser( + description="SSH over WebSocket tunnel", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Destinations:\n" + "\n".join(f" {k:10s} {v}" for k, v in DESTINATIONS.items()) + ) parser.add_argument("host", help="WebSocket server hostname") + parser.add_argument("-d", "--destination", default="parspack", + choices=DESTINATIONS.keys(), + help="SSH destination (default: parspack)") parser.add_argument("-p", "--port", type=int, default=1212, help="Local port to listen on (default: 1212)") parser.add_argument("--dns", default=None, - help="DNS server to resolve hostname (e.g. 8.8.8.8, 1.1.1.1)") + help="DNS server to resolve hostname (e.g. 8.8.8.8)") parser.add_argument("--no-tls", action="store_true", help="Use ws:// instead of wss://") args = parser.parse_args() use_tls = not args.no_tls resolved_ip = None + dest = args.destination if args.dns: try: @@ -221,12 +246,12 @@ async def main(): async def on_connect(reader, writer): addr = writer.get_extra_info("peername") print(f" new connection from {addr}") - await handle_local_client(reader, writer, args.host, use_tls, resolved_ip) + await handle_local_client(reader, writer, args.host, dest, use_tls, resolved_ip) server = await asyncio.start_server(on_connect, "127.0.0.1", args.port) - print(f"Tunnel listening on localhost:{args.port}") proto = "wss" if use_tls else "ws" - print(f" → {proto}://{args.host}/tunnel → 185.208.174.152:22") + print(f"Tunnel listening on localhost:{args.port}") + print(f" → {proto}://{args.host}/tunnel/{dest} → {DESTINATIONS[dest]}") print(f" Run: ssh -p {args.port} user@localhost") async with server: