#!/usr/bin/env python3 """ PSP Chat – Cliente Web Flask + Socket.IO que hace de PUENTE entre el navegador y el servidor TCP. Arquitectura: Navegador ⇄ Socket.IO (WebSocket) ⇄ client_web.py ⇄ TCP ⇄ server.py Uso: python client_web.py [--server 127.0.0.1] [--port 9000] [--web-port 5001] """ # ── Importaciones ──────────────────────────────────────────────────────────── import argparse # Para leer argumentos de la línea de comandos (--server, --port, etc.) import json # Para convertir diccionarios Python a texto JSON y viceversa import socket # Para crear conexiones TCP hacia el servidor de chat import threading # Para leer del servidor TCP en paralelo sin bloquear el servidor web import logging # Para mostrar mensajes informativos en la consola from flask import Flask, render_template, request # Framework web para servir la página HTML from flask_socketio import SocketIO, emit # WebSockets: comunicación en tiempo real con el navegador # ── Configuración del sistema de logs ────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S' ) log = logging.getLogger('ChatClient') # ── Aplicación Flask y Socket.IO ──────────────────────────────────────────── # Flask sirve la página web (HTML/CSS/JS) app = Flask(__name__) app.config['SECRET_KEY'] = 'psp_chat_secret' # Clave para firmar cookies de sesión # SocketIO añade soporte WebSocket sobre Flask # cors_allowed_origins='*' permite conexiones desde cualquier dirección IP # async_mode='threading' usa hilos de Python (compatible con nuestro código TCP) socketio = SocketIO(app, cors_allowed_origins='*', async_mode='threading') # ── Configuración del servidor TCP destino (se puede cambiar por argumentos CLI) ── SERVER_HOST = '127.0.0.1' # IP del servidor TCP de chat SERVER_PORT = 9000 # Puerto del servidor TCP de chat # ── Sesiones activas ──────────────────────────────────────────────────────────── # Cada navegador que se conecta tiene su propia sesión con su propio socket TCP. # Clave: sid (Socket.IO session ID) del navegador # Valor: {'sock': socket TCP, 'nick': nombre del usuario} sessions = {} sess_lock = threading.Lock() # Candado para acceder a 'sessions' de forma segura def tcp_reader(sid, sock): """ Hilo que escucha continuamente el socket TCP del servidor de chat y reenvía cada mensaje al navegador mediante Socket.IO. Esto es necesario porque TCP y WebSocket son protocolos distintos; este puente los une de forma transparente para el usuario. """ buf = '' try: while True: # Leemos hasta 4096 bytes del servidor TCP chunk = sock.recv(4096).decode('utf-8', errors='replace') if not chunk: break # El servidor cerró la conexión buf += chunk # Procesamos cada línea completa del buffer (cada mensaje JSON) while '\n' in buf: line, buf = buf.split('\n', 1) line = line.strip() if not line: continue try: msg = json.loads(line) # Convertir el texto JSON a diccionario except json.JSONDecodeError: continue # Línea no válida, la ignoramos log.debug(f'TCP→Browser [{sid[:6]}]: {msg}') # Enviar el mensaje al navegador por WebSocket socketio.emit('srv_msg', msg, to=sid) except Exception as e: log.info(f'TCP reader ended for {sid[:6]}: {e}') finally: # Si se pierde la conexión TCP, notificamos al navegador socketio.emit('srv_msg', { 'type': 'error', 'text': 'Conexión con el servidor perdida' }, to=sid) with sess_lock: sessions.pop(sid, None) # Limpiar la sesión de la memoria def tcp_send(sid, data: dict): """ Envía un mensaje JSON al servidor TCP desde la sesión indicada. Se usa solo para el JOIN inicial, que sí requiere formato JSON. Devuelve True si se envió correctamente, False si hubo error. """ with sess_lock: s = sessions.get(sid) if not s: return False # Sesión no encontrada (el usuario no está conectado al TCP) try: s['sock'].sendall((json.dumps(data, ensure_ascii=False) + '\n').encode('utf-8')) return True except Exception as e: log.error(f'TCP send error: {e}') return False def tcp_send_text(sid, text: str): """ Envía una línea de texto plano al servidor TCP. El servidor, tras el JOIN, trata cada línea como un mensaje de chat, así que debemos enviar solo el texto, no JSON. Esto garantiza que los mensajes del cliente web sean idénticos a los que envía un usuario de netcat o terminal. """ with sess_lock: s = sessions.get(sid) if not s: return False try: s['sock'].sendall((text + '\n').encode('utf-8')) return True except Exception as e: log.error(f'TCP send error: {e}') return False # ── Rutas Flask (HTTP) ────────────────────────────────────────────────── @app.route('/') def index(): """ Sirve la página principal del chat (templates/index.html). Pasa al HTML la IP y puerto del servidor TCP para mostrarlos en el login. """ return render_template('index.html', server_host=SERVER_HOST, server_port=SERVER_PORT) # ── Eventos Socket.IO (WebSocket entre navegador y este cliente web) ───────── @socketio.on('connect') def on_connect(): """Se dispara cuando un navegador abre la página. Solo lo registramos en el log.""" log.info(f'Browser conectado: {request.sid[:8]}') @socketio.on('disconnect') def on_disconnect(): """ Se dispara cuando el navegador cierra la pestaña o pierde la conexión. Cerramos el socket TCP asociado para no dejar conexiones huérfanas abiertas. """ sid = request.sid log.info(f'Browser desconectado: {sid[:8]}') with sess_lock: s = sessions.pop(sid, None) # Eliminar la sesión de la memoria if s: try: s['sock'].close() # Cerrar el socket TCP liberando el recurso except Exception: pass @socketio.on('join') def on_join(data): """ El navegador quiere unirse al chat. Recibe: {nick: 'Alice'} Pasos: 1. Crea una nueva conexión TCP al servidor de chat. 2. Guarda el socket en la tabla de sesiones. 3. Lanza el hilo tcp_reader para escuchar respuestas del servidor. 4. Envía el mensaje JOIN al servidor TCP. """ sid = request.sid nick = str(data.get('nick', '')).strip() # Evitar que el mismo navegador se conecte dos veces with sess_lock: if sid in sessions: emit('srv_msg', {'type': 'error', 'text': 'Ya estás conectado'}) return # Intentar conectar al servidor TCP try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) # Timeout de 10 s para la conexión inicial sock.connect((SERVER_HOST, SERVER_PORT)) sock.settimeout(None) # Sin timeout una vez conectado except Exception as e: emit('srv_msg', {'type': 'error', 'text': f'No se puede conectar al servidor TCP: {e}'}) return # Guardar la sesión: asociar el sid del navegador con su socket TCP with sess_lock: sessions[sid] = {'sock': sock, 'nick': nick} # Lanzar el hilo lector: escuchará el TCP y retransmitirá mensajes al navegador t = threading.Thread(target=tcp_reader, args=(sid, sock), daemon=True) t.start() # Enviar el mensaje de join al servidor TCP tcp_send(sid, {'type': 'join', 'nick': nick}) log.info(f'{nick} ({sid[:8]}) conectado al servidor TCP') @socketio.on('send_msg') def on_send_msg(data): """ El navegador envía un mensaje a la sala. Recibe: {type:'msg', room:'general', text:'...'} Enviamos Solo el texto plano al servidor TCP (no el JSON completo), porque el servidor tras el JOIN trata cada línea como texto de chat. Así los mensajes del cliente web son idénticos a los de netcat. """ text = str(data.get('text', '')).strip() if text: tcp_send_text(request.sid, text) @socketio.on('send_pm') def on_send_pm(data): """ El navegador envía un mensaje privado. Recibe: {type:'pm', to:'Bob', text:'...'} Lo reenvía al servidor TCP. """ tcp_send(request.sid, data) @socketio.on('cmd') def on_cmd(data): """ Cualquier otro comando del navegador (crear sala, unirse a sala, etc.). Se reenvía directamente al servidor TCP sin modificaciones. """ tcp_send(request.sid, data) # ── Punto de entrada principal ──────────────────────────────────────────────── if __name__ == '__main__': # Configurar argumentos de línea de comandos para poder personalizar # la IP/puerto del servidor TCP y el puerto web sin editar el código parser = argparse.ArgumentParser(description='PSP Chat – Web Client') parser.add_argument('--server', default='127.0.0.1', help='IP del servidor TCP') parser.add_argument('--port', type=int, default=9000, help='Puerto del servidor TCP') parser.add_argument('--web-port', type=int, default=5001, help='Puerto del servidor web') args = parser.parse_args() # Actualizamos las variables globales con los valores recibidos SERVER_HOST = args.server SERVER_PORT = args.port log.info(f'╔══════════════════════════════════════╗') log.info(f'║ PSP Chat – Cliente Web ║') log.info(f'║ Servidor TCP : {SERVER_HOST}:{SERVER_PORT} ║') log.info(f'║ Interfaz web : http://0.0.0.0:{args.web_port} ║') log.info(f'╚══════════════════════════════════════╝') # Arrancar el servidor web Flask+SocketIO en todas las interfaces # debug=False para no mostrar información sensible en producción socketio.run(app, host='0.0.0.0', port=args.web_port, debug=False)