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
|
import urllib.parse
|
||||||
|
|
||||||
PORT = 9999
|
PORT = 9999
|
||||||
VERSION = "8"
|
VERSION = "9"
|
||||||
TUNNEL_TARGET = ("185.208.174.152", 22)
|
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_FILE_SIZE = 1 * 1024 * 1024 # 1 MB per file
|
||||||
MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
|
MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
|
||||||
|
|
||||||
@@ -380,10 +384,10 @@ def ws_make_frame(opcode, data):
|
|||||||
return frame + data
|
return frame + data
|
||||||
|
|
||||||
|
|
||||||
async def handle_ws_tunnel(ws_reader, ws_writer):
|
async def handle_ws_tunnel(ws_reader, ws_writer, target):
|
||||||
"""Bridge WebSocket frames <-> raw TCP to TUNNEL_TARGET."""
|
"""Bridge WebSocket frames <-> raw TCP to target (host, port)."""
|
||||||
try:
|
try:
|
||||||
ssh_reader, ssh_writer = await asyncio.open_connection(*TUNNEL_TARGET)
|
ssh_reader, ssh_writer = await asyncio.open_connection(*target)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ws_writer.write(ws_make_frame(0x8, struct.pack("!H", 1011) + str(e).encode()[:123]))
|
ws_writer.write(ws_make_frame(0x8, struct.pack("!H", 1011) + str(e).encode()[:123]))
|
||||||
await ws_writer.drain()
|
await ws_writer.drain()
|
||||||
@@ -569,8 +573,22 @@ async def handle_http(reader, writer, first_line):
|
|||||||
writer.close()
|
writer.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
# GET /tunnel — WebSocket upgrade for SSH tunnel
|
# GET /tunnel/<dest> — WebSocket upgrade for SSH tunnel
|
||||||
if method == "GET" and path == "/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", "")
|
ws_key = headers.get("sec-websocket-key", "")
|
||||||
if not ws_key:
|
if not ws_key:
|
||||||
writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
|
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"Sec-WebSocket-Accept: {accept}\r\n"
|
||||||
f"\r\n".encode())
|
f"\r\n".encode())
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
await handle_ws_tunnel(reader, writer)
|
await handle_ws_tunnel(reader, writer, target)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 404
|
# 404
|
||||||
|
|||||||
73
tunnel.py
73
tunnel.py
@@ -3,11 +3,17 @@
|
|||||||
SSH-over-WebSocket tunnel client. No dependencies beyond stdlib.
|
SSH-over-WebSocket tunnel client. No dependencies beyond stdlib.
|
||||||
|
|
||||||
Usage:
|
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:
|
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 -d parspack -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 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
|
ssh -p 1212 user@localhost
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -21,6 +27,13 @@ import struct
|
|||||||
import sys
|
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):
|
def ws_make_frame(opcode, data, masked=True):
|
||||||
"""Build a WebSocket frame (client frames must be masked)."""
|
"""Build a WebSocket frame (client frames must be masked)."""
|
||||||
frame = bytes([0x80 | opcode])
|
frame = bytes([0x80 | opcode])
|
||||||
@@ -54,10 +67,10 @@ async def ws_read_frame(reader):
|
|||||||
return opcode, data
|
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."""
|
"""Perform WebSocket handshake, return True on success."""
|
||||||
key = base64.b64encode(os.urandom(16)).decode()
|
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"Host: {host}\r\n"
|
||||||
f"Upgrade: websocket\r\n"
|
f"Upgrade: websocket\r\n"
|
||||||
f"Connection: Upgrade\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()
|
status_line = (await reader.readline()).decode().strip()
|
||||||
if "101" not in status_line:
|
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}")
|
print(f"Handshake failed: {status_line}")
|
||||||
|
if rest:
|
||||||
|
print(f" {rest.decode(errors='replace')}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# consume headers
|
# consume headers
|
||||||
@@ -83,16 +105,15 @@ async def ws_handshake(reader, writer, host):
|
|||||||
|
|
||||||
def resolve_with_dns(hostname, dns_server):
|
def resolve_with_dns(hostname, dns_server):
|
||||||
"""Resolve hostname using a specific DNS server via raw UDP query."""
|
"""Resolve hostname using a specific DNS server via raw UDP query."""
|
||||||
# Build a minimal DNS A-record query
|
|
||||||
import random
|
import random
|
||||||
txn_id = random.randint(0, 65535)
|
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)
|
header = struct.pack("!HHHHHH", txn_id, flags, 1, 0, 0, 0)
|
||||||
query = b""
|
query = b""
|
||||||
for label in hostname.split("."):
|
for label in hostname.split("."):
|
||||||
query += bytes([len(label)]) + label.encode()
|
query += bytes([len(label)]) + label.encode()
|
||||||
query += b"\x00"
|
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 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.settimeout(5)
|
sock.settimeout(5)
|
||||||
@@ -102,19 +123,15 @@ def resolve_with_dns(hostname, dns_server):
|
|||||||
finally:
|
finally:
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
# Parse answer — skip header (12 bytes) + question section
|
|
||||||
pos = 12
|
pos = 12
|
||||||
# skip question
|
|
||||||
while resp[pos] != 0:
|
while resp[pos] != 0:
|
||||||
pos += resp[pos] + 1
|
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]
|
ans_count = struct.unpack("!H", resp[6:8])[0]
|
||||||
if ans_count == 0:
|
if ans_count == 0:
|
||||||
raise RuntimeError(f"DNS {dns_server} returned no answers for {hostname}")
|
raise RuntimeError(f"DNS {dns_server} returned no answers for {hostname}")
|
||||||
|
|
||||||
# skip name (could be pointer)
|
|
||||||
if resp[pos] & 0xC0 == 0xC0:
|
if resp[pos] & 0xC0 == 0xC0:
|
||||||
pos += 2
|
pos += 2
|
||||||
else:
|
else:
|
||||||
@@ -124,12 +141,12 @@ def resolve_with_dns(hostname, dns_server):
|
|||||||
|
|
||||||
rtype, rclass, ttl, rdlen = struct.unpack("!HHIH", resp[pos:pos+10])
|
rtype, rclass, ttl, rdlen = struct.unpack("!HHIH", resp[pos:pos+10])
|
||||||
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])
|
return socket.inet_ntoa(resp[pos:pos+4])
|
||||||
raise RuntimeError(f"DNS {dns_server}: unexpected record type {rtype}")
|
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."""
|
"""Handle one SSH connection by tunneling it over WebSocket."""
|
||||||
ctx = None
|
ctx = None
|
||||||
if use_tls:
|
if use_tls:
|
||||||
@@ -148,12 +165,12 @@ async def handle_local_client(local_reader, local_writer, host, use_tls, resolve
|
|||||||
local_writer.close()
|
local_writer.close()
|
||||||
return
|
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()
|
local_writer.close()
|
||||||
ws_writer.close()
|
ws_writer.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f" tunnel established → {host}")
|
print(f" tunnel established → {host} → {dest} ({DESTINATIONS[dest]})")
|
||||||
|
|
||||||
async def local_to_ws():
|
async def local_to_ws():
|
||||||
try:
|
try:
|
||||||
@@ -177,9 +194,9 @@ async def handle_local_client(local_reader, local_writer, host, use_tls, resolve
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
op, data = await ws_read_frame(ws_reader)
|
op, data = await ws_read_frame(ws_reader)
|
||||||
if op == 0x8: # close
|
if op == 0x8:
|
||||||
break
|
break
|
||||||
if op == 0x9: # ping → pong
|
if op == 0x9:
|
||||||
ws_writer.write(ws_make_frame(0xA, data))
|
ws_writer.write(ws_make_frame(0xA, data))
|
||||||
await ws_writer.drain()
|
await ws_writer.drain()
|
||||||
continue
|
continue
|
||||||
@@ -197,18 +214,26 @@ async def handle_local_client(local_reader, local_writer, host, use_tls, resolve
|
|||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
import argparse
|
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("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,
|
parser.add_argument("-p", "--port", type=int, default=1212,
|
||||||
help="Local port to listen on (default: 1212)")
|
help="Local port to listen on (default: 1212)")
|
||||||
parser.add_argument("--dns", default=None,
|
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",
|
parser.add_argument("--no-tls", action="store_true",
|
||||||
help="Use ws:// instead of wss://")
|
help="Use ws:// instead of wss://")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
use_tls = not args.no_tls
|
use_tls = not args.no_tls
|
||||||
resolved_ip = None
|
resolved_ip = None
|
||||||
|
dest = args.destination
|
||||||
|
|
||||||
if args.dns:
|
if args.dns:
|
||||||
try:
|
try:
|
||||||
@@ -221,12 +246,12 @@ async def main():
|
|||||||
async def on_connect(reader, writer):
|
async def on_connect(reader, writer):
|
||||||
addr = writer.get_extra_info("peername")
|
addr = writer.get_extra_info("peername")
|
||||||
print(f" new connection from {addr}")
|
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)
|
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"
|
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")
|
print(f" Run: ssh -p {args.port} user@localhost")
|
||||||
|
|
||||||
async with server:
|
async with server:
|
||||||
|
|||||||
Reference in New Issue
Block a user