commit 8d6d50a2e4140835a49b739c7663395249831601 Author: Siavash Sameni Date: Thu Mar 26 14:38:36 2026 +0400 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) diff --git a/apache b/apache new file mode 100644 index 0000000..59a040f --- /dev/null +++ b/apache @@ -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: | + + 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/ + +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" + diff --git a/chat.py b/chat.py new file mode 100644 index 0000000..7e97370 --- /dev/null +++ b/chat.py @@ -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 [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""" + + + + +Chat + + + +
+
+ + + +
+ + + +""" + +# ── 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 {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) diff --git a/nginx b/nginx new file mode 100644 index 0000000..8c0454f --- /dev/null +++ b/nginx @@ -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 diff --git a/nginx.txt b/nginx.txt new file mode 100644 index 0000000..38bec0a --- /dev/null +++ b/nginx.txt @@ -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 diff --git a/tunnel.py b/tunnel.py new file mode 100644 index 0000000..01afb98 --- /dev/null +++ b/tunnel.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +SSH-over-WebSocket tunnel client. No dependencies beyond stdlib. + +Usage: + python3 tunnel.py [-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())