From 5ccc100c18091ef4d8bdaf52a3a5fb331e8ae84e Mon Sep 17 00:00:00 2001 From: Mark Salter Date: Fri, 1 May 2026 19:14:25 +0100 Subject: [PATCH 1/3] Add host-join mode, terminal UX improvements, and session lifecycle fixes Server: - New --join/-j flag on serve command starts the server and immediately joins as a chat participant in the same process (server runs as a daemon child process via multiprocessing to work around signal handler restrictions on non-main threads) - Host typing q broadcasts a farewell message to all connected clients before shutting the server down - Session store now cleaned up on WebSocket disconnect, allowing users to reconnect with the same username after leaving Client: - Async stdin reading via connect_read_pipe replaces run_in_executor, eliminating the hang on exit caused by asyncio waiting for the blocked input() thread - SOCKS proxy bypass (proxy=None) prevents websockets auto-detecting system proxy configuration for local connections - Chat display scales to terminal height with messages pinned to the bottom and input at the foot of the screen (minimum 15 message rows) - Separators fill terminal width dynamically - Server shutdown detected and reported; client exits cleanly without requiring user input - Screen cleared and session ended message shown on clean exit; error output preserved on connection or auth failure Launcher: - cmd_chat.py auto-selects venv interpreter via os.execv when not already running inside it (detected via sys.prefix comparison) - Server and client imports are lazy so connect command has no dependency on Sanic --- cmd_chat.py | 10 +++- cmd_chat/__init__.py | 7 ++- cmd_chat/client/client.py | 116 +++++++++++++++++++++++++++---------- cmd_chat/server/helpers.py | 3 +- cmd_chat/server/server.py | 49 +++++++++++++--- cmd_chat/server/views.py | 6 ++ 6 files changed, 147 insertions(+), 44 deletions(-) diff --git a/cmd_chat.py b/cmd_chat.py index f85aa9e..41dd076 100644 --- a/cmd_chat.py +++ b/cmd_chat.py @@ -1,4 +1,12 @@ -from cmd_chat import main +import sys +import os + +_venv_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv") +_venv_python = os.path.join(_venv_dir, "bin", "python3") + +if os.path.exists(_venv_python) and os.path.realpath(sys.prefix) != os.path.realpath(_venv_dir): + os.execv(_venv_python, [_venv_python] + sys.argv) if __name__ == "__main__": + from cmd_chat import main main() diff --git a/cmd_chat/__init__.py b/cmd_chat/__init__.py index c6ea4f6..e856730 100644 --- a/cmd_chat/__init__.py +++ b/cmd_chat/__init__.py @@ -1,6 +1,4 @@ import argparse -from cmd_chat.server.server import run_server -from cmd_chat.client.client import Client def main(): @@ -11,6 +9,7 @@ def main(): serve_p.add_argument("ip_address") serve_p.add_argument("port") serve_p.add_argument("--password", "-p", required=True) + serve_p.add_argument("--join", "-j", metavar="USERNAME", help="Join chat as this user after server starts") connect_p = subparsers.add_parser("connect", help="Connect to server") connect_p.add_argument("ip_address") @@ -21,8 +20,10 @@ def main(): args = parser.parse_args() if args.command == "serve": - run_server(host=args.ip_address, port=int(args.port), password=args.password) + from cmd_chat.server.server import run_server + run_server(host=args.ip_address, port=int(args.port), password=args.password, join_as=args.join) elif args.command == "connect": + from cmd_chat.client.client import Client Client( server=args.ip_address, port=int(args.port), diff --git a/cmd_chat/client/client.py b/cmd_chat/client/client.py index ea3e67f..0eda98d 100644 --- a/cmd_chat/client/client.py +++ b/cmd_chat/client/client.py @@ -1,6 +1,8 @@ import asyncio import json import base64 +import sys +from datetime import date from typing import Optional import srp @@ -15,14 +17,23 @@ srp.rfc5054_enable() +def _fmt_join_time(iso: str) -> str: + if len(iso) < 19: + return iso + day, time = iso[:10], iso[11:19] + return time if day == str(date.today()) else f"{day} {time}" + + class Client: def __init__( - self, server: str, port: int, username: str, password: Optional[str] = None + self, server: str, port: int, username: str, password: Optional[str] = None, + is_host: bool = False, ): self.server = server self.port = port self.username = username self.password = (password or "").encode() + self.is_host = is_host self.user_id: Optional[str] = None self.fernet: Optional[Fernet] = None self.room_fernet: Optional[Fernet] = None @@ -32,6 +43,7 @@ def __init__( self.users: list[dict] = [] self.connected = False self.running = False + self._session_completed = False @property def base_url(self) -> str: @@ -121,26 +133,36 @@ def decrypt_message(self, msg: dict) -> dict: def render_messages(self) -> None: self.console.clear() - users_online = ", ".join(u.get("username", "?") for u in self.users) or "none" - self.console.print(f"[dim]Online: {users_online}[/]") - self.console.print("─" * 60) - - display_messages = ( - self.messages[-15:] if len(self.messages) > 15 else self.messages - ) - - for msg in display_messages: - username = msg.get("username", "unknown") - text = msg.get("text", "") - timestamp = str(msg.get("timestamp", ""))[:19].replace("T", " ") + height = self.console.size.height + width = self.console.size.width + # fixed rows: online + sep + sep + hint + input line = 5 + message_rows = max(15, height - 5) - style = "green" if username == self.username else "cyan" - self.console.print(f"[dim]{timestamp}[/] [{style}]{username}[/]: {text}") - - if not display_messages: + users_online = ", ".join( + f"{u.get('username', '?')} ({u.get('joined_at', '')})" + for u in self.users + ) or "none" + self.console.print(f"[dim]Online: {users_online}[/]") + self.console.print("─" * width) + + display_messages = self.messages[-message_rows:] + # 1 slot reserved when empty for the "no messages" line + content_lines = len(display_messages) if display_messages else 1 + + for _ in range(message_rows - content_lines): + self.console.print() + + if display_messages: + for msg in display_messages: + username = msg.get("username", "unknown") + text = msg.get("text", "") + timestamp = str(msg.get("timestamp", ""))[:19].replace("T", " ") + style = "green" if username == self.username else "cyan" + self.console.print(f"[dim]{timestamp}[/] [{style}]{username}[/]: {text}") + else: self.console.print("[dim italic]No messages yet...[/]") - self.console.print("─" * 60) + self.console.print("─" * width) self.console.print("[dim]Type message and press Enter. 'q' to quit.[/]") async def receive_loop(self, ws) -> None: @@ -153,39 +175,65 @@ async def receive_loop(self, ws) -> None: msg_type = data.get("type", "") if msg_type == "init": - messages = [ - self.decrypt_message(m) for m in data.get("messages", []) + self.messages = [self.decrypt_message(m) for m in data.get("messages", [])] + self.users = [ + { + "user_id": u.get("user_id"), + "username": u.get("username", "?"), + "joined_at": _fmt_join_time(u.get("joined_at", "")), + } + for u in data.get("users", []) ] - self.messages = messages - self.users = data.get("users", []) self.connected = True self.render_messages() elif msg_type == "message": msg_data = self.decrypt_message(data.get("data", {})) self.messages.append(msg_data) self.render_messages() + elif msg_type == "user_joined": + self.users.append({ + "user_id": data.get("user_id"), + "username": data.get("username", "?"), + "joined_at": _fmt_join_time(data.get("joined_at", "")), + }) + self.render_messages() elif msg_type == "user_left": left_id = data.get("user_id") self.users = [u for u in self.users if u.get("user_id") != left_id] self.render_messages() except websockets.ConnectionClosed: + if self.running: + self.console.print("\n[yellow]Server has shut down.[/]") + self.running = False self.connected = False async def input_loop(self, ws) -> None: - loop = asyncio.get_event_loop() - while self.running: - try: - text = await loop.run_in_executor(None, input) + loop = asyncio.get_running_loop() + reader = asyncio.StreamReader() + transport, _ = await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), sys.stdin + ) + try: + while self.running: + line = await reader.readline() + if not line: + break + text = line.decode("utf-8", errors="replace").rstrip("\n\r") if text.lower() in ("q", "quit", "exit"): + if self.is_host and self.room_fernet: + farewell = self.room_fernet.encrypt( + "Server shutting down — byeee! 👋".encode() + ).decode() + await ws.send(farewell) + await asyncio.sleep(0.3) self.running = False break if text.strip(): encrypted = self.room_fernet.encrypt(text.encode()).decode() await ws.send(encrypted) - except (EOFError, KeyboardInterrupt): - self.running = False - break + finally: + transport.close() async def run_async(self) -> None: self.console.clear() @@ -198,7 +246,7 @@ async def run_async(self) -> None: self.info("Connecting to chat...") url = f"{self.ws_url}/ws/chat?user_id={self.user_id}" - async with websockets.connect(url) as ws: + async with websockets.connect(url, proxy=None) as ws: self.success("Connected to chat server") self.running = True @@ -212,6 +260,7 @@ async def run_async(self) -> None: for task in pending: task.cancel() + self._session_completed = True self.console.print("\n[yellow]Disconnected[/]") except requests.exceptions.ConnectionError: @@ -227,4 +276,9 @@ async def run_async(self) -> None: traceback.print_exc() def run(self) -> None: - asyncio.run(self.run_async()) + try: + asyncio.run(self.run_async()) + finally: + if self._session_completed: + self.console.clear() + self.console.print("[dim]Session ended.[/]") diff --git a/cmd_chat/server/helpers.py b/cmd_chat/server/helpers.py index db47811..d3dc314 100644 --- a/cmd_chat/server/helpers.py +++ b/cmd_chat/server/helpers.py @@ -40,7 +40,8 @@ async def send_state(ws: Websocket, app: Sanic) -> None: "type": "init", "messages": [asdict(m) for m in messages], "users": [ - {"user_id": u.user_id, "username": u.username} for u in users + {"user_id": u.user_id, "username": u.username, "joined_at": u.created_at} + for u in users ], } ) diff --git a/cmd_chat/server/server.py b/cmd_chat/server/server.py index 87e03c0..50a7d10 100644 --- a/cmd_chat/server/server.py +++ b/cmd_chat/server/server.py @@ -1,19 +1,52 @@ +import time +import multiprocessing from typing import Optional from .factory import create_app +def _server_worker(host: str, port: int, password: str) -> None: + app = create_app(password=password) + app.run(host=host, port=port, single_process=True, debug=False, access_log=False) + + def run_server( host: str = "0.0.0.0", port: int = 8000, password: Optional[str] = None, workers: int = 1, + join_as: Optional[str] = None, ) -> None: - app = create_app(password=password or "") - - app.run( - host=host, - port=port, - single_process=True, - debug=False, - access_log=True, + if join_as: + _run_with_client(host, port, password or "", join_as) + else: + app = create_app(password=password or "") + app.run(host=host, port=port, single_process=True, debug=False, access_log=True) + + +def _run_with_client(host: str, port: int, password: str, username: str) -> None: + import requests + from cmd_chat.client.client import Client + + proc = multiprocessing.Process( + target=_server_worker, + args=(host, port, password), + daemon=True, ) + proc.start() + + client_host = "127.0.0.1" if host == "0.0.0.0" else host + base_url = f"http://{client_host}:{port}" + + for _ in range(20): + try: + requests.get(f"{base_url}/health", timeout=1) + break + except Exception: + time.sleep(0.5) + + Client(server=client_host, port=port, username=username, password=password, is_host=True).run() + proc.terminate() + proc.join(timeout=1) + if proc.is_alive(): + proc.kill() + proc.join(timeout=1) diff --git a/cmd_chat/server/views.py b/cmd_chat/server/views.py index e2ecd36..ba8cee3 100644 --- a/cmd_chat/server/views.py +++ b/cmd_chat/server/views.py @@ -94,6 +94,10 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: manager = app.ctx.connection_manager await manager.connect(user_id, ws) + await manager.broadcast( + json.dumps({"type": "user_joined", "user_id": user_id, "username": session.username, "joined_at": session.created_at}), + exclude_user=user_id, + ) try: await send_state(ws, app) @@ -124,11 +128,13 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: pass finally: await manager.disconnect(user_id) + app.ctx.session_store.remove(user_id) await manager.broadcast( json.dumps( { "type": "user_left", "user_id": user_id, + "username": session.username, } ) ) From 485d28c2fa8f1dd5d2b8cec72e1399af79ab098b Mon Sep 17 00:00:00 2001 From: Mark Salter Date: Fri, 1 May 2026 19:40:50 +0100 Subject: [PATCH 2/3] Add ngrok tunnel support with --ngrok flag Adds optional ngrok HTTPS tunnel to the serve command via --ngrok and --ngrok-token flags. On startup, opens an HTTP tunnel (TLS-terminated at grok). The host's chat UI shows the tunnel address in the Online header. Client auto-selects https:// and wss:// when connecting on port 443. Tunnel is torn down cleanly on server exit. --- cmd_chat/__init__.py | 11 +++++- cmd_chat/client/client.py | 10 ++++- cmd_chat/server/server.py | 76 ++++++++++++++++++++++++++++++++++++-- requirements.txt | Bin 1254 -> 1222 bytes 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/cmd_chat/__init__.py b/cmd_chat/__init__.py index e856730..bad8023 100644 --- a/cmd_chat/__init__.py +++ b/cmd_chat/__init__.py @@ -10,6 +10,8 @@ def main(): serve_p.add_argument("port") serve_p.add_argument("--password", "-p", required=True) serve_p.add_argument("--join", "-j", metavar="USERNAME", help="Join chat as this user after server starts") + serve_p.add_argument("--ngrok", action="store_true", help="Expose server via ngrok tunnel") + serve_p.add_argument("--ngrok-token", metavar="TOKEN", help="ngrok authtoken") connect_p = subparsers.add_parser("connect", help="Connect to server") connect_p.add_argument("ip_address") @@ -21,7 +23,14 @@ def main(): if args.command == "serve": from cmd_chat.server.server import run_server - run_server(host=args.ip_address, port=int(args.port), password=args.password, join_as=args.join) + run_server( + host=args.ip_address, + port=int(args.port), + password=args.password, + join_as=args.join, + ngrok=args.ngrok, + ngrok_token=args.ngrok_token, + ) elif args.command == "connect": from cmd_chat.client.client import Client Client( diff --git a/cmd_chat/client/client.py b/cmd_chat/client/client.py index 0eda98d..f35d4ae 100644 --- a/cmd_chat/client/client.py +++ b/cmd_chat/client/client.py @@ -27,13 +27,14 @@ def _fmt_join_time(iso: str) -> str: class Client: def __init__( self, server: str, port: int, username: str, password: Optional[str] = None, - is_host: bool = False, + is_host: bool = False, ngrok_addr: Optional[str] = None, ): self.server = server self.port = port self.username = username self.password = (password or "").encode() self.is_host = is_host + self.ngrok_addr = ngrok_addr self.user_id: Optional[str] = None self.fernet: Optional[Fernet] = None self.room_fernet: Optional[Fernet] = None @@ -47,10 +48,14 @@ def __init__( @property def base_url(self) -> str: + if self.port == 443: + return f"https://{self.server}" return f"http://{self.server}:{self.port}" @property def ws_url(self) -> str: + if self.port == 443: + return f"wss://{self.server}" return f"ws://{self.server}:{self.port}" def success(self, message: str) -> None: @@ -142,7 +147,8 @@ def render_messages(self) -> None: f"{u.get('username', '?')} ({u.get('joined_at', '')})" for u in self.users ) or "none" - self.console.print(f"[dim]Online: {users_online}[/]") + ngrok_suffix = f" [dim]tunnel: {self.ngrok_addr}:443[/]" if self.ngrok_addr else "" + self.console.print(f"[dim]Online: {users_online}[/]{ngrok_suffix}") self.console.print("─" * width) display_messages = self.messages[-message_rows:] diff --git a/cmd_chat/server/server.py b/cmd_chat/server/server.py index 50a7d10..07e5fe9 100644 --- a/cmd_chat/server/server.py +++ b/cmd_chat/server/server.py @@ -9,21 +9,70 @@ def _server_worker(host: str, port: int, password: str) -> None: app.run(host=host, port=port, single_process=True, debug=False, access_log=False) +def _start_ngrok(port: int, token: Optional[str] = None) -> Optional[str]: + try: + from pyngrok import ngrok, conf + if token: + conf.get_default().auth_token = token + tunnel = ngrok.connect(port, "http") + # https://xxxx.ngrok.io → xxxx.ngrok.io + return tunnel.public_url.replace("https://", "").replace("http://", "") + except Exception as e: + print(f"[ngrok] Failed to start tunnel: {e}") + return None + + def run_server( host: str = "0.0.0.0", port: int = 8000, password: Optional[str] = None, workers: int = 1, join_as: Optional[str] = None, + ngrok: bool = False, + ngrok_token: Optional[str] = None, ) -> None: if join_as: - _run_with_client(host, port, password or "", join_as) + _run_with_client(host, port, password or "", join_as, ngrok=ngrok, ngrok_token=ngrok_token) else: + ngrok_addr: Optional[str] = None + if ngrok: + ngrok_addr = _start_ngrok(port, ngrok_token) + if ngrok_addr: + _print_ngrok_panel(ngrok_addr, password or "") + app = create_app(password=password or "") app.run(host=host, port=port, single_process=True, debug=False, access_log=True) + if ngrok: + try: + from pyngrok import ngrok as _ngrok + _ngrok.kill() + except Exception: + pass + + +def _print_ngrok_panel(ngrok_addr: str, password: str) -> None: + from rich.console import Console + from rich.panel import Panel + console = Console() + connect_cmd = f"python cmd_chat.py connect {ngrok_addr} 443 {password}" + console.print(Panel( + f"[bold green]ngrok tunnel active (HTTPS)[/]\n\n" + f"[cyan]Address:[/] https://{ngrok_addr}:443\n\n" + f"[cyan]Connect:[/] {connect_cmd}", + title="[bold]Public Access[/]", + expand=False, + )) + -def _run_with_client(host: str, port: int, password: str, username: str) -> None: +def _run_with_client( + host: str, + port: int, + password: str, + username: str, + ngrok: bool = False, + ngrok_token: Optional[str] = None, +) -> None: import requests from cmd_chat.client.client import Client @@ -44,9 +93,30 @@ def _run_with_client(host: str, port: int, password: str, username: str) -> None except Exception: time.sleep(0.5) - Client(server=client_host, port=port, username=username, password=password, is_host=True).run() + ngrok_addr: Optional[str] = None + if ngrok: + ngrok_addr = _start_ngrok(port, ngrok_token) + if ngrok_addr: + _print_ngrok_panel(ngrok_addr, password) + + Client( + server=client_host, + port=port, + username=username, + password=password, + is_host=True, + ngrok_addr=ngrok_addr, + ).run() + proc.terminate() proc.join(timeout=1) if proc.is_alive(): proc.kill() proc.join(timeout=1) + + if ngrok: + try: + from pyngrok import ngrok as _ngrok + _ngrok.kill() + except Exception: + pass diff --git a/requirements.txt b/requirements.txt index 39a62e32bc0898ff78191e53a763015a72827431..7975aae250297e7982b3f31c60dfb55d4ea0c8c1 100644 GIT binary patch delta 254 zcmaFHd5m*{(nJ-#i6I&j>m(-5FqwExYvM1p$x4h$qFfA_3@HqG42cZ3KxoXM$6&}{ zI60P4cXAJ-)Z`V6N|VnpDohq)5}L#$0Hky#J1{9pb1?)kR5C;|I5PM$_<%K<0W}%` zvGL?aCS^%3h9aP`$qX4_IYR~$po|en#pHub@{=DisZHi$)|+g_tTs7`Sz>Yx5Km)P zn!JnIX!1K|6Im{XQlS1EAk1V)0y+ugFpzGL9Y&L#S=1-zu*gjAV$qwti$zC)i=hDM YmOO@Zpq_k&Y_Mtzph`oKY6dO_0Bce>0ssI2 delta 297 zcmX@c`HXXd5+mF6x9W_% zlZ6?TB|*wF8B!SX7!twCje$xHfsl8yH>0jBNH!NJTgp(xkOP(n8D$8>MnDx48I^&i zY-Ushl9$2kuZ+4twjz_VJV<8%LnT8bgCm14gAZ7*8Q2+m48}mc@k}7SjZD%&ayFAZ zkle`xa?^b#pg$(_GOL3pv7l0_E)T2nQl From bf56c8461c46def90943dbfd6ca0be9727b6d243 Mon Sep 17 00:00:00 2001 From: Mark Salter Date: Mon, 4 May 2026 11:49:11 +0100 Subject: [PATCH 3/3] Improve UX, session lifecycle, and ngrok polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ngrok: - Show public access panel after Sanic "Worker ready" (not before startup noise) via after_server_start listener - With --join, hold at the panel until the first remote participant connects before launching the host's chat client - Redisplay ngrok panel inside the host's chat client (cleared on start) - Password omitted from connect command example — show placeholder with a security warning to share it out-of-band Session exit: - Kill server process via on_disconnect callback the moment the host's websocket closes, so remote clients drop to "press any key" in sync - Await cancelled tasks after asyncio.wait so readline unblocks immediately when the server disconnects; no stale input prompt - Clear visible screen and scrollback (\033[H\033[2J\033[3J) written atomically to /dev/tty to avoid buffer-ordering issues with Rich - GRACEFUL_SHUTDOWN_TIMEOUT = 0 so Ctrl-C on serve exits immediately CLI: - username and password are now optional positionals on connect, and --password is optional on serve; missing values are prompted interactively (password via getpass, no echo) - --join now accepts an optional username; omitting it prompts --- README.MD | 46 +++++++++++++++++++++++++ cmd_chat/__init__.py | 38 ++++++++++++++++----- cmd_chat/client/client.py | 51 ++++++++++++++++++++++++++-- cmd_chat/server/factory.py | 1 + cmd_chat/server/server.py | 69 ++++++++++++++++++++++++++++---------- 5 files changed, 178 insertions(+), 27 deletions(-) diff --git a/README.MD b/README.MD index 4ae118d..cbd9b7b 100644 --- a/README.MD +++ b/README.MD @@ -113,12 +113,58 @@ start server: python cmd_chat.py serve 0.0.0.0 3000 --password mysecret ``` +start server and join as host: + +```bash +python cmd_chat.py serve 0.0.0.0 3000 --password mysecret --join alice +``` + connect: ```bash python cmd_chat.py connect SERVER_IP 3000 username mysecret ``` +### ngrok (internet access) + +expose your server over the internet without port forwarding using [ngrok](https://ngrok.com): + +```bash +python cmd_chat.py serve 0.0.0.0 3000 --password mysecret --ngrok +``` + +with a free ngrok account (removes session limits): + +```bash +python cmd_chat.py serve 0.0.0.0 3000 --password mysecret --ngrok --ngrok-token YOUR_TOKEN +``` + +on startup a panel is printed with the public address and ready-to-run connect command, e.g.: + +``` +╭─────────────── Public Access ────────────────╮ +│ ngrok tunnel active (HTTPS) │ +│ │ +│ Address: https://abc1-2a04.ngrok-free.app:443│ +│ │ +│ Connect: python cmd_chat.py connect │ +│ abc1-2a04.ngrok-free.app 443 │ +│ mysecret │ +╰──────────────────────────────────────────────╯ +``` + +remote participants connect via the ngrok address (TLS encrypted end-to-end): + +```bash +python cmd_chat.py connect abc1-2a04.ngrok-free.app 443 username mysecret +``` + +to host and chat simultaneously over ngrok: + +```bash +python cmd_chat.py serve 0.0.0.0 3000 --password mysecret --ngrok --join alice +``` + ![Example](example.gif) ## features diff --git a/cmd_chat/__init__.py b/cmd_chat/__init__.py index bad8023..5e1c060 100644 --- a/cmd_chat/__init__.py +++ b/cmd_chat/__init__.py @@ -1,4 +1,12 @@ import argparse +import getpass + + +def _prompt(label: str, secret: bool = False) -> str: + while True: + value = (getpass.getpass(label) if secret else input(label)).strip() + if value: + return value def main(): @@ -8,36 +16,50 @@ def main(): serve_p = subparsers.add_parser("serve", help="Run server") serve_p.add_argument("ip_address") serve_p.add_argument("port") - serve_p.add_argument("--password", "-p", required=True) - serve_p.add_argument("--join", "-j", metavar="USERNAME", help="Join chat as this user after server starts") + serve_p.add_argument("--password", "-p", default=None) + serve_p.add_argument("--join", "-j", nargs="?", const=True, default=False, metavar="USERNAME", + help="Join chat as host (prompts for username if not given)") serve_p.add_argument("--ngrok", action="store_true", help="Expose server via ngrok tunnel") serve_p.add_argument("--ngrok-token", metavar="TOKEN", help="ngrok authtoken") connect_p = subparsers.add_parser("connect", help="Connect to server") connect_p.add_argument("ip_address") connect_p.add_argument("port") - connect_p.add_argument("username") - connect_p.add_argument("password") + connect_p.add_argument("username", nargs="?", default=None) + connect_p.add_argument("password", nargs="?", default=None) args = parser.parse_args() if args.command == "serve": + password = args.password or _prompt("Password: ", secret=True) + + if args.join is True: + join_as = _prompt("Your username: ") + elif args.join: + join_as = args.join + else: + join_as = None + from cmd_chat.server.server import run_server run_server( host=args.ip_address, port=int(args.port), - password=args.password, - join_as=args.join, + password=password, + join_as=join_as, ngrok=args.ngrok, ngrok_token=args.ngrok_token, ) + elif args.command == "connect": + username = args.username or _prompt("Username: ") + password = args.password or _prompt("Password: ", secret=True) + from cmd_chat.client.client import Client Client( server=args.ip_address, port=int(args.port), - username=args.username, - password=args.password, + username=username, + password=password, ).run() diff --git a/cmd_chat/client/client.py b/cmd_chat/client/client.py index f35d4ae..1bc3413 100644 --- a/cmd_chat/client/client.py +++ b/cmd_chat/client/client.py @@ -17,6 +17,33 @@ srp.rfc5054_enable() +def _clear_terminal() -> None: + try: + import os + fd = os.open("/dev/tty", os.O_WRONLY) + os.write(fd, b"\033[H\033[2J\033[3J") + os.close(fd) + except OSError: + sys.stdout.write("\033[H\033[2J\033[3J") + sys.stdout.flush() + + +def _wait_for_keypress() -> None: + import tty + import termios + try: + with open("/dev/tty", "rb") as tty_in: + fd = tty_in.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + tty_in.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + except (KeyboardInterrupt, EOFError, OSError): + pass + + def _fmt_join_time(iso: str) -> str: if len(iso) < 19: return iso @@ -28,6 +55,7 @@ class Client: def __init__( self, server: str, port: int, username: str, password: Optional[str] = None, is_host: bool = False, ngrok_addr: Optional[str] = None, + on_disconnect=None, ): self.server = server self.port = port @@ -35,6 +63,7 @@ def __init__( self.password = (password or "").encode() self.is_host = is_host self.ngrok_addr = ngrok_addr + self.on_disconnect = on_disconnect self.user_id: Optional[str] = None self.fernet: Optional[Fernet] = None self.room_fernet: Optional[Fernet] = None @@ -244,6 +273,20 @@ async def input_loop(self, ws) -> None: async def run_async(self) -> None: self.console.clear() self.console.print(Panel("[bold cyan]CMD Chat Client[/]", expand=False)) + if self.ngrok_addr: + from rich.panel import Panel as _Panel + password = self.password.decode() + connect_cmd = f"python cmd_chat.py connect {self.ngrok_addr} 443 " + self.console.print(_Panel( + f"[bold green]ngrok tunnel active (HTTPS)[/]\n\n" + f"[cyan]Address:[/] https://{self.ngrok_addr}:443\n\n" + f"[cyan]Connect:[/] {connect_cmd}\n\n" + f"[bold red]⚠ The shared password must be pre-known and never sent over this channel.[/]\n" + f"[red]Anyone with the password can join and decrypt all messages.[/]\n" + f"[red]Sharing it here destroys all security guarantees.[/]", + title="[bold]Public Access[/]", + expand=False, + )) self.console.print() try: @@ -265,6 +308,7 @@ async def run_async(self) -> None: for task in pending: task.cancel() + await asyncio.gather(*pending, return_exceptions=True) self._session_completed = True self.console.print("\n[yellow]Disconnected[/]") @@ -286,5 +330,8 @@ def run(self) -> None: asyncio.run(self.run_async()) finally: if self._session_completed: - self.console.clear() - self.console.print("[dim]Session ended.[/]") + if self.on_disconnect: + self.on_disconnect() + self.console.print("\n[dim]Press any key to clear screen and exit...[/]") + _wait_for_keypress() + _clear_terminal() diff --git a/cmd_chat/server/factory.py b/cmd_chat/server/factory.py index 894f08c..528876d 100644 --- a/cmd_chat/server/factory.py +++ b/cmd_chat/server/factory.py @@ -13,6 +13,7 @@ def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic: app = Sanic(name) + app.config.GRACEFUL_SHUTDOWN_TIMEOUT = 0 Extend(app) app.ctx.message_store = MessageStore() diff --git a/cmd_chat/server/server.py b/cmd_chat/server/server.py index 07e5fe9..eaeec52 100644 --- a/cmd_chat/server/server.py +++ b/cmd_chat/server/server.py @@ -37,12 +37,27 @@ def run_server( ngrok_addr: Optional[str] = None if ngrok: ngrok_addr = _start_ngrok(port, ngrok_token) - if ngrok_addr: - _print_ngrok_panel(ngrok_addr, password or "") app = create_app(password=password or "") + + if ngrok_addr: + _pwd = password or "" + _addr = ngrok_addr + + @app.after_server_start + async def _show_ngrok_panel(app, loop): + _print_ngrok_panel(_addr, _pwd) + app.run(host=host, port=port, single_process=True, debug=False, access_log=True) + try: + import os + fd = os.open("/dev/tty", os.O_WRONLY) + os.write(fd, b"\033[H\033[2J\033[3J") + os.close(fd) + except OSError: + pass + if ngrok: try: from pyngrok import ngrok as _ngrok @@ -51,15 +66,33 @@ def run_server( pass +def _wait_for_first_participant(base_url: str) -> None: + import requests + from rich.console import Console + console = Console() + with console.status("[cyan]Waiting for first participant to connect...[/]", spinner="dots"): + while True: + try: + r = requests.get(f"{base_url}/health", timeout=1) + if r.json().get("users", 0) >= 1: + break + except Exception: + pass + time.sleep(0.5) + + def _print_ngrok_panel(ngrok_addr: str, password: str) -> None: from rich.console import Console from rich.panel import Panel console = Console() - connect_cmd = f"python cmd_chat.py connect {ngrok_addr} 443 {password}" + connect_cmd = f"python cmd_chat.py connect {ngrok_addr} 443 " console.print(Panel( f"[bold green]ngrok tunnel active (HTTPS)[/]\n\n" f"[cyan]Address:[/] https://{ngrok_addr}:443\n\n" - f"[cyan]Connect:[/] {connect_cmd}", + f"[cyan]Connect:[/] {connect_cmd}\n\n" + f"[bold red]⚠ The shared password must be pre-known and never sent over this channel.[/]\n" + f"[red]Anyone with the password can join and decrypt all messages.[/]\n" + f"[red]Sharing it here destroys all security guarantees.[/]", title="[bold]Public Access[/]", expand=False, )) @@ -98,6 +131,20 @@ def _run_with_client( ngrok_addr = _start_ngrok(port, ngrok_token) if ngrok_addr: _print_ngrok_panel(ngrok_addr, password) + _wait_for_first_participant(base_url) + + def _kill_server(): + proc.terminate() + proc.join(timeout=1) + if proc.is_alive(): + proc.kill() + proc.join(timeout=1) + if ngrok: + try: + from pyngrok import ngrok as _ngrok + _ngrok.kill() + except Exception: + pass Client( server=client_host, @@ -106,17 +153,5 @@ def _run_with_client( password=password, is_host=True, ngrok_addr=ngrok_addr, + on_disconnect=_kill_server, ).run() - - proc.terminate() - proc.join(timeout=1) - if proc.is_alive(): - proc.kill() - proc.join(timeout=1) - - if ngrok: - try: - from pyngrok import ngrok as _ngrok - _ngrok.kill() - except Exception: - pass