from gevent import monkey
# 1. 关键修复:必须在导入 socket, os 等其他库之前打补丁
monkey.patch_all()
import os
import pty
import select
import struct
import fcntl
import termios
import signal
import shlex
from flask import Flask, render_template_string, request, redirect, url_for, session
from flask_socketio import SocketIO, emit, disconnect
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'secret_key_change_this_in_production')
app.config['ADMIN_USER'] = 'ikun'
app.config['ADMIN_PASS'] = os.environ.get('PASSWORD', 'ikun114514')
# 切换为 gevent 模式
socketio = SocketIO(app, cors_allowed_origins='*', async_mode='gevent')
user_sessions = {}
HTML_TEMPLATE = """
Web Terminal
{% if page == 'login' %}
终端登录
{% if error %}
{{ error }}
{% endif %}
{% else %}
初始化中...
{% endif %}
"""
@app.route('/', methods=['GET', 'POST'])
def index():
error = None
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username == app.config['ADMIN_USER'] and password == app.config['ADMIN_PASS']:
session['logged_in'] = True
return redirect(url_for('terminal'))
else:
error = '密码错误'
return render_template_string(HTML_TEMPLATE, page='login', error=error)
@app.route('/terminal')
def terminal():
if not session.get('logged_in'):
return redirect(url_for('index'))
return render_template_string(HTML_TEMPLATE, page='terminal')
# --- SocketIO 逻辑 ---
def set_winsize(fd, row, col, xpix=0, ypix=0):
try:
winsize = struct.pack("HHHH", row, col, xpix, ypix)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
except:
pass
def read_and_forward_pty_output(fd, sid):
"""
2. 关键修复:使用 select 监听 fd,防止 os.read 阻塞 gevent 循环
"""
max_read_bytes = 1024 * 20
while True:
socketio.sleep(0.01) # 让出控制权给其他 gevent 协程
if fd:
try:
# select 会等待 fd 变为可读,超时时间 0.1 秒
# 这样不会一直卡在 read 上,允许 loop 处理其他事件
r, w, x = select.select([fd], [], [], 0.1)
if fd in r:
output = os.read(fd, max_read_bytes).decode(errors='ignore')
if output:
socketio.emit('pty-output', output, room=sid)
else:
# EOF
break
except OSError:
break
except Exception as e:
# 捕获其他可能的错误防止线程崩溃
print(f"Read error: {e}")
break
@socketio.on('connect')
def connect():
if not session.get('logged_in'):
return False
# 创建伪终端
pid, fd = pty.fork()
if pid == 0:
# 子进程
os.environ['TERM'] = 'xterm-256color'
cmd = ["/bin/bash"]
os.execvp(cmd[0], cmd)
else:
# 父进程
user_sessions[request.sid] = {'fd': fd, 'pid': pid}
# 启动后台任务读取输出
socketio.start_background_task(target=read_and_forward_pty_output, fd=fd, sid=request.sid)
@socketio.on('disconnect')
def on_disconnect():
sid = request.sid
if sid in user_sessions:
fd = user_sessions[sid]['fd']
pid = user_sessions[sid]['pid']
try:
os.close(fd)
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
except OSError:
pass
del user_sessions[sid]
@socketio.on('pty-input')
def on_pty_input(data):
sid = request.sid
if sid in user_sessions:
fd = user_sessions[sid]['fd']
try:
# 写入输入到 PTY
os.write(fd, data['input'].encode())
except OSError:
pass
@socketio.on('resize')
def on_resize(data):
sid = request.sid
if sid in user_sessions:
fd = user_sessions[sid]['fd']
set_winsize(fd, data['rows'], data['cols'])
if __name__ == '__main__':
print("Running on port 7860...")
socketio.run(app, host='0.0.0.0', port=7860, debug=True)