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.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..5e1c060 100644 --- a/cmd_chat/__init__.py +++ b/cmd_chat/__init__.py @@ -1,6 +1,12 @@ import argparse -from cmd_chat.server.server import run_server -from cmd_chat.client.client import Client +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(): @@ -10,24 +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("--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": - run_server(host=args.ip_address, port=int(args.port), password=args.password) + 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=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 ea3e67f..1bc3413 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,53 @@ 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 + 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, ngrok_addr: Optional[str] = None, + on_disconnect=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.on_disconnect = on_disconnect self.user_id: Optional[str] = None self.fernet: Optional[Fernet] = None self.room_fernet: Optional[Fernet] = None @@ -32,13 +73,18 @@ def __init__( self.users: list[dict] = [] self.connected = False self.running = False + self._session_completed = False @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: @@ -121,26 +167,37 @@ 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", " ") - - style = "green" if username == self.username else "cyan" - self.console.print(f"[dim]{timestamp}[/] [{style}]{username}[/]: {text}") - - if not display_messages: + 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) + + users_online = ", ".join( + f"{u.get('username', '?')} ({u.get('joined_at', '')})" + for u in self.users + ) or "none" + 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:] + # 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,43 +210,83 @@ 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() 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: @@ -198,7 +295,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 @@ -211,7 +308,9 @@ 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[/]") except requests.exceptions.ConnectionError: @@ -227,4 +326,12 @@ 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: + 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/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..eaeec52 100644 --- a/cmd_chat/server/server.py +++ b/cmd_chat/server/server.py @@ -1,19 +1,157 @@ +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 _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: - app = create_app(password=password or "") + if 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) - app.run( - host=host, - port=port, - single_process=True, - debug=False, - access_log=True, + 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 + _ngrok.kill() + except Exception: + 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 " + 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}\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, + )) + + +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 + + 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) + + ngrok_addr: Optional[str] = None + if ngrok: + 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, + port=port, + username=username, + password=password, + is_host=True, + ngrok_addr=ngrok_addr, + on_disconnect=_kill_server, + ).run() 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, } ) ) diff --git a/requirements.txt b/requirements.txt index 39a62e3..7975aae 100644 Binary files a/requirements.txt and b/requirements.txt differ