对 terminal_service.py 文件的全面检查和修复:在子进程中使用 os._exit(1) 替代 disconnect() ,避免在子进程中调用 SocketIO 方法导致的错误;修复了 SSH 硬编码端口问题 等

This commit is contained in:
CakeCN
2026-01-07 18:57:00 +08:00
parent f818d84208
commit b806da10c4

View File

@@ -42,26 +42,36 @@ def read_and_forward_pty_output(fd=None, pid=None, room_id=None,namespace=None):
read data on pty master from the pty slave, and emit to the web terminal visitor read data on pty master from the pty slave, and emit to the web terminal visitor
""" """
max_read_bytes = 1024 * 20 max_read_bytes = 1024 * 20
timeout=0.1 # 初始超时时间设置为较短值,确保响应迅速
timeout = 0.05
# 最大超时时间,避免太频繁的检查
max_timeout = 0.5
# 成功读取数据后的重置超时时间
reset_timeout = 0.05
try: try:
while True: while True:
# 等待一段时间减少CPU占用
socketio.sleep(timeout) socketio.sleep(timeout)
timeout=min(timeout*2, 0.4)
# using flask default web server, or uwsgi production web server # 使用指数退避算法调整超时时间,但不超过最大值
# when the child process is terminated, it will not disappear from linux process list timeout = min(timeout * 1.5, max_timeout)
# and keep staying as a zombie process until the parent exits.
# 检查子进程状态
try: try:
child_process = psutil.Process(pid) child_process = psutil.Process(pid)
except psutil.NoSuchProcess as err: except psutil.NoSuchProcess:
# Process already terminated, clean up any zombie # 进程已终止,清理僵尸进程
try: try:
os.waitpid(pid, os.WNOHANG) os.waitpid(pid, os.WNOHANG)
except Exception: except Exception:
pass pass
return return
# 检查进程状态,如果不是运行或睡眠状态,则退出
if child_process.status() not in ('running', 'sleeping'): if child_process.status() not in ('running', 'sleeping'):
# Process is terminated or in other state, clean up
try: try:
# 等待进程终止
child_process.wait(timeout=1) child_process.wait(timeout=1)
except Exception: except Exception:
try: try:
@@ -69,39 +79,43 @@ def read_and_forward_pty_output(fd=None, pid=None, room_id=None,namespace=None):
except Exception: except Exception:
pass pass
return return
# 如果文件描述符有效,尝试读取数据
if fd: if fd:
timeout_sec = 0
try: try:
# Check if file descriptor is still valid by trying to select on it # 使用非阻塞select检查是否有数据可读
(data_ready, _, _) = select.select([fd], [], [], timeout_sec) (data_ready, _, _) = select.select([fd], [], [], 0)
if data_ready: if data_ready:
timeout=0.1 # 有数据可读,重置超时时间
timeout = reset_timeout
try: try:
# 读取数据
output = os.read(fd, max_read_bytes).decode() output = os.read(fd, max_read_bytes).decode()
except (OSError, IOError, EOFError) as err: except (OSError, IOError, EOFError):
# File descriptor closed or other IO error, exit gracefully # 文件描述符已关闭或其他IO错误优雅退出
return return
except UnicodeDecodeError as err: except UnicodeDecodeError as err:
output = """ # 处理编码错误
output = f"""
***AQUI WEB TERM ERR*** ***AQUI WEB TERM ERR***
Unicode decode error: {} Unicode decode error: {err}
*********************** ***********************
""".format(err) """
# the key for different visitor to get different terminal (instead of mixing up)
# is to let the background task push pty response to each one's own (default) ROOM! # 发送数据到客户端
try: try:
namespace.emit("pty_output", {"output": output}, room=room_id) namespace.emit("pty_output", {"output": output}, room=room_id)
except Exception: except Exception:
# If emit fails, the client is probably disconnected, exit # 如果发送失败,客户端可能已断开连接,退出
return return
except (OSError, IOError) as err: except (OSError, IOError):
# File descriptor closed or invalid, exit gracefully # 文件描述符无效,优雅退出
return return
except Exception as e: except Exception as e:
# Catch any other unexpected exceptions to prevent server crash # 捕获任何其他未预期的异常,防止服务器崩溃
pass current_app.logger.error(f"Unexpected error in read_and_forward_pty_output: {e}")
finally: finally:
# Clean up file descriptor if it's open # 清理文件描述符
if fd: if fd:
try: try:
os.close(fd) os.close(fd)
@@ -111,10 +125,26 @@ def read_and_forward_pty_output(fd=None, pid=None, room_id=None,namespace=None):
class VSCLikeNameSpace(Namespace): class VSCLikeNameSpace(Namespace):
def on_connect(self): def on_connect(self):
"""new client connected""" """new client connected"""
if session.get('terminal_config', {}).get('child_pid', None): # 确保terminal_config存在于session中
print(session['terminal_config']['child_pid']) if 'terminal_config' not in session:
# already started child process, don't start another session['terminal_config'] = TERM_INIT_CONFIG.copy()
session.modified = True
terminal_config = session['terminal_config']
# 检查是否已经有运行中的子进程
if terminal_config.get('child_pid'):
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 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()
@@ -123,91 +153,171 @@ class VSCLikeNameSpace(Namespace):
# 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
# subprocess.run('bash') try:
term_type = session.get('terminal_config').get('term_type') # 获取终端配置
terminal_config = session.get('terminal_config', {})
term_type = terminal_config.get('term_type')
if not term_type:
print("Terminal type not specified, exit")
os._exit(1)
path = TERM_INIT_CONFIG.get('client_path', {}).get(term_type, None) path = TERM_INIT_CONFIG.get('client_path', {}).get(term_type, None)
if not path: if not path or not os.path.exists(path):
print("Can't locate {} binary, exit".format(term_type)) print("Can't locate {} binary at {}, exit".format(term_type, path))
disconnect() os._exit(1)
# 获取连接参数
username = terminal_config.get('username')
domain = terminal_config.get('domain', TERM_INIT_CONFIG['domain'])
port = terminal_config.get('port')
if not username or not domain:
print("Missing required connection parameters, exit")
os._exit(1)
if term_type == 'telnet': if term_type == 'telnet':
# switch to the right location of your telnet binary (example comes from OSX which got telnet from brew) # 使用telnet连接
# or you can also make work like auto-detection, or manually but configurable if not port:
os.execl(path, 'telnet', '-l', session['terminal_config']['username'], print("Port not specified for telnet, exit")
session['terminal_config']['domain'], '{}'.format(session['terminal_config']['port'])) os._exit(1)
os.execl(path, 'telnet', '-l', username, domain, str(port))
elif term_type == 'ssh': elif term_type == 'ssh':
os.execl(path,'ssh', '-p','22', # 使用ssh连接
#'{}'.format(session['terminal_config']['port']), ssh_port = port if port else 22 # 默认端口22
'{}@{}'.format(session['terminal_config']['username'], session['terminal_config']['domain'])) os.execl(path, 'ssh', '-p', str(ssh_port), '{}@{}'.format(username, domain))
else: else:
current_app.logger.debug("wrong term type {}".format(term_type)) print("Wrong term type {}, exit".format(term_type))
disconnect() os._exit(1)
session['terminal_config'] = TERM_INIT_CONFIG except Exception as e:
print("Error in child process: {}", str(e))
os._exit(1)
else: else:
session['terminal_config']['fd'] = fd # 更新会话配置
session['terminal_config']['child_pid'] = child_pid terminal_config['fd'] = fd
session['terminal_config']['room_id'] = rooms()[0] terminal_config['child_pid'] = child_pid
terminal_config['room_id'] = rooms()[0]
session.modified = True session.modified = True
# 设置初始窗口大小
set_winsize(fd, 50, 50) set_winsize(fd, 50, 50)
# 记录日志
current_app.logger.debug("child pid = {}".format(child_pid)) current_app.logger.debug("child pid = {}".format(child_pid))
current_app.logger.debug("rooms of this session = {}".format(rooms())) current_app.logger.debug("rooms of this session = {}".format(rooms()))
# 启动后台任务读取pty输出
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, rooms()[0], self)
current_app.logger.debug("background task running") current_app.logger.debug("background task running")
# print(session)
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
""" """
print(f"get data {data}") # 输入验证
try: if not isinstance(data, dict):
child_process = psutil.Process(session.get('terminal_config').get('child_pid')) current_app.logger.error("Invalid input format: expected dictionary")
except psutil.NoSuchProcess as err:
disconnect()
session['terminal_config'] = TERM_INIT_CONFIG
return return
input_data = data.get("input")
if not isinstance(input_data, str):
current_app.logger.error("Invalid input data: expected string")
return
# 限制输入长度,防止缓冲区溢出
if len(input_data) > 1024:
input_data = input_data[:1024]
current_app.logger.warning("Input truncated due to length")
try:
terminal_config = session.get('terminal_config', {})
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error("No child process found")
return
child_process = psutil.Process(child_pid)
if child_process.status() not in ('running', 'sleeping'): if child_process.status() not in ('running', 'sleeping'):
disconnect() current_app.logger.debug("Child process not running, cleaning up")
session['terminal_config'] = TERM_INIT_CONFIG terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
return return
# print(session)
# print(data, 'from input') fd = terminal_config.get('fd')
fd = session.get('terminal_config').get('fd')
if fd: if fd:
# print("writing to ptd: %s" % data["input"])
# os.write(fd, data["input"].encode('ascii'))
try: try:
os.write(fd, data["input"].encode()) os.write(fd, input_data.encode())
except (OSError, IOError): except (OSError, IOError) as e:
# File descriptor closed or invalid, clean up # File descriptor closed or invalid, clean up
disconnect() current_app.logger.debug(f"Error writing to file descriptor: {e}")
session['terminal_config'] = TERM_INIT_CONFIG terminal_config.pop('child_pid', None)
return 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:
current_app.logger.error(f"Error in pty_input handling: {e}")
def on_resize(self, data): def on_resize(self, data):
# 输入验证
if not isinstance(data, dict):
current_app.logger.error("Invalid resize data format: expected dictionary")
return
rows = data.get("rows")
cols = data.get("cols")
# 验证行和列的值是否为正整数
if not (isinstance(rows, int) and isinstance(cols, int)):
current_app.logger.error("Invalid resize dimensions: expected integers")
return
# 限制窗口大小范围,防止异常值
if rows <= 0 or rows > 1000 or cols <= 0 or cols > 1000:
current_app.logger.error(f"Invalid resize dimensions: {rows}x{cols} (out of range)")
return
try: try:
child_process = psutil.Process(session.get('terminal_config').get('child_pid')) terminal_config = session.get('terminal_config', {})
except psutil.NoSuchProcess as err: child_pid = terminal_config.get('child_pid')
disconnect() if not child_pid:
session['terminal_config'] = TERM_INIT_CONFIG current_app.logger.error("No child process found for resize")
return return
# 检查子进程是否存在且运行中
child_process = psutil.Process(child_pid)
if child_process.status() not in ('running', 'sleeping'): if child_process.status() not in ('running', 'sleeping'):
disconnect() current_app.logger.debug("Child process not running, cleaning up resize")
session['terminal_config'] = TERM_INIT_CONFIG terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
return return
fd = session.get('terminal_config').get('fd')
fd = terminal_config.get('fd')
if fd: if fd:
# 检查文件描述符是否有效 # 检查文件描述符是否有效
try: try:
# 尝试一个简单的操作来检查文件描述符是否有效
os.fstat(fd) os.fstat(fd)
set_winsize(fd, data["rows"], data["cols"]) set_winsize(fd, rows, cols)
except (OSError, IOError): except (OSError, IOError) as e:
# 文件描述符无效,清理资源 # 文件描述符无效,清理资源
disconnect() current_app.logger.debug(f"Error resizing terminal: {e}")
session['terminal_config'] = TERM_INIT_CONFIG terminal_config.pop('child_pid', None)
return 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:
current_app.logger.error(f"Error in resize handling: {e}")
def on_disconnect(self): def on_disconnect(self):
terminal_config = session.get('terminal_config', {}) terminal_config = session.get('terminal_config', {})