266 lines
11 KiB
Python
266 lines
11 KiB
Python
#!/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)
|