v9: multi-destination tunnel support (parspack, mequ, alipi)

Server:
- /tunnel/<dest> 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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 15:03:23 +04:00
parent 6aa2717560
commit 1d0b87b509
2 changed files with 75 additions and 32 deletions

View File

@@ -3,11 +3,17 @@
SSH-over-WebSocket tunnel client. No dependencies beyond stdlib.
Usage:
python3 tunnel.py <host> [-p PORT] [--dns DNS_SERVER]
python3 tunnel.py <host> -d <destination> [-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: