Files
meshcore-bot/modules/web_viewer/socket_clients.py
T
agessaman 853f86d8a5 build(mypy): type-check the modules the refactor created, tracking included
Plan rule 3c said every module this refactor creates is type-checked from its first commit; web_viewer/tracking.py was on the ignore list because it moved out of app.py with app's existing errors, and mypy skipped the bodies of untyped helpers in all the new modules. tracking.py is off the ignore list, and the 30 new modules get check_untyped_defs. The 25 errors that surfaced were all missing annotations: container annotations (paths_by_key, tracking, server_stats, result, daily_by_role, feed params), casts for page/page_size where pagination guarantees ints, FeedSubscriptionsMixin's two host attributes, and an ignore for Flask-SocketIO's request.sid. No runtime change.
2026-10-03 22:45:42 -07:00

277 lines
13 KiB
Python

"""Socket.IO clients for the web viewer: connect/disconnect and stream
subscription handlers, history replay to a new subscriber, logout disconnects
and stale-client cleanup."""
from __future__ import annotations
import configparser
import json
import os
import sqlite3
import time
from contextlib import closing, suppress
from typing import Any
from flask import request, session
from flask_socketio import disconnect, emit
from modules.utils import resolve_path
from modules.web_viewer.live_stream import _strip_ansi_codes
class SocketClientsMixin:
"""Mixed into BotDataViewer."""
_clients_lock: Any
_config_base: Any
config: Any
connected_clients: Any
db_path: Any
logger: Any
max_clients: Any
socketio: Any
web_viewer_password: Any
def _setup_socketio_handlers(self):
"""Setup SocketIO event handlers using modern patterns"""
@self.socketio.on('connect')
def handle_connect():
"""Handle client connection"""
try:
client_id = request.sid # type: ignore[attr-defined] # Flask-SocketIO sets sid on the request
if not client_id:
self.logger.warning("Connect event received but client_id is None")
return False
# Reject unauthenticated SocketIO connections when auth is enabled.
# Live packet/message/log streams stay admin-only — they can carry
# decrypted channel traffic and operator logs (deviation from #240).
if self.web_viewer_password and not session.get('authenticated_admin'):
self.logger.warning(f"Rejected unauthenticated SocketIO connection from {client_id}")
with suppress(Exception):
disconnect()
return False
self.logger.info(f"Client connected: {client_id}")
with self._clients_lock:
# Check client limit
if len(self.connected_clients) >= self.max_clients:
self.logger.warning(f"Client limit reached ({self.max_clients}), rejecting connection")
try:
disconnect()
except Exception as e:
self.logger.error(f"Error disconnecting client: {e}")
return False
# Track client
self.connected_clients[client_id] = {
'admin_login_id': session.get('admin_login_id'),
'connected_at': time.time(),
'last_activity': time.time(),
'subscribed_commands': False,
'subscribed_packets': False,
'subscribed_messages': False,
'subscribed_mesh': False,
'subscribed_logs': False,
}
# Connection status is shown via the green indicator in the navbar, no toast needed
self.logger.info(f"Client {client_id} connected. Total clients: {len(self.connected_clients)}")
except Exception as e:
self.logger.error(f"Error in handle_connect: {e}", exc_info=True)
return False
@self.socketio.on('disconnect')
def handle_disconnect(data=None):
"""Handle client disconnection"""
try:
# Safely get client_id - it may be None if disconnect happens during error state
client_id = getattr(request, 'sid', None)
with self._clients_lock:
if client_id and client_id in self.connected_clients:
del self.connected_clients[client_id]
self.logger.info(f"Client {client_id} disconnected. Total clients: {len(self.connected_clients)}")
elif client_id:
# Client disconnected but wasn't in our tracking dict (might have been cleaned up)
self.logger.debug(f"Client {client_id} disconnected (not in tracking dict)")
else:
# No client_id available - this can happen during error states
self.logger.debug("Disconnect event received but client_id is None")
except Exception as e:
# Don't emit errors during disconnect as the connection may be broken
self.logger.error(f"Error in handle_disconnect: {e}", exc_info=True)
@self.socketio.on('subscribe_commands')
def handle_subscribe_commands():
"""Handle command stream subscription — also replays recent history to the new subscriber."""
try:
client_id = self._mark_subscribed('subscribed_commands')
# Keep connection/subscription success silent; navbar indicator already shows socket state.
self.logger.debug(f"Client {client_id} subscribed to commands")
# Replay recent command history so the page isn't blank on load (BUG-023 fix)
try:
self._replay_packet_stream("type = 'command'", lambda _type: 'command_data')
except Exception as e:
self.logger.warning(f"Error replaying command history: {e}", exc_info=True)
except Exception as e:
self.logger.error(f"Error in handle_subscribe_commands: {e}", exc_info=True)
@self.socketio.on('subscribe_packets')
def handle_subscribe_packets():
"""Handle packet stream subscription — also replays recent history to the new subscriber."""
try:
client_id = self._mark_subscribed('subscribed_packets')
self.logger.debug(f"Client {client_id} subscribed to packets")
# Replay recent packet/command/routing history so the page isn't blank on load
try:
self._replay_packet_stream(
"type IN ('packet','command','routing')",
lambda _type: 'command_data' if _type == 'command' else 'packet_data',
)
except Exception as e:
self.logger.warning(f"Error replaying packet history: {e}", exc_info=True)
except Exception as e:
self.logger.error(f"Error in handle_subscribe_packets: {e}", exc_info=True)
@self.socketio.on('subscribe_mesh')
def handle_subscribe_mesh():
"""Handle mesh graph stream subscription"""
try:
client_id = self._mark_subscribed('subscribed_mesh')
self.logger.debug(f"Client {client_id} subscribed to mesh graph")
except Exception as e:
self.logger.error(f"Error in handle_subscribe_mesh: {e}", exc_info=True)
@self.socketio.on('subscribe_messages')
def handle_subscribe_messages():
"""Handle live channel message stream subscription — also replays recent messages."""
try:
client_id = self._mark_subscribed('subscribed_messages')
self.logger.debug(f"Client {client_id} subscribed to messages")
# Replay recent channel messages so the page isn't blank on load
try:
self._replay_packet_stream("type = 'message'", lambda _type: 'message_data')
except Exception as e:
self.logger.warning(f"Error replaying message history: {e}", exc_info=True)
except Exception as e:
self.logger.error(f"Error in handle_subscribe_messages: {e}", exc_info=True)
@self.socketio.on('subscribe_logs')
def handle_subscribe_logs():
"""Handle live log stream subscription — also sends last 200 log lines to the new subscriber."""
try:
client_id = self._mark_subscribed('subscribed_logs')
self.logger.debug(f"Client {client_id} subscribed to logs")
# Send recent log history so the page isn't blank on load
log_file = ''
try:
log_file = self.config.get('Logging', 'log_file', fallback='').strip()
if log_file:
log_file = str(resolve_path(log_file, self._config_base))
except (configparser.Error, OSError, ValueError): # bad config or inaccessible path
pass
if log_file and os.path.exists(log_file):
try:
with open(log_file, encoding='utf-8', errors='replace') as _fh:
recent_lines = _fh.readlines()[-200:]
for line in recent_lines:
emit('log_line', {'line': _strip_ansi_codes(line.rstrip())})
except Exception as e:
self.logger.debug(f"Error reading log history: {e}")
except Exception as e:
self.logger.error(f"Error in handle_subscribe_logs: {e}", exc_info=True)
@self.socketio.on('ping')
def handle_ping():
"""Handle client ping (modern ping/pong pattern)"""
try:
client_id = getattr(request, 'sid', None)
with self._clients_lock:
if client_id and client_id in self.connected_clients:
self.connected_clients[client_id]['last_activity'] = time.time()
emit('pong') # Server responds with pong (Flask-SocketIO 5.x pattern)
except Exception as e:
self.logger.error(f"Error in handle_ping: {e}", exc_info=True)
@self.socketio.on_error_default
def default_error_handler(e):
"""Handle SocketIO errors gracefully"""
try:
self.logger.error(f"SocketIO error: {e}", exc_info=True)
# Only emit if we have a valid request context
if hasattr(request, 'sid') and request.sid:
emit('error', {'message': str(e)})
except Exception as emit_error:
# If we can't emit, just log it
self.logger.error(f"Error emitting error message: {emit_error}")
def _mark_subscribed(self, flag: str):
"""Set a stream flag on the requesting Socket.IO client; returns its sid (may be None)."""
client_id = getattr(request, 'sid', None)
with self._clients_lock:
if client_id and client_id in self.connected_clients:
self.connected_clients[client_id][flag] = True
return client_id
def _replay_packet_stream(self, where: str, event_for_type) -> None:
"""Emit the newest 50 packet_stream rows matching *where* to the requesting client, oldest first.
``event_for_type`` maps a row's type to its event name; rows whose data
does not parse are skipped.
"""
with closing(sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)) as _conn:
_conn.row_factory = sqlite3.Row
_cur = _conn.cursor()
_cur.execute(
"SELECT data, type FROM packet_stream"
f" WHERE {where}"
" ORDER BY timestamp DESC LIMIT 50"
)
rows = list(reversed(_cur.fetchall()))
for row in rows:
try:
data = json.loads(row['data'])
emit(event_for_type(row['type']), data)
except (json.JSONDecodeError, KeyError, TypeError):
pass
def _disconnect_login_sockets(self, login_id):
"""Disconnect every Socket.IO client opened under the given admin login."""
with self._clients_lock:
sids = [
sid for sid, info in self.connected_clients.items()
if info.get('admin_login_id') == login_id
]
for sid in sids:
try:
self.socketio.server.disconnect(sid, namespace='/')
except Exception as e:
self.logger.warning(f"Could not disconnect socket {sid} on logout: {e}")
with self._clients_lock:
self.connected_clients.pop(sid, None)
if sids:
self.logger.info(f"Logout disconnected {len(sids)} live socket(s)")
def _cleanup_stale_clients(self, max_idle_seconds: int = 300):
"""Remove clients that haven't had activity in max_idle_seconds"""
try:
current_time = time.time()
stale_clients = []
with self._clients_lock:
for client_id, client_info in self.connected_clients.items():
last_activity = client_info.get('last_activity', 0)
if current_time - last_activity > max_idle_seconds:
stale_clients.append(client_id)
for client_id in stale_clients:
del self.connected_clients[client_id]
if stale_clients:
self.logger.info(f"Cleaned up {len(stale_clients)} stale client(s)")
except Exception as e:
self.logger.error(f"Error cleaning up stale clients: {e}")