6 Commits

2 changed files with 319 additions and 106 deletions

View File

@@ -1,6 +1,9 @@
import eventlet import eventlet
from eventlet import greenio
import logging import logging
import traceback
# 获取当前 socketio 实例并配置错误处理
import sys
# 配置 eventlet 日志,抑制 IOClosed 等错误日志 # 配置 eventlet 日志,抑制 IOClosed 等错误日志
logging.getLogger('eventlet.hubs').setLevel(logging.CRITICAL) logging.getLogger('eventlet.hubs').setLevel(logging.CRITICAL)
logging.getLogger('eventlet.greenio').setLevel(logging.CRITICAL) logging.getLogger('eventlet.greenio').setLevel(logging.CRITICAL)
@@ -10,11 +13,7 @@ eventlet.monkey_patch()
# 配置 eventlet 以更优雅地处理错误,避免疯狂报错 # 配置 eventlet 以更优雅地处理错误,避免疯狂报错
try: try:
import eventlet
import traceback
# 1. 首先,禁用 eventlet 的调试日志,减少输出 # 1. 首先,禁用 eventlet 的调试日志,减少输出
import logging
logging.getLogger('eventlet').setLevel(logging.CRITICAL) logging.getLogger('eventlet').setLevel(logging.CRITICAL)
logging.getLogger('socketio').setLevel(logging.CRITICAL) logging.getLogger('socketio').setLevel(logging.CRITICAL)
logging.getLogger('werkzeug').setLevel(logging.CRITICAL) logging.getLogger('werkzeug').setLevel(logging.CRITICAL)
@@ -41,15 +40,12 @@ try:
# 4. 只修改关键的 greenio 方法,确保在致命错误时彻底关闭连接 # 4. 只修改关键的 greenio 方法,确保在致命错误时彻底关闭连接
# 增强 socketio 错误处理,确保不会因为单个连接错误而宕机 # 增强 socketio 错误处理,确保不会因为单个连接错误而宕机
try: try:
from flask_socketio import SocketIO
# 获取当前 socketio 实例并配置错误处理
import sys
original_excepthook = sys.excepthook original_excepthook = sys.excepthook
def custom_excepthook(exc_type, exc_value, exc_traceback): def custom_excepthook(exc_type, exc_value, exc_traceback):
"""全局异常钩子,捕获所有未处理的异常""" """全局异常钩子,捕获所有未处理的异常"""
if exc_type.__name__ in ['OSError', 'IOError'] and 'Bad file descriptor' in str(exc_value): error_str = str(exc_value)
# 处理 Bad file descriptor 错误,不导致服务器宕机 if exc_type.__name__ in ['OSError', 'IOError'] and any(msg in error_str for msg in ['Bad file descriptor', 'Socket operation on non-socket', 'Operation on closed file']):
# 处理各种socket错误不导致服务器宕机
if not is_duplicate_error(exc_value): if not is_duplicate_error(exc_value):
print(f"[Global Error Handler] {exc_type.__name__}: {exc_value}") print(f"[Global Error Handler] {exc_type.__name__}: {exc_value}")
print("[Traceback]") print("[Traceback]")
@@ -64,7 +60,6 @@ try:
except Exception as e: except Exception as e:
# 如果修改失败,忽略错误 # 如果修改失败,忽略错误
print(f"[Global Excepthook Patch Error] {e}") print(f"[Global Excepthook Patch Error] {e}")
from eventlet import greenio
# 保存并替换原始的 greenio 方法 # 保存并替换原始的 greenio 方法
if hasattr(greenio.base.GreenSocket, '_recv_loop'): if hasattr(greenio.base.GreenSocket, '_recv_loop'):

View File

@@ -14,6 +14,7 @@ import termios
import struct import struct
import fcntl import fcntl
import psutil import psutil
TERM_INIT_CONFIG = { TERM_INIT_CONFIG = {
# instead of local server runnning this web terminal service # instead of local server runnning this web terminal service
# "domain" is the target that you want to access through local server (with this web terminal) # "domain" is the target that you want to access through local server (with this web terminal)
@@ -24,9 +25,108 @@ TERM_INIT_CONFIG = {
'ssh': '/usr/bin/ssh' 'ssh': '/usr/bin/ssh'
} }
} }
terminal_bp = Blueprint('terminal', __name__, url_prefix='/vsc-like') terminal_bp = Blueprint('terminal', __name__, url_prefix='/vsc-like')
class TerminalSessionManager:
"""终端会话管理器,用于统一管理所有终端会话"""
def __init__(self):
# 存储所有终端会话key为session_idvalue为会话配置
self.sessions = {}
# 线程锁,保证多线程安全
self.lock = threading.Lock()
# 最大终端数量限制
self.max_terminals = 100
# 终端空闲超时时间(秒)
self.idle_timeout = 3600 # 1小时
def create_session(self):
"""创建新的终端会话"""
with self.lock:
# 检查是否达到最大终端数量
if len(self.sessions) >= self.max_terminals:
return None, "Maximum number of terminals reached"
# 生成唯一的会话ID和房间ID
session_id = str(uuid.uuid4())
room_id = f"terminal_{session_id}"
# 创建会话配置
session_config = {
**TERM_INIT_CONFIG.copy(),
'session_id': session_id,
'room_id': room_id,
'created_at': time.time(),
'last_activity': time.time()
}
# 保存会话
self.sessions[session_id] = session_config
return session_config, None
def get_session(self, session_id):
"""获取会话配置"""
with self.lock:
return self.sessions.get(session_id)
def update_session(self, session_id, updates):
"""更新会话配置"""
with self.lock:
if session_id not in self.sessions:
return False
# 更新会话配置
self.sessions[session_id].update(updates)
# 更新最后活动时间
self.sessions[session_id]['last_activity'] = time.time()
return True
def delete_session(self, session_id):
"""删除会话"""
with self.lock:
if session_id in self.sessions:
del self.sessions[session_id]
return True
return False
def get_session_by_room_id(self, room_id):
"""通过房间ID获取会话"""
with self.lock:
for session_config in self.sessions.values():
if session_config.get('room_id') == room_id:
return session_config
return None
def cleanup_inactive_sessions(self):
"""清理不活动的会话"""
with self.lock:
current_time = time.time()
expired_sessions = []
# 找出超时的会话
for session_id, session_config in self.sessions.items():
if current_time - session_config['last_activity'] > self.idle_timeout:
expired_sessions.append(session_id)
# 删除超时的会话
for session_id in expired_sessions:
del self.sessions[session_id]
return expired_sessions
def get_active_session_count(self):
"""获取当前活动会话数量"""
with self.lock:
return len(self.sessions)
# 创建全局终端会话管理器实例
terminal_manager = TerminalSessionManager()
def set_winsize(fd, row, col, xpix=0, ypix=0): def set_winsize(fd, row, col, xpix=0, ypix=0):
try: try:
@@ -123,53 +223,46 @@ def read_and_forward_pty_output(fd=None, pid=None, room_id=None, namespace=None)
pass pass
class VSCLikeNameSpace(Namespace): class VSCLikeNameSpace(Namespace):
def on_connect(self): def __init__(self, namespace=None):
"""new client connected""" super().__init__(namespace)
# 确保terminal_config存在于session # 存储客户端socket_id到session_id的映射
if 'terminal_config' not in session: self.socket_session_map = {}
session['terminal_config'] = TERM_INIT_CONFIG.copy() # 线程锁,保证多线程安全
session.modified = True self.lock = threading.Lock()
terminal_config = session['terminal_config'] def create_terminal(self, session_id):
"""创建新的终端并更新会话配置"""
# 获取会话配置
terminal_config = terminal_manager.get_session(session_id)
if not terminal_config:
current_app.logger.error(f"Session not found: {session_id}")
return None
# 检查是否已经有运行中的子进程 room_id = terminal_config['room_id']
if terminal_config.get('child_pid'): current_app.logger.debug(f"Creating new terminal for room: {room_id}")
try:
# 验证进程是否真的存在
child_process = psutil.Process(terminal_config['child_pid'])
if child_process.status() in ('running', 'sleeping'):
current_app.logger.debug("Already running child process: {}".format(terminal_config['child_pid']))
return
except psutil.NoSuchProcess:
# 进程不存在,清理配置
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
# create child process attached to a pty we can read from and write to # create child process attached to a pty we can read from and write to
(child_pid, fd) = pty.fork() (child_pid, fd) = pty.fork()
lesson_path = session.get('path')
if child_pid == 0: if child_pid == 0:
# this is the child process fork. # this is the child process fork.
# anything printed here will show up in the pty, including the output # anything printed here will show up in the pty, including the output
# of this subprocess # of this subprocess
try: try:
# 获取终端配置 # 获取终端配置
terminal_config = session.get('terminal_config', {})
term_type = terminal_config.get('term_type') term_type = terminal_config.get('term_type')
if not term_type: if not term_type:
print("Terminal type not specified, exit") print("Terminal type not specified, exit")
os._exit(1) os._exit(1)
path = TERM_INIT_CONFIG.get('client_path', {}).get(term_type, None) path = terminal_config.get('client_path', {}).get(term_type, None)
if not path or not os.path.exists(path): if not path or not os.path.exists(path):
print("Can't locate {} binary at {}, exit".format(term_type, path)) print(f"Can't locate {term_type} binary at {path}, exit")
os._exit(1) os._exit(1)
# 获取连接参数 # 获取连接参数
username = terminal_config.get('username') username = terminal_config.get('username')
domain = terminal_config.get('domain', TERM_INIT_CONFIG['domain']) domain = terminal_config.get('domain')
port = terminal_config.get('port') port = terminal_config.get('port')
if not username or not domain: if not username or not domain:
@@ -185,34 +278,70 @@ class VSCLikeNameSpace(Namespace):
elif term_type == 'ssh': elif term_type == 'ssh':
# 使用ssh连接 # 使用ssh连接
ssh_port = port if port else 22 # 默认端口22 ssh_port = port if port else 22 # 默认端口22
os.execl(path, 'ssh', '-p', str(ssh_port), '{}@{}'.format(username, domain)) os.execl(path, 'ssh', '-p', str(ssh_port), f'{username}@{domain}')
else: else:
print("Wrong term type {}, exit".format(term_type)) print(f"Wrong term type {term_type}, exit")
os._exit(1) os._exit(1)
except Exception as e: except Exception as e:
print("Error in child process: {}", str(e)) print(f"Error in child process: {e}")
os._exit(1) os._exit(1)
else: else:
# 更新会话配置 # 更新会话配置,保存到终端管理器
terminal_config['fd'] = fd updates = {
terminal_config['child_pid'] = child_pid 'fd': fd,
terminal_config['room_id'] = rooms()[0] 'child_pid': child_pid
session.modified = True }
terminal_manager.update_session(session_id, updates)
# 设置初始窗口大小 # 设置初始窗口大小
set_winsize(fd, 50, 50) set_winsize(fd, 50, 50)
# 记录日志 # 记录日志
current_app.logger.debug("child pid = {}".format(child_pid)) current_app.logger.debug(f"Child process created: {child_pid} for room: {room_id}")
current_app.logger.debug("rooms of this session = {}".format(rooms()))
# 启动后台任务读取pty输出 # 启动后台任务读取pty输出传入正确的room_id
socketio.start_background_task(read_and_forward_pty_output, fd, child_pid, rooms()[0], self) socketio.start_background_task(read_and_forward_pty_output, fd, child_pid, room_id, self)
current_app.logger.debug("Background task running")
return terminal_config
def on_connect(self):
"""new client connected"""
# 创建新的终端会话
terminal_config, error = terminal_manager.create_session()
if not terminal_config:
current_app.logger.error(f"Failed to create terminal session: {error}")
return
session_id = terminal_config['session_id']
room_id = terminal_config['room_id']
# 加入房间
join_room(room_id)
# 保存socket_id到session_id的映射
socket_id = request.sid
with self.lock:
self.socket_session_map[socket_id] = session_id
current_app.logger.debug(f"Client connected, session_id: {session_id}, room_id: {room_id}, socket_id: {socket_id}")
# 创建终端
self.create_terminal(session_id)
current_app.logger.debug("background task running") current_app.logger.debug("background task running")
def on_pty_input(self, data): def on_pty_input(self, data):
"""write to the child pty, which now is the ssh process from this machine to the 'domain' configured """write to the child pty, which now is the ssh process from this machine to the 'domain' configured
""" """
# 获取socket_id对应的session_id
socket_id = request.sid
with self.lock:
session_id = self.socket_session_map.get(socket_id)
if not session_id:
current_app.logger.error(f"No session found for socket: {socket_id}")
return
# 输入验证 # 输入验证
if not isinstance(data, dict): if not isinstance(data, dict):
current_app.logger.error("Invalid input format: expected dictionary") current_app.logger.error("Invalid input format: expected dictionary")
@@ -229,41 +358,77 @@ class VSCLikeNameSpace(Namespace):
current_app.logger.warning("Input truncated due to length") current_app.logger.warning("Input truncated due to length")
try: try:
terminal_config = session.get('terminal_config', {}) terminal_config = terminal_manager.get_session(session_id)
if not terminal_config:
current_app.logger.error(f"Session not found: {session_id}")
return
child_pid = terminal_config.get('child_pid')
# 如果没有子进程,重新创建终端
if not child_pid:
current_app.logger.debug(f"No child process found for session: {session_id}, creating new terminal")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for session: {session_id}")
return
child_pid = terminal_config.get('child_pid') child_pid = terminal_config.get('child_pid')
if not child_pid: if not child_pid:
current_app.logger.error("No child process found") current_app.logger.error(f"Failed to get child_pid for session: {session_id}")
return return
# 检查子进程是否存在且运行中
try:
child_process = psutil.Process(child_pid) child_process = psutil.Process(child_pid)
if child_process.status() not in ('running', 'sleeping'): if child_process.status() not in ('running', 'sleeping'):
current_app.logger.debug("Child process not running, cleaning up") current_app.logger.debug(f"Child process not running for session: {session_id}, creating new terminal")
terminal_config.pop('child_pid', None) terminal_config = self.create_terminal(session_id)
terminal_config.pop('fd', None) if not terminal_config:
session.modified = True current_app.logger.error(f"Failed to create new terminal for session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for session: {session_id}")
return
except psutil.NoSuchProcess:
current_app.logger.debug(f"Child process not found for session: {session_id}, creating new terminal")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for session: {session_id}")
return return
# 获取文件描述符并写入数据
fd = terminal_config.get('fd') fd = terminal_config.get('fd')
if fd: if fd:
try: try:
os.write(fd, input_data.encode()) os.write(fd, input_data.encode())
except (OSError, IOError) as e: except (OSError, IOError) as e:
# File descriptor closed or invalid, clean up # File descriptor closed or invalid, create new terminal
current_app.logger.debug(f"Error writing to file descriptor: {e}") current_app.logger.debug(f"Error writing to file descriptor: {e}, creating new terminal for session: {session_id}")
terminal_config.pop('child_pid', None) self.create_terminal(session_id)
terminal_config.pop('fd', None)
session.modified = True
except psutil.NoSuchProcess:
# Process no longer exists, clean up
terminal_config = session.get('terminal_config', {})
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
except Exception as e: except Exception as e:
current_app.logger.error(f"Error in pty_input handling: {e}") current_app.logger.error(f"Error in pty_input handling: {e}")
# 发生异常时尝试重新创建终端
try:
self.create_terminal(session_id)
except Exception as create_err:
current_app.logger.error(f"Failed to recreate terminal: {create_err}")
def on_resize(self, data): def on_resize(self, data):
# 获取socket_id对应的session_id
socket_id = request.sid
with self.lock:
session_id = self.socket_session_map.get(socket_id)
if not session_id:
current_app.logger.error(f"No session found for socket: {socket_id}")
return
# 输入验证 # 输入验证
if not isinstance(data, dict): if not isinstance(data, dict):
current_app.logger.error("Invalid resize data format: expected dictionary") current_app.logger.error("Invalid resize data format: expected dictionary")
@@ -283,53 +448,101 @@ class VSCLikeNameSpace(Namespace):
return return
try: try:
terminal_config = session.get('terminal_config', {}) terminal_config = terminal_manager.get_session(session_id)
if not terminal_config:
current_app.logger.error(f"Session not found: {session_id}")
return
child_pid = terminal_config.get('child_pid')
# 如果没有子进程,重新创建终端
if not child_pid:
current_app.logger.debug(f"No child process found for resize, creating new terminal for session: {session_id}")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for resize, session: {session_id}")
return
child_pid = terminal_config.get('child_pid') child_pid = terminal_config.get('child_pid')
if not child_pid: if not child_pid:
current_app.logger.error("No child process found for resize") current_app.logger.error(f"Failed to get child_pid for resize, session: {session_id}")
return return
# 检查子进程是否存在且运行中 # 检查子进程是否存在且运行中
try:
child_process = psutil.Process(child_pid) child_process = psutil.Process(child_pid)
if child_process.status() not in ('running', 'sleeping'): if child_process.status() not in ('running', 'sleeping'):
current_app.logger.debug("Child process not running, cleaning up resize") current_app.logger.debug(f"Child process not running for resize, creating new terminal for session: {session_id}")
terminal_config.pop('child_pid', None) terminal_config = self.create_terminal(session_id)
terminal_config.pop('fd', None) if not terminal_config:
session.modified = True current_app.logger.error(f"Failed to create new terminal for resize, session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for resize, session: {session_id}")
return
except psutil.NoSuchProcess:
current_app.logger.debug(f"Child process not found for resize, creating new terminal for session: {session_id}")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for resize, session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for resize, session: {session_id}")
return return
# 获取文件描述符并调整窗口大小
fd = terminal_config.get('fd') fd = terminal_config.get('fd')
if fd: if fd:
# 检查文件描述符是否有效
try: try:
os.fstat(fd) os.fstat(fd)
set_winsize(fd, rows, cols) set_winsize(fd, rows, cols)
except (OSError, IOError) as e: except (OSError, IOError) as e:
# 文件描述符无效,清理资源 # 文件描述符无效,创建新终端
current_app.logger.debug(f"Error resizing terminal: {e}") current_app.logger.debug(f"Error resizing terminal: {e}, creating new terminal for session: {session_id}")
terminal_config.pop('child_pid', None) self.create_terminal(session_id)
terminal_config.pop('fd', None)
session.modified = True
except psutil.NoSuchProcess:
# 进程不存在,清理资源
terminal_config = session.get('terminal_config', {})
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
except Exception as e: except Exception as e:
current_app.logger.error(f"Error in resize handling: {e}") current_app.logger.error(f"Error in resize handling: {e}")
# 发生异常时尝试重新创建终端
try:
self.create_terminal(session_id)
except Exception as create_err:
current_app.logger.error(f"Failed to recreate terminal for resize: {create_err}")
def on_disconnect(self): def on_disconnect(self):
terminal_config = session.get('terminal_config', {}) # 获取socket_id对应的session_id
socket_id = request.sid
with self.lock:
session_id = self.socket_session_map.pop(socket_id, None)
if not session_id:
current_app.logger.error(f"No session found for socket: {socket_id}")
return
# 获取会话配置
terminal_config = terminal_manager.get_session(session_id)
if not terminal_config:
current_app.logger.error(f"Session not found: {session_id}")
return
child_pid = terminal_config.get('child_pid') child_pid = terminal_config.get('child_pid')
fd = terminal_config.get('fd') fd = terminal_config.get('fd')
room_id = terminal_config.get('room_id')
current_app.logger.debug(f"Client disconnecting, session_id: {session_id}, room_id: {room_id}, socket_id: {socket_id}")
# 退出房间
if room_id:
leave_room(room_id)
current_app.logger.debug(f"Client left room: {room_id}")
# 先关闭文件描述符,避免其他操作尝试使用无效的文件描述符 # 先关闭文件描述符,避免其他操作尝试使用无效的文件描述符
if fd: if fd:
try: try:
os.close(fd) os.close(fd)
except Exception: current_app.logger.debug(f"Closed file descriptor: {fd}")
pass except Exception as e:
current_app.logger.debug(f"Error closing file descriptor: {e}")
if child_pid: if child_pid:
try: try:
@@ -340,23 +553,28 @@ class VSCLikeNameSpace(Namespace):
child_process.terminate() child_process.terminate()
# Wait for the process to terminate and collect its exit status # Wait for the process to terminate and collect its exit status
child_process.wait(timeout=2) child_process.wait(timeout=2)
current_app.logger.debug('user left the pty alone, terminated and waited') current_app.logger.debug(f'User left pty alone, terminated and waited: {child_pid}')
except psutil.NoSuchProcess as err: except psutil.NoSuchProcess:
# Process already terminated, try to wait anyway to clean up any zombie # Process already terminated, try to wait anyway to clean up any zombie
try: try:
os.waitpid(child_pid, os.WNOHANG) os.waitpid(child_pid, os.WNOHANG)
except Exception: current_app.logger.debug(f'Cleaned up zombie process: {child_pid}')
pass except Exception as e:
current_app.logger.debug(f"Error cleaning up zombie process: {e}")
except psutil.TimeoutExpired: except psutil.TimeoutExpired:
# If process didn't terminate in time, kill it forcefully # If process didn't terminate in time, kill it forcefully
try: try:
child_process.kill() child_process.kill()
child_process.wait(timeout=1) child_process.wait(timeout=1)
except Exception: current_app.logger.debug(f'Forcefully killed process: {child_pid}')
pass except Exception as e:
current_app.logger.debug(f"Error forcefully killing process: {e}")
except Exception as err: except Exception as err:
current_app.logger.error(f'Error terminating process: {err}') current_app.logger.error(f'Error terminating process: {err}')
# Reset session config # 从终端管理器中删除会话
session['terminal_config'] = TERM_INIT_CONFIG terminal_manager.delete_session(session_id)
current_app.logger.debug(f"Session deleted: {session_id}")
current_app.logger.debug(f"Client disconnected, session_id: {session_id}, room_id: {room_id}")
current_app.logger.debug('Client disconnected') current_app.logger.debug('Client disconnected')