v5: stable chat server with web UI, SSH tunnel, and nginx proxy
- chat.py: multi-user chat server (stdlib only, single port) - Web UI at /chat with SSE real-time messaging - Per-user colors (green for self, palette for others) - Curses TUI client with scroll support - WebSocket SSH tunnel at /tunnel -> 185.208.174.152:22 - /version endpoint for deployment verification - /tunnel.py download endpoint - tunnel.py: SSH-over-WebSocket client with custom DNS support - nginx: Kubernetes manifests (Deployment + Service + Ingress) - Reverse proxy to chat.py at 188.213.68.133:9997 - SSE buffering disabled, WebSocket upgrade for /tunnel - nginx.txt: alternate nginx deployment with different ingress host - apache: Bitnami Apache Helm values (initial attempt, replaced by nginx) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
252
apache
Normal file
252
apache
Normal file
@@ -0,0 +1,252 @@
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-role.kubernetes.io/cloud-container-g2
|
||||
operator: In
|
||||
values:
|
||||
- "true"
|
||||
args: []
|
||||
automountServiceAccountToken: false
|
||||
autoscaling:
|
||||
enabled: false
|
||||
maxReplicas: 11
|
||||
minReplicas: 1
|
||||
targetCPU: 50
|
||||
targetMemory: 50
|
||||
cloneHtdocsFromGit:
|
||||
branch: ""
|
||||
enableAutoRefresh: true
|
||||
enabled: false
|
||||
extraVolumeMounts: []
|
||||
interval: 60
|
||||
repository: ""
|
||||
resources: {}
|
||||
resourcesPreset: medium
|
||||
command: []
|
||||
commonAnnotations: {}
|
||||
commonLabels: {}
|
||||
containerPorts:
|
||||
http: 8080
|
||||
https: 8443
|
||||
containerSecurityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
enabled: true
|
||||
privileged: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsGroup: 1001
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
seLinuxOptions: {}
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
customLivenessProbe: {}
|
||||
customReadinessProbe: {}
|
||||
customStartupProbe: {}
|
||||
extraDeploy:
|
||||
- apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: apache-vhosts
|
||||
data:
|
||||
my-vhost.conf: |
|
||||
<VirtualHost *:8080>
|
||||
ProxyPreserveHost On
|
||||
ProxyTimeout 600
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
ProxyPass /chat/events http://188.213.68.133:9997/chat/events connectiontimeout=600 timeout=600 flushpackets=on
|
||||
ProxyPassReverse /chat/events http://188.213.68.133:9997/chat/events
|
||||
|
||||
ProxyPass / http://188.213.68.133:9997/
|
||||
ProxyPassReverse / http://188.213.68.133:9997/
|
||||
</VirtualHost>
|
||||
extraEnvVars: []
|
||||
extraEnvVarsCM: ""
|
||||
extraEnvVarsSecret: ""
|
||||
extraPodSpec: {}
|
||||
extraVolumeMounts: []
|
||||
extraVolumes: []
|
||||
fullnameOverride: ""
|
||||
git:
|
||||
digest: ""
|
||||
pullPolicy: IfNotPresent
|
||||
pullSecrets: []
|
||||
registry: docker.io
|
||||
repository: bitnami/git
|
||||
tag: 2.50.1-debian-12-r30
|
||||
global:
|
||||
compatibility:
|
||||
openshift:
|
||||
adaptSecurityContext: auto
|
||||
defaultStorageClass: ""
|
||||
imagePullSecrets: []
|
||||
imageRegistry: ""
|
||||
security:
|
||||
allowInsecureImages: false
|
||||
storageClass: ""
|
||||
hostAliases:
|
||||
- hostnames:
|
||||
- status.localhost
|
||||
ip: 127.0.0.1
|
||||
htdocsConfigMap: ""
|
||||
htdocsPVC: ""
|
||||
httpdConfConfigMap: ""
|
||||
image:
|
||||
debug: false
|
||||
digest: ""
|
||||
pullPolicy: IfNotPresent
|
||||
pullSecrets: []
|
||||
registry: docker.io
|
||||
repository: bitnami/apache
|
||||
tag: 2.4.65-debian-12-r2
|
||||
ingress:
|
||||
annotations: {}
|
||||
apiVersion: ""
|
||||
enabled: true
|
||||
extraHosts: []
|
||||
extraPaths: []
|
||||
extraRules: []
|
||||
extraTls: []
|
||||
hostname: man-pache-0651fe8398-manpache.apps.ir-central1.arvancaas.ir
|
||||
ingressClassName: ""
|
||||
path: /
|
||||
pathType: ImplementationSpecific
|
||||
secrets: []
|
||||
selfSigned: false
|
||||
tls: false
|
||||
initContainers: []
|
||||
kubeVersion: ""
|
||||
lifecycleHooks: {}
|
||||
livenessProbe:
|
||||
enabled: false
|
||||
failureThreshold: 6
|
||||
initialDelaySeconds: 180
|
||||
periodSeconds: 20
|
||||
port: http
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 5
|
||||
metrics:
|
||||
containerPort: 9141
|
||||
enabled: false
|
||||
image:
|
||||
debug: false
|
||||
digest: ""
|
||||
pullPolicy: IfNotPresent
|
||||
pullSecrets: []
|
||||
registry: docker.io
|
||||
repository: bitnami/apache-exporter
|
||||
tag: 1.0.10-debian-12-r54
|
||||
podAnnotations:
|
||||
prometheus.io/port: "9117"
|
||||
prometheus.io/scrape: "true"
|
||||
prometheusRule:
|
||||
enabled: false
|
||||
labels: {}
|
||||
namespace: ""
|
||||
rules: []
|
||||
resources: {}
|
||||
resourcesPreset: none
|
||||
scrapeUri: ""
|
||||
service:
|
||||
annotations:
|
||||
prometheus.io/port: "{{ .Values.metrics.service.port }}"
|
||||
prometheus.io/scrape: "true"
|
||||
port: 9117
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
interval: ""
|
||||
labels: {}
|
||||
metricRelabelings: []
|
||||
namespace: ""
|
||||
relabelings: []
|
||||
scrapeTimeout: ""
|
||||
nameOverride: ""
|
||||
networkPolicy:
|
||||
allowExternal: true
|
||||
allowExternalEgress: true
|
||||
enabled: false
|
||||
extraEgress: []
|
||||
extraIngress: []
|
||||
ingressNSMatchLabels: {}
|
||||
ingressNSPodMatchLabels: {}
|
||||
nodeAffinityPreset:
|
||||
key: ""
|
||||
type: ""
|
||||
values: []
|
||||
nodeSelector: {}
|
||||
pdb:
|
||||
create: false
|
||||
maxUnavailable: ""
|
||||
minAvailable: ""
|
||||
podAffinityPreset: ""
|
||||
podAnnotations: {}
|
||||
podAntiAffinityPreset: soft
|
||||
podLabels: {}
|
||||
podSecurityContext:
|
||||
enabled: true
|
||||
fsGroup: 1001
|
||||
fsGroupChangePolicy: Always
|
||||
supplementalGroups: []
|
||||
sysctls: []
|
||||
priorityClassName: ""
|
||||
readinessProbe:
|
||||
enabled: false
|
||||
failureThreshold: 6
|
||||
initialDelaySeconds: 30
|
||||
path: /
|
||||
periodSeconds: 10
|
||||
port: http
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 5
|
||||
replicaCount: 1
|
||||
resources: {}
|
||||
resourcesPreset: medium
|
||||
revisionHistoryLimit: 10
|
||||
schedulerName: ""
|
||||
service:
|
||||
annotations: {}
|
||||
clusterIP: ""
|
||||
externalTrafficPolicy: Cluster
|
||||
extraPorts: []
|
||||
loadBalancerIP: ""
|
||||
loadBalancerSourceRanges: []
|
||||
nodePorts:
|
||||
http: ""
|
||||
https: ""
|
||||
ports:
|
||||
http: 80
|
||||
https: 443
|
||||
sessionAffinity: None
|
||||
sessionAffinityConfig: {}
|
||||
type: LoadBalancer
|
||||
serviceAccount:
|
||||
annotations: {}
|
||||
automountServiceAccountToken: false
|
||||
create: true
|
||||
name: ""
|
||||
sidecars: []
|
||||
startupProbe:
|
||||
enabled: false
|
||||
failureThreshold: 6
|
||||
initialDelaySeconds: 180
|
||||
path: /
|
||||
periodSeconds: 20
|
||||
port: http
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 5
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
key: role
|
||||
operator: Equal
|
||||
value: cloud-container-g2
|
||||
topologySpreadConstraints: []
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
vhostsConfigMap: "apache-vhosts"
|
||||
|
||||
547
chat.py
Normal file
547
chat.py
Normal file
@@ -0,0 +1,547 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal multi-user chat. No dependencies beyond stdlib.
|
||||
|
||||
Server: python3 chat.py server [port]
|
||||
Client: python3 chat.py <host> <name> [port]
|
||||
|
||||
The server also exposes a web UI at /chat (HTTP on the same port).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import curses
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import html
|
||||
import urllib.parse
|
||||
|
||||
PORT = 9999
|
||||
VERSION = "5"
|
||||
TUNNEL_TARGET = ("185.208.174.152", 22)
|
||||
|
||||
# ── Server ──────────────────────────────────────────────────────────────
|
||||
|
||||
clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
|
||||
sse_queues: list[asyncio.Queue] = [] # web clients
|
||||
history: list[dict] = []
|
||||
|
||||
|
||||
async def broadcast(msg: dict):
|
||||
history.append(msg)
|
||||
line = json.dumps(msg) + "\n"
|
||||
|
||||
# TCP clients
|
||||
dead = []
|
||||
for w in clients:
|
||||
try:
|
||||
w.write(line.encode())
|
||||
await w.drain()
|
||||
except Exception:
|
||||
dead.append(w)
|
||||
for w in dead:
|
||||
clients.pop(w, None)
|
||||
|
||||
# SSE web clients
|
||||
dead_q = []
|
||||
for q in sse_queues:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except Exception:
|
||||
dead_q.append(q)
|
||||
for q in dead_q:
|
||||
sse_queues.remove(q)
|
||||
|
||||
|
||||
# ── HTML / JS chat page ────────────────────────────────────────────────
|
||||
|
||||
CHAT_HTML = r"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Chat</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Courier New', monospace;
|
||||
display: flex; flex-direction: column; height: 100vh; }
|
||||
#messages { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.msg { padding: 3px 0; }
|
||||
.ts { color: #666; }
|
||||
.sys { color: #5e9ca0; font-style: italic; }
|
||||
#bottom { display: flex; padding: 8px; gap: 8px; border-top: 1px solid #333; background: #16213e; }
|
||||
#name { width: 100px; padding: 8px; background: #0f3460; border: 1px solid #444;
|
||||
color: #e0e0e0; border-radius: 4px; }
|
||||
#input { flex: 1; padding: 8px; background: #0f3460; border: 1px solid #444;
|
||||
color: #e0e0e0; border-radius: 4px; }
|
||||
#send { padding: 8px 16px; background: #e94560; border: none; color: #fff;
|
||||
border-radius: 4px; cursor: pointer; }
|
||||
#send:hover { background: #c73e54; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="messages"></div>
|
||||
<div id="bottom">
|
||||
<input id="name" placeholder="Name" value="">
|
||||
<input id="input" placeholder="Type a message…" autofocus autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
||||
<button id="send">Send</button>
|
||||
</div>
|
||||
<script>
|
||||
const $msg = document.getElementById('messages');
|
||||
const $input = document.getElementById('input');
|
||||
const $name = document.getElementById('name');
|
||||
const $send = document.getElementById('send');
|
||||
|
||||
$name.value = 'user' + Math.floor(Math.random() * 1000);
|
||||
|
||||
// dark-mode-friendly palette for other users
|
||||
const USER_COLORS = [
|
||||
'#e6a23c', '#f56c9d', '#67c7eb', '#b39ddb',
|
||||
'#ff8a65', '#81c784', '#ce93d8', '#4fc3f7',
|
||||
'#ffb74d', '#aed581', '#f06292', '#4dd0e1'
|
||||
];
|
||||
|
||||
function userColor(name) {
|
||||
// "my" user gets green, others get a stable color from the palette
|
||||
if (name === $name.value.trim()) return '#4ade80';
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
|
||||
return USER_COLORS[Math.abs(h) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
function addMsg(data) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const t = new Date(data.ts * 1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
||||
if (data.user === '***') {
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span class="sys">' + esc(data.text) + '</span>';
|
||||
} else {
|
||||
const c = userColor(data.user);
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span style="color:' + c + ';font-weight:bold">' + esc(data.user) + '</span>: ' + esc(data.text);
|
||||
}
|
||||
$msg.appendChild(d);
|
||||
$msg.scrollTop = $msg.scrollHeight;
|
||||
}
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function send() {
|
||||
const text = $input.value.trim();
|
||||
const name = $name.value.trim() || 'anon';
|
||||
if (!text) return;
|
||||
fetch('/chat/send', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'name=' + encodeURIComponent(name) + '&text=' + encodeURIComponent(text)
|
||||
});
|
||||
$input.value = '';
|
||||
}
|
||||
|
||||
$send.onclick = send;
|
||||
$input.onkeydown = function(e) { if (e.key === 'Enter') send(); };
|
||||
|
||||
// SSE
|
||||
const es = new EventSource('/chat/events');
|
||||
es.onmessage = function(e) { addMsg(JSON.parse(e.data)); };
|
||||
es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# ── HTTP request parser ────────────────────────────────────────────────
|
||||
|
||||
async def parse_http_request(reader: asyncio.StreamReader, first_line: str):
|
||||
"""Parse an HTTP request, return (method, path, headers, body)."""
|
||||
parts = first_line.split()
|
||||
method = parts[0]
|
||||
path = parts[1] if len(parts) > 1 else "/"
|
||||
|
||||
headers = {}
|
||||
while True:
|
||||
hline = (await reader.readline()).decode().strip()
|
||||
if not hline:
|
||||
break
|
||||
k, _, v = hline.partition(":")
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
|
||||
body = b""
|
||||
clen = int(headers.get("content-length", 0))
|
||||
if clen > 0:
|
||||
body = await reader.readexactly(clen)
|
||||
|
||||
return method, path, headers, body
|
||||
|
||||
|
||||
# ── WebSocket helpers ───────────────────────────────────────────────────
|
||||
|
||||
async def ws_read_frame(reader):
|
||||
"""Read one WebSocket frame, return (opcode, payload)."""
|
||||
b0, b1 = struct.unpack("!BB", await reader.readexactly(2))
|
||||
opcode = b0 & 0x0F
|
||||
masked = b1 & 0x80
|
||||
length = b1 & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack("!H", await reader.readexactly(2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack("!Q", await reader.readexactly(8))[0]
|
||||
mask = await reader.readexactly(4) if masked else None
|
||||
data = await reader.readexactly(length)
|
||||
if mask:
|
||||
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
return opcode, data
|
||||
|
||||
|
||||
def ws_make_frame(opcode, data):
|
||||
"""Build an unmasked WebSocket frame."""
|
||||
frame = bytes([0x80 | opcode])
|
||||
if len(data) < 126:
|
||||
frame += bytes([len(data)])
|
||||
elif len(data) < 65536:
|
||||
frame += struct.pack("!BH", 126, len(data))
|
||||
else:
|
||||
frame += struct.pack("!BQ", 127, len(data))
|
||||
return frame + data
|
||||
|
||||
|
||||
async def handle_ws_tunnel(ws_reader, ws_writer):
|
||||
"""Bridge WebSocket frames <-> raw TCP to TUNNEL_TARGET."""
|
||||
try:
|
||||
ssh_reader, ssh_writer = await asyncio.open_connection(*TUNNEL_TARGET)
|
||||
except Exception as e:
|
||||
ws_writer.write(ws_make_frame(0x8, struct.pack("!H", 1011) + str(e).encode()[:123]))
|
||||
await ws_writer.drain()
|
||||
ws_writer.close()
|
||||
return
|
||||
|
||||
async def ws_to_ssh():
|
||||
try:
|
||||
while True:
|
||||
op, data = await ws_read_frame(ws_reader)
|
||||
if op == 0x8:
|
||||
break
|
||||
if op == 0x9:
|
||||
ws_writer.write(ws_make_frame(0xA, data))
|
||||
await ws_writer.drain()
|
||||
continue
|
||||
if op in (0x1, 0x2):
|
||||
ssh_writer.write(data)
|
||||
await ssh_writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
ssh_writer.close()
|
||||
|
||||
async def ssh_to_ws():
|
||||
try:
|
||||
while True:
|
||||
data = await ssh_reader.read(16384)
|
||||
if not data:
|
||||
break
|
||||
ws_writer.write(ws_make_frame(0x2, data))
|
||||
await ws_writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
ws_writer.write(ws_make_frame(0x8, struct.pack("!H", 1000)))
|
||||
try:
|
||||
await ws_writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
ws_writer.close()
|
||||
|
||||
await asyncio.gather(ws_to_ssh(), ssh_to_ws())
|
||||
|
||||
|
||||
# ── HTTP handling ───────────────────────────────────────────────────────
|
||||
|
||||
async def handle_http(reader, writer, first_line):
|
||||
method, path, headers, body = await parse_http_request(reader, first_line)
|
||||
|
||||
# GET /version
|
||||
if method == "GET" and path == "/version":
|
||||
resp = json.dumps({"version": VERSION}).encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: application/json\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /tunnel.py — download the tunnel client
|
||||
if method == "GET" and path == "/tunnel.py":
|
||||
tunnel_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tunnel.py")
|
||||
try:
|
||||
source = open(tunnel_path, "rb").read()
|
||||
except FileNotFoundError:
|
||||
writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/plain\r\n")
|
||||
writer.write(f"Content-Length: {len(source)}\r\n".encode())
|
||||
writer.write(b"Content-Disposition: attachment; filename=\"tunnel.py\"\r\n")
|
||||
writer.write(b"\r\n")
|
||||
writer.write(source)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET / or /chat — serve the web UI
|
||||
if method == "GET" and path in ("/", "/chat", "/chat/"):
|
||||
resp = CHAT_HTML.encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/html; charset=utf-8\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /chat/events — SSE stream
|
||||
if method == "GET" and path == "/chat/events":
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/event-stream\r\n")
|
||||
writer.write(b"Cache-Control: no-cache\r\n")
|
||||
writer.write(b"Connection: keep-alive\r\n")
|
||||
writer.write(b"X-Accel-Buffering: no\r\n")
|
||||
writer.write(b"Access-Control-Allow-Origin: *\r\n")
|
||||
writer.write(b"\r\n")
|
||||
await writer.drain()
|
||||
|
||||
for msg in history:
|
||||
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
|
||||
await writer.drain()
|
||||
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
sse_queues.append(q)
|
||||
try:
|
||||
while True:
|
||||
msg = await q.get()
|
||||
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if q in sse_queues:
|
||||
sse_queues.remove(q)
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /chat/send — send a message from the web UI
|
||||
if method == "POST" and path == "/chat/send":
|
||||
params = urllib.parse.parse_qs(body.decode())
|
||||
name = params.get("name", ["anon"])[0]
|
||||
text = params.get("text", [""])[0].strip()
|
||||
if text:
|
||||
await broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /tunnel — WebSocket upgrade for SSH tunnel
|
||||
if method == "GET" and path == "/tunnel":
|
||||
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")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
accept = base64.b64encode(
|
||||
hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-5AB9DC65B5F3").encode()).digest()
|
||||
).decode()
|
||||
writer.write(f"HTTP/1.1 101 Switching Protocols\r\n"
|
||||
f"Upgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Accept: {accept}\r\n"
|
||||
f"\r\n".encode())
|
||||
await writer.drain()
|
||||
await handle_ws_tunnel(reader, writer)
|
||||
return
|
||||
|
||||
# 404
|
||||
writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
|
||||
|
||||
# ── Connection handler (TCP chat + HTTP on same port) ──────────────────
|
||||
|
||||
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
first_line = (await reader.readline()).decode().strip()
|
||||
|
||||
# HTTP request
|
||||
if first_line.startswith(("GET ", "POST ", "PUT ", "HEAD ")):
|
||||
await handle_http(reader, writer, first_line)
|
||||
return
|
||||
|
||||
# Raw TCP chat client
|
||||
name = first_line
|
||||
clients[writer] = name
|
||||
|
||||
for msg in history:
|
||||
writer.write((json.dumps(msg) + "\n").encode())
|
||||
await writer.drain()
|
||||
|
||||
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"})
|
||||
print(f"+ {name} connected ({len(clients)} online)")
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await reader.readline()
|
||||
if not data:
|
||||
break
|
||||
text = data.decode().strip()
|
||||
if text:
|
||||
await broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
clients.pop(writer, None)
|
||||
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"})
|
||||
print(f"- {name} disconnected ({len(clients)} online)")
|
||||
writer.close()
|
||||
|
||||
|
||||
async def run_server(port: int):
|
||||
srv = await asyncio.start_server(handle, "0.0.0.0", port)
|
||||
print(f"Chat server listening on :{port}")
|
||||
print(f" Web UI: http://localhost:{port}/chat")
|
||||
print(f" CLI: python3 chat.py localhost <name> {port}")
|
||||
async with srv:
|
||||
await srv.serve_forever()
|
||||
|
||||
# ── Client TUI ──────────────────────────────────────────────────────────
|
||||
|
||||
class ChatClient:
|
||||
def __init__(self, host: str, name: str, port: int):
|
||||
self.host = host
|
||||
self.name = name
|
||||
self.port = port
|
||||
self.messages: list[str] = []
|
||||
self.input_buf = ""
|
||||
self.scroll = 0
|
||||
self.reader: asyncio.StreamReader | None = None
|
||||
self.writer: asyncio.StreamWriter | None = None
|
||||
self.running = True
|
||||
|
||||
def fmt(self, msg: dict) -> str:
|
||||
t = time.strftime("%H:%M", time.localtime(msg["ts"]))
|
||||
if msg["user"] == "***":
|
||||
return f" {t} {msg['text']}"
|
||||
return f" {t} {msg['user']}: {msg['text']}"
|
||||
|
||||
async def recv_loop(self):
|
||||
while self.running:
|
||||
line = await self.reader.readline()
|
||||
if not line:
|
||||
self.messages.append(" *** connection lost")
|
||||
self.running = False
|
||||
break
|
||||
msg = json.loads(line.decode())
|
||||
self.messages.append(self.fmt(msg))
|
||||
|
||||
async def send(self, text: str):
|
||||
self.writer.write((text + "\n").encode())
|
||||
await self.writer.drain()
|
||||
|
||||
async def run(self, stdscr):
|
||||
curses.curs_set(1)
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(2, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, -1)
|
||||
stdscr.nodelay(True)
|
||||
stdscr.timeout(50)
|
||||
|
||||
self.reader, self.writer = await asyncio.open_connection(self.host, self.port)
|
||||
await self.send(self.name)
|
||||
|
||||
recv_task = asyncio.create_task(self.recv_loop())
|
||||
|
||||
while self.running:
|
||||
h, w = stdscr.getmaxyx()
|
||||
stdscr.erase()
|
||||
|
||||
sep_y = h - 2
|
||||
stdscr.addstr(sep_y, 0, "─" * w, curses.color_pair(1))
|
||||
|
||||
visible = self.messages[-(sep_y + self.scroll):len(self.messages) - self.scroll if self.scroll else None]
|
||||
for i, line in enumerate(visible[-(sep_y):]):
|
||||
try:
|
||||
stdscr.addnstr(i, 0, line, w - 1)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
prompt = f" {self.name}> "
|
||||
try:
|
||||
stdscr.addstr(h - 1, 0, prompt, curses.color_pair(2))
|
||||
stdscr.addnstr(h - 1, len(prompt), self.input_buf, w - len(prompt) - 1)
|
||||
stdscr.move(h - 1, min(len(prompt) + len(self.input_buf), w - 1))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
try:
|
||||
ch = stdscr.get_wch()
|
||||
except curses.error:
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
|
||||
if isinstance(ch, str):
|
||||
if ch == "\n":
|
||||
if self.input_buf.strip():
|
||||
if self.input_buf.strip() == "/quit":
|
||||
break
|
||||
await self.send(self.input_buf)
|
||||
self.input_buf = ""
|
||||
self.scroll = 0
|
||||
elif ch == "\x7f" or ch == "\b":
|
||||
self.input_buf = self.input_buf[:-1]
|
||||
elif ch == "\x1b":
|
||||
pass
|
||||
elif ch.isprintable():
|
||||
self.input_buf += ch
|
||||
elif isinstance(ch, int):
|
||||
if ch == curses.KEY_BACKSPACE:
|
||||
self.input_buf = self.input_buf[:-1]
|
||||
elif ch == curses.KEY_PPAGE:
|
||||
self.scroll = min(self.scroll + 5, max(0, len(self.messages) - (h - 2)))
|
||||
elif ch == curses.KEY_NPAGE:
|
||||
self.scroll = max(self.scroll - 5, 0)
|
||||
|
||||
self.running = False
|
||||
recv_task.cancel()
|
||||
self.writer.close()
|
||||
|
||||
def run_client(host: str, name: str, port: int):
|
||||
client = ChatClient(host, name, port)
|
||||
async def main(stdscr):
|
||||
await client.run(stdscr)
|
||||
curses.wrapper(lambda stdscr: asyncio.run(main(stdscr)))
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__.strip())
|
||||
sys.exit(1)
|
||||
|
||||
if sys.argv[1] == "server":
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else PORT
|
||||
asyncio.run(run_server(port))
|
||||
else:
|
||||
host = sys.argv[1]
|
||||
name = sys.argv[2] if len(sys.argv) > 2 else "anon"
|
||||
port = int(sys.argv[3]) if len(sys.argv) > 3 else PORT
|
||||
run_client(host, name, port)
|
||||
104
nginx
Normal file
104
nginx
Normal file
@@ -0,0 +1,104 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: nginx-conf
|
||||
data:
|
||||
default.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location /tunnel {
|
||||
proxy_pass http://188.213.68.133:9997;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://188.213.68.133:9997;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# SSE support: disable buffering
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 600s;
|
||||
add_header X-Accel-Buffering no;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
limits:
|
||||
cpu: "0.5"
|
||||
ephemeral-storage: 1G
|
||||
memory: 1G
|
||||
requests:
|
||||
cpu: "0.5"
|
||||
ephemeral-storage: 1G
|
||||
memory: 1G
|
||||
volumeMounts:
|
||||
- name: nginx-conf
|
||||
mountPath: /etc/nginx/conf.d
|
||||
volumes:
|
||||
- name: nginx-conf
|
||||
configMap:
|
||||
name: nginx-conf
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nginx-svc
|
||||
spec:
|
||||
selector:
|
||||
app: nginx
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: nginx-ingress
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: nginx-0651fe8398-manpache.apps.ir-central1.arvancaas.ir
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: nginx-svc
|
||||
port:
|
||||
name: http
|
||||
104
nginx.txt
Normal file
104
nginx.txt
Normal file
@@ -0,0 +1,104 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: nginx-conf
|
||||
data:
|
||||
default.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location /tunnel {
|
||||
proxy_pass http://188.213.68.133:9997;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://188.213.68.133:9997;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# SSE support: disable buffering
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 600s;
|
||||
add_header X-Accel-Buffering no;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
limits:
|
||||
cpu: "0.5"
|
||||
ephemeral-storage: 1G
|
||||
memory: 1G
|
||||
requests:
|
||||
cpu: "0.5"
|
||||
ephemeral-storage: 1G
|
||||
memory: 1G
|
||||
volumeMounts:
|
||||
- name: nginx-conf
|
||||
mountPath: /etc/nginx/conf.d
|
||||
volumes:
|
||||
- name: nginx-conf
|
||||
configMap:
|
||||
name: nginx-conf
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nginx-svc
|
||||
spec:
|
||||
selector:
|
||||
app: nginx
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: nginx-ingress
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: nginx-0651fe8398-manpache2.apps.ir-central1.arvancaas.ir
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: nginx-svc
|
||||
port:
|
||||
name: http
|
||||
237
tunnel.py
Normal file
237
tunnel.py
Normal file
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SSH-over-WebSocket tunnel client. No dependencies beyond stdlib.
|
||||
|
||||
Usage:
|
||||
python3 tunnel.py <host> [-p PORT] [--dns DNS_SERVER]
|
||||
|
||||
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
|
||||
ssh -p 1212 user@localhost
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def ws_make_frame(opcode, data, masked=True):
|
||||
"""Build a WebSocket frame (client frames must be masked)."""
|
||||
frame = bytes([0x80 | opcode])
|
||||
if len(data) < 126:
|
||||
frame += bytes([0x80 | len(data)] if masked else [len(data)])
|
||||
elif len(data) < 65536:
|
||||
frame += struct.pack("!BH", (0x80 | 126) if masked else 126, len(data))
|
||||
else:
|
||||
frame += struct.pack("!BQ", (0x80 | 127) if masked else 127, len(data))
|
||||
if masked:
|
||||
mask = os.urandom(4)
|
||||
frame += mask
|
||||
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
return frame + data
|
||||
|
||||
|
||||
async def ws_read_frame(reader):
|
||||
"""Read one WebSocket frame, return (opcode, payload)."""
|
||||
b0, b1 = struct.unpack("!BB", await reader.readexactly(2))
|
||||
opcode = b0 & 0x0F
|
||||
masked = b1 & 0x80
|
||||
length = b1 & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack("!H", await reader.readexactly(2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack("!Q", await reader.readexactly(8))[0]
|
||||
mask = await reader.readexactly(4) if masked else None
|
||||
data = await reader.readexactly(length)
|
||||
if mask:
|
||||
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
return opcode, data
|
||||
|
||||
|
||||
async def ws_handshake(reader, writer, host):
|
||||
"""Perform WebSocket handshake, return True on success."""
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
req = (f"GET /tunnel HTTP/1.1\r\n"
|
||||
f"Host: {host}\r\n"
|
||||
f"Upgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
f"Sec-WebSocket-Version: 13\r\n"
|
||||
f"\r\n")
|
||||
writer.write(req.encode())
|
||||
await writer.drain()
|
||||
|
||||
status_line = (await reader.readline()).decode().strip()
|
||||
if "101" not in status_line:
|
||||
print(f"Handshake failed: {status_line}")
|
||||
return False
|
||||
|
||||
# consume headers
|
||||
while True:
|
||||
line = (await reader.readline()).decode().strip()
|
||||
if not line:
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(5)
|
||||
try:
|
||||
sock.sendto(header + query, (dns_server, 53))
|
||||
resp, _ = sock.recvfrom(1024)
|
||||
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)
|
||||
|
||||
# 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:
|
||||
while resp[pos] != 0:
|
||||
pos += resp[pos] + 1
|
||||
pos += 1
|
||||
|
||||
rtype, rclass, ttl, rdlen = struct.unpack("!HHIH", resp[pos:pos+10])
|
||||
pos += 10
|
||||
if rtype == 1 and rdlen == 4: # A record
|
||||
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):
|
||||
"""Handle one SSH connection by tunneling it over WebSocket."""
|
||||
ctx = None
|
||||
if use_tls:
|
||||
ctx = ssl.create_default_context()
|
||||
if resolved_ip:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
connect_host = resolved_ip or host
|
||||
try:
|
||||
ws_reader, ws_writer = await asyncio.open_connection(
|
||||
connect_host, 443 if use_tls else 80, ssl=ctx,
|
||||
server_hostname=host if resolved_ip else None)
|
||||
except Exception as e:
|
||||
print(f"Failed to connect to {host}: {e}")
|
||||
local_writer.close()
|
||||
return
|
||||
|
||||
if not await ws_handshake(ws_reader, ws_writer, host):
|
||||
local_writer.close()
|
||||
ws_writer.close()
|
||||
return
|
||||
|
||||
print(f" tunnel established → {host}")
|
||||
|
||||
async def local_to_ws():
|
||||
try:
|
||||
while True:
|
||||
data = await local_reader.read(16384)
|
||||
if not data:
|
||||
break
|
||||
ws_writer.write(ws_make_frame(0x2, data))
|
||||
await ws_writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
ws_writer.write(ws_make_frame(0x8, struct.pack("!H", 1000), masked=True))
|
||||
try:
|
||||
await ws_writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
ws_writer.close()
|
||||
|
||||
async def ws_to_local():
|
||||
try:
|
||||
while True:
|
||||
op, data = await ws_read_frame(ws_reader)
|
||||
if op == 0x8: # close
|
||||
break
|
||||
if op == 0x9: # ping → pong
|
||||
ws_writer.write(ws_make_frame(0xA, data))
|
||||
await ws_writer.drain()
|
||||
continue
|
||||
if op in (0x1, 0x2):
|
||||
local_writer.write(data)
|
||||
await local_writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
local_writer.close()
|
||||
|
||||
await asyncio.gather(local_to_ws(), ws_to_local())
|
||||
print(f" tunnel closed")
|
||||
|
||||
|
||||
async def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="SSH over WebSocket tunnel")
|
||||
parser.add_argument("host", help="WebSocket server hostname")
|
||||
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)")
|
||||
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
|
||||
|
||||
if args.dns:
|
||||
try:
|
||||
resolved_ip = resolve_with_dns(args.host, args.dns)
|
||||
print(f"Resolved {args.host} → {resolved_ip} (via {args.dns})")
|
||||
except Exception as e:
|
||||
print(f"DNS resolution failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
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)
|
||||
|
||||
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" Run: ssh -p {args.port} user@localhost")
|
||||
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user