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

34
chat.py
View File

@@ -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/<dest> — 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

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: