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:
34
chat.py
34
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/<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
|
||||
|
||||
73
tunnel.py
73
tunnel.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user