mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/sky22333/ansible-ui
synced 2026-09-20 08:03:57 +08:00
清理冗余
This commit is contained in:
521
app.py
521
app.py
@@ -1,3 +1,4 @@
|
||||
from contextlib import contextmanager
|
||||
from flask import Flask, request, jsonify, send_from_directory, Response
|
||||
from database import Database
|
||||
from ansible_manager import AnsibleManager
|
||||
@@ -74,26 +75,68 @@ def handle_error(f):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return decorated_function
|
||||
|
||||
def get_request_token():
|
||||
"""从请求中提取JWT令牌"""
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
return auth_header.split(' ', 1)[1]
|
||||
|
||||
return request.cookies.get('token') or request.args.get('token')
|
||||
|
||||
def ensure_crypto_key():
|
||||
"""确保运行期加密密钥已初始化"""
|
||||
from crypto_utils import CRYPTO_KEY
|
||||
|
||||
if isinstance(CRYPTO_KEY, bytes) and len(CRYPTO_KEY) == 32:
|
||||
return True
|
||||
|
||||
if not ADMIN_USERNAME or not ADMIN_PASSWORD:
|
||||
app.logger.error("缺少管理员凭证,无法派生加密密钥")
|
||||
return False
|
||||
|
||||
app.logger.info("检测到加密密钥未初始化,尝试从管理员凭证派生")
|
||||
try:
|
||||
key, salt = derive_key_from_credentials(ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
set_crypto_keys(key, salt)
|
||||
app.logger.info("密钥派生成功,长度为: %d 字节", len(key))
|
||||
return True
|
||||
except Exception as e:
|
||||
app.logger.error(f"密钥派生失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@contextmanager
|
||||
def ssh_client_for_host(host, timeout=10):
|
||||
"""为指定主机创建并管理SSH连接"""
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username'],
|
||||
'timeout': timeout
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
|
||||
ssh.connect(**connect_args)
|
||||
try:
|
||||
yield ssh
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
@contextmanager
|
||||
def sftp_client_for_host(host, timeout=10):
|
||||
"""为指定主机创建并管理SFTP连接"""
|
||||
with ssh_client_for_host(host, timeout=timeout) as ssh:
|
||||
with ssh.open_sftp() as sftp:
|
||||
yield sftp
|
||||
|
||||
def auth_required(f):
|
||||
"""JWT认证要求装饰器"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# 获取Authorization头部
|
||||
auth_header = request.headers.get('Authorization')
|
||||
token = None
|
||||
|
||||
# 从header中提取token
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ')[1]
|
||||
|
||||
# 如果token不在header中,尝试从cookies获取
|
||||
if not token:
|
||||
token = request.cookies.get('token')
|
||||
|
||||
# 如果token不在cookies中,尝试从查询参数获取(用于兼容某些场景)
|
||||
if not token:
|
||||
token = request.args.get('token')
|
||||
|
||||
token = get_request_token()
|
||||
if not token:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
@@ -101,22 +144,8 @@ def auth_required(f):
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid or expired token'}), 401
|
||||
|
||||
# 在每次API调用时,如果没有设置加密密钥,则从用户凭证派生
|
||||
# 这里从crypto_utils导入全局变量
|
||||
from crypto_utils import CRYPTO_KEY
|
||||
|
||||
# 检查密钥是否有效或需要重新派生
|
||||
if (CRYPTO_KEY is None or
|
||||
isinstance(CRYPTO_KEY, bytes) and (len(CRYPTO_KEY) != 32 or CRYPTO_KEY == os.urandom(32))) and ADMIN_USERNAME and ADMIN_PASSWORD:
|
||||
# 只有在设置了环境变量时才尝试派生密钥
|
||||
app.logger.info("API调用中检测到加密密钥未设置或无效,尝试从用户凭证派生")
|
||||
try:
|
||||
key, salt = derive_key_from_credentials(ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
set_crypto_keys(key, salt)
|
||||
app.logger.info("密钥派生成功,长度为: %d 字节", len(key))
|
||||
except Exception as e:
|
||||
app.logger.error(f"密钥派生失败: {str(e)}")
|
||||
return jsonify({'error': '系统加密配置错误,请联系管理员'}), 500
|
||||
if not ensure_crypto_key():
|
||||
return jsonify({'error': '系统加密配置错误,请联系管理员'}), 500
|
||||
|
||||
# 将用户信息添加到request中,以便视图函数使用
|
||||
request.user = user
|
||||
@@ -130,39 +159,6 @@ def before_request():
|
||||
if request.method == 'OPTIONS':
|
||||
return None
|
||||
|
||||
if request.path.startswith('/ws/'):
|
||||
return None
|
||||
|
||||
if request.path.startswith('/terminal'):
|
||||
return None
|
||||
|
||||
if request.path == '/login':
|
||||
return None
|
||||
|
||||
if request.path.startswith('/api/') and request.path != '/api/login':
|
||||
auth_header = request.headers.get('Authorization')
|
||||
token = None
|
||||
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ')[1]
|
||||
|
||||
if not token:
|
||||
token = request.cookies.get('token')
|
||||
|
||||
if not token:
|
||||
token = request.args.get('token')
|
||||
|
||||
if not token:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
user = decode_token(token)
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid or expired token'}), 401
|
||||
|
||||
request.user = user
|
||||
else:
|
||||
pass
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
|
||||
@@ -345,19 +341,8 @@ def add_hosts_batch():
|
||||
def update_host(host_id):
|
||||
"""更新主机信息"""
|
||||
host_data = request.json
|
||||
auth_method = host_data.get('auth_method', 'password') # 默认为密码认证
|
||||
|
||||
required_fields = ['comment', 'address', 'username', 'port']
|
||||
if auth_method == 'password' and 'password' in host_data and host_data['password'] != '':
|
||||
# 如果是密码认证且提供了新密码,则密码是必需的
|
||||
pass # 密码会在db.update_host中处理
|
||||
elif auth_method == 'password' and ('password' not in host_data or host_data['password'] == ''):
|
||||
# 如果是密码认证但未提供新密码,则不更新密码字段,保持原有密码
|
||||
pass
|
||||
elif auth_method == 'key':
|
||||
# 如果是密钥认证,密码字段不强制要求
|
||||
pass
|
||||
|
||||
if not all(field in host_data for field in required_fields):
|
||||
return jsonify({'error': 'Missing required fields'}), 400
|
||||
|
||||
@@ -503,75 +488,60 @@ def terminal_ws(ws, host_id):
|
||||
app.logger.info(f"找到主机信息: id={host_id}")
|
||||
|
||||
try:
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
with ssh_client_for_host(host, timeout=10) as ssh:
|
||||
# 默认终端大小
|
||||
term_width = 100
|
||||
term_height = 30
|
||||
|
||||
app.logger.info("正在连接SSH")
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username'],
|
||||
'timeout': 10
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
# else: paramiko will try to use SSH agent or default keys (/root/.ssh/id_ed25519)
|
||||
app.logger.info("SSH连接成功,创建终端会话")
|
||||
channel = ssh.invoke_shell(term='xterm-256color', width=term_width, height=term_height)
|
||||
|
||||
ssh.connect(**connect_args)
|
||||
def send_data():
|
||||
while True:
|
||||
try:
|
||||
if channel.recv_ready():
|
||||
data = channel.recv(1024).decode('utf-8', errors='ignore')
|
||||
if data:
|
||||
ws.send(data)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
except Exception:
|
||||
app.logger.error("数据发送错误")
|
||||
break
|
||||
|
||||
# 默认终端大小
|
||||
term_width = 100
|
||||
term_height = 30
|
||||
thread = threading.Thread(target=send_data)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
app.logger.info("SSH连接成功,创建终端会话")
|
||||
channel = ssh.invoke_shell(term='xterm-256color', width=term_width, height=term_height)
|
||||
app.logger.info("WebSocket连接已建立,后台线程已启动")
|
||||
|
||||
# 发送初始欢迎信息
|
||||
welcome_msg = "\r\n\x1b[1;32m*** 已连接到主机 ***\x1b[0m\r\n"
|
||||
ws.send(welcome_msg)
|
||||
|
||||
def send_data():
|
||||
while True:
|
||||
try:
|
||||
if channel.recv_ready():
|
||||
data = channel.recv(1024).decode('utf-8', errors='ignore')
|
||||
if data:
|
||||
ws.send(data)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
message = ws.receive()
|
||||
if message is None:
|
||||
app.logger.info("WebSocket连接已关闭")
|
||||
break
|
||||
|
||||
data = json.loads(message)
|
||||
if data['type'] == 'input':
|
||||
channel.send(data['data'])
|
||||
elif data['type'] == 'resize':
|
||||
new_size = data['data']
|
||||
channel.resize_pty(
|
||||
width=new_size['cols'],
|
||||
height=new_size['rows']
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
app.logger.error("JSON解析错误")
|
||||
continue
|
||||
except Exception:
|
||||
app.logger.error("数据发送错误")
|
||||
app.logger.error("WebSocket接收错误")
|
||||
break
|
||||
|
||||
thread = threading.Thread(target=send_data)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
app.logger.info("WebSocket连接已建立,后台线程已启动")
|
||||
|
||||
# 发送初始欢迎信息
|
||||
welcome_msg = "\r\n\x1b[1;32m*** 已连接到主机 ***\x1b[0m\r\n"
|
||||
ws.send(welcome_msg)
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = ws.receive()
|
||||
if message is None:
|
||||
app.logger.info("WebSocket连接已关闭")
|
||||
break
|
||||
|
||||
data = json.loads(message)
|
||||
if data['type'] == 'input':
|
||||
channel.send(data['data'])
|
||||
elif data['type'] == 'resize':
|
||||
new_size = data['data']
|
||||
channel.resize_pty(
|
||||
width=new_size['cols'],
|
||||
height=new_size['rows']
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
app.logger.error("JSON解析错误")
|
||||
continue
|
||||
except Exception:
|
||||
app.logger.error("WebSocket接收错误")
|
||||
break
|
||||
|
||||
except paramiko.AuthenticationException:
|
||||
app.logger.error("SSH认证失败")
|
||||
ws.send('\r\n\x1b[1;31m*** SSH认证失败 ***\x1b[0m\r\n')
|
||||
@@ -585,8 +555,6 @@ def terminal_ws(ws, host_id):
|
||||
app.logger.info("关闭终端连接")
|
||||
if 'channel' in locals():
|
||||
channel.close()
|
||||
if 'ssh' in locals():
|
||||
ssh.close()
|
||||
|
||||
@app.route('/api/sftp/<int:host_id>/list')
|
||||
@handle_error
|
||||
@@ -596,28 +564,20 @@ def sftp_list(host_id):
|
||||
path = request.args.get('path', '/')
|
||||
host = db.get_host(host_id)
|
||||
|
||||
try:
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
if not host:
|
||||
return jsonify({'error': 'Host not found'}), 404
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
file_list = []
|
||||
for entry in sftp.listdir_attr(path):
|
||||
file_list.append({
|
||||
'name': entry.filename,
|
||||
'type': 'directory' if stat.S_ISDIR(entry.st_mode) else 'file',
|
||||
'size': entry.st_size,
|
||||
'mtime': entry.st_mtime
|
||||
})
|
||||
return jsonify(file_list)
|
||||
try:
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
file_list = []
|
||||
for entry in sftp.listdir_attr(path):
|
||||
file_list.append({
|
||||
'name': entry.filename,
|
||||
'type': 'directory' if stat.S_ISDIR(entry.st_mode) else 'file',
|
||||
'size': entry.st_size,
|
||||
'mtime': entry.st_mtime
|
||||
})
|
||||
return jsonify(file_list)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -637,23 +597,12 @@ def sftp_mkdir(host_id):
|
||||
if not path:
|
||||
return jsonify({'error': 'Path is required'}), 400
|
||||
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
try:
|
||||
sftp.stat(path)
|
||||
return jsonify({'error': 'Directory already exists'}), 400
|
||||
except IOError:
|
||||
sftp.mkdir(path)
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
try:
|
||||
sftp.stat(path)
|
||||
return jsonify({'error': 'Directory already exists'}), 400
|
||||
except IOError:
|
||||
sftp.mkdir(path)
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
@@ -676,31 +625,20 @@ def sftp_upload(host_id):
|
||||
|
||||
files = request.files.getlist('files[]')
|
||||
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
for file in files:
|
||||
if file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
remote_path = os.path.join(path, filename).replace('\\', '/')
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
for file in files:
|
||||
if file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
remote_path = os.path.join(path, filename).replace('\\', '/')
|
||||
temp_path = os.path.join('/tmp', filename)
|
||||
file.save(temp_path)
|
||||
|
||||
temp_path = os.path.join('/tmp', filename)
|
||||
file.save(temp_path)
|
||||
|
||||
try:
|
||||
sftp.put(temp_path, remote_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
try:
|
||||
sftp.put(temp_path, remote_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
@@ -724,23 +662,12 @@ def sftp_rename(host_id):
|
||||
if not old_path or not new_path:
|
||||
return jsonify({'error': 'Both old_path and new_path are required'}), 400
|
||||
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
try:
|
||||
sftp.stat(new_path)
|
||||
return jsonify({'error': 'Destination already exists'}), 400
|
||||
except IOError:
|
||||
sftp.rename(old_path, new_path)
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
try:
|
||||
sftp.stat(new_path)
|
||||
return jsonify({'error': 'Destination already exists'}), 400
|
||||
except IOError:
|
||||
sftp.rename(old_path, new_path)
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
@@ -763,24 +690,13 @@ def sftp_touch(host_id):
|
||||
if not path:
|
||||
return jsonify({'error': 'Path is required'}), 400
|
||||
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
try:
|
||||
sftp.stat(path)
|
||||
return jsonify({'error': 'File already exists'}), 400
|
||||
except IOError:
|
||||
with sftp.file(path, 'w') as f:
|
||||
f.write('')
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
try:
|
||||
sftp.stat(path)
|
||||
return jsonify({'error': 'File already exists'}), 400
|
||||
except IOError:
|
||||
with sftp.file(path, 'w') as f:
|
||||
f.write('')
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
@@ -799,21 +715,10 @@ def sftp_read(host_id):
|
||||
path = request.args.get('path')
|
||||
|
||||
try:
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
with sftp.file(path, 'r') as f:
|
||||
content = f.read().decode('utf-8', errors='replace')
|
||||
return jsonify({'content': content})
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
with sftp.file(path, 'r') as f:
|
||||
content = f.read().decode('utf-8', errors='replace')
|
||||
return jsonify({'content': content})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -835,20 +740,9 @@ def sftp_write(host_id):
|
||||
if not path:
|
||||
return jsonify({'error': 'Path is required'}), 400
|
||||
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
with sftp.file(path, 'w') as f:
|
||||
f.write(content)
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
with sftp.file(path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
@@ -871,25 +765,14 @@ def sftp_delete(host_id):
|
||||
if not path:
|
||||
return jsonify({'error': 'Path is required'}), 400
|
||||
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
if is_directory:
|
||||
# 检查目录是否为空
|
||||
if sftp.listdir(path):
|
||||
return jsonify({'error': 'Directory is not empty'}), 400
|
||||
sftp.rmdir(path)
|
||||
else:
|
||||
sftp.remove(path)
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
if is_directory:
|
||||
# 检查目录是否为空
|
||||
if sftp.listdir(path):
|
||||
return jsonify({'error': 'Directory is not empty'}), 400
|
||||
sftp.rmdir(path)
|
||||
else:
|
||||
sftp.remove(path)
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
@@ -910,39 +793,28 @@ def sftp_download(host_id):
|
||||
|
||||
try:
|
||||
filename = os.path.basename(path)
|
||||
with paramiko.SSHClient() as ssh:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_args = {
|
||||
'hostname': host['address'],
|
||||
'port': host['port'],
|
||||
'username': host['username']
|
||||
}
|
||||
if host['auth_method'] == 'password':
|
||||
connect_args['password'] = host['password']
|
||||
ssh.connect(**connect_args)
|
||||
with sftp_client_for_host(host) as sftp:
|
||||
# 检查文件状态
|
||||
file_attr = sftp.stat(path)
|
||||
if stat.S_ISDIR(file_attr.st_mode):
|
||||
return jsonify({'error': 'Cannot download a directory'}), 400
|
||||
|
||||
with ssh.open_sftp() as sftp:
|
||||
# 检查文件状态
|
||||
file_attr = sftp.stat(path)
|
||||
if stat.S_ISDIR(file_attr.st_mode):
|
||||
return jsonify({'error': 'Cannot download a directory'}), 400
|
||||
# 为防止路径遍历漏洞,只处理文件名
|
||||
temp_path = os.path.join('/tmp', secure_filename(filename))
|
||||
sftp.get(path, temp_path)
|
||||
|
||||
# 为防止路径遍历漏洞,只处理文件名
|
||||
temp_path = os.path.join('/tmp', secure_filename(filename))
|
||||
sftp.get(path, temp_path)
|
||||
try:
|
||||
with open(temp_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
try:
|
||||
with open(temp_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
# 创建响应对象
|
||||
response = Response(content)
|
||||
response.headers['Content-Type'] = 'application/octet-stream'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
# 创建响应对象
|
||||
response = Response(content)
|
||||
response.headers['Content-Type'] = 'application/octet-stream'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"SFTP download error: {str(e)}")
|
||||
@@ -970,7 +842,10 @@ def internal_error(error):
|
||||
@auth_required
|
||||
def get_access_logs():
|
||||
"""获取访问日志"""
|
||||
logs = db.get_access_logs()
|
||||
limit = request.args.get('limit', default=100, type=int)
|
||||
ip_filter = request.args.get('ip', '').strip()
|
||||
path_filter = request.args.get('path', '').strip()
|
||||
logs = db.get_access_logs(limit=limit, ip_filter=ip_filter, path_filter=path_filter)
|
||||
return jsonify(logs)
|
||||
|
||||
@app.route('/api/access-logs/cleanup', methods=['POST'])
|
||||
@@ -1134,22 +1009,6 @@ def decode_token(token):
|
||||
# 添加用于WebSocket令牌生成的函数
|
||||
def generate_ws_token(host_id):
|
||||
"""生成用于WebSocket连接的令牌"""
|
||||
# 获取Authorization头部
|
||||
auth_header = request.headers.get('Authorization')
|
||||
jwt_token = None
|
||||
|
||||
# 从header中提取token
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
jwt_token = auth_header.split(' ')[1]
|
||||
|
||||
# 如果token不在header中,尝试从cookies获取
|
||||
if not jwt_token:
|
||||
jwt_token = request.cookies.get('token')
|
||||
|
||||
# 验证JWT令牌
|
||||
if not jwt_token or not decode_token(jwt_token):
|
||||
return None
|
||||
|
||||
timestamp = int(time.time())
|
||||
message = f"{host_id}:{timestamp}"
|
||||
|
||||
@@ -1172,11 +1031,7 @@ def get_ws_token(host_id):
|
||||
if not host:
|
||||
return jsonify({'error': 'Host not found'}), 404
|
||||
|
||||
token = generate_ws_token(host_id)
|
||||
if not token:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
return jsonify({'token': token})
|
||||
return jsonify({'token': generate_ws_token(host_id)})
|
||||
|
||||
@app.route('/api/playbook/execute', methods=['POST'])
|
||||
@handle_error
|
||||
|
||||
22
database.py
22
database.py
@@ -214,14 +214,30 @@ class Database:
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (ip_address, path, status, status_code))
|
||||
|
||||
def get_access_logs(self, limit=100):
|
||||
def get_access_logs(self, limit=100, ip_filter='', path_filter=''):
|
||||
"""获取访问日志"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.execute("""
|
||||
clauses = []
|
||||
params = []
|
||||
|
||||
if ip_filter:
|
||||
clauses.append("ip_address LIKE ?")
|
||||
params.append(f"%{ip_filter}%")
|
||||
|
||||
if path_filter:
|
||||
clauses.append("path LIKE ?")
|
||||
params.append(f"%{path_filter}%")
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
query = f"""
|
||||
SELECT * FROM access_logs
|
||||
{where_sql}
|
||||
ORDER BY access_time DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
"""
|
||||
params.append(limit)
|
||||
|
||||
cursor = conn.execute(query, tuple(params))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def cleanup_old_logs(self):
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getApiErrorMessage } from '@/utils/http';
|
||||
|
||||
interface FileUploadProps {
|
||||
targetHostIds: number[] | 'all';
|
||||
onUploadComplete: () => void; // Callback when upload finishes
|
||||
onClose: () => void; // Callback to close the dialog
|
||||
}
|
||||
|
||||
@@ -25,7 +24,7 @@ interface UploadResult {
|
||||
};
|
||||
}
|
||||
|
||||
function FileUpload({ targetHostIds, onUploadComplete, onClose }: FileUploadProps) {
|
||||
function FileUpload({ targetHostIds, onClose }: FileUploadProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [remotePath, setRemotePath] = useState('/tmp/'); // Default remote path
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -96,8 +95,6 @@ function FileUpload({ targetHostIds, onUploadComplete, onClose }: FileUploadProp
|
||||
});
|
||||
}
|
||||
|
||||
// 不自动关闭对话框,让用户查看结果
|
||||
onUploadComplete(); // 仅通知父组件上传完成
|
||||
} else {
|
||||
// 全部失败时的显示
|
||||
toast.error("文件上传失败", {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getApiErrorMessage } from '@/utils/http';
|
||||
|
||||
interface PlaybookExecutorProps {
|
||||
targetHostIds: number[] | 'all';
|
||||
onExecutionComplete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -40,7 +39,7 @@ const defaultPlaybook = `---
|
||||
var: hello_result.stdout
|
||||
`;
|
||||
|
||||
function PlaybookExecutor({ targetHostIds, onExecutionComplete, onClose }: PlaybookExecutorProps) {
|
||||
function PlaybookExecutor({ targetHostIds, onClose }: PlaybookExecutorProps) {
|
||||
const [playbook, setPlaybook] = useState(defaultPlaybook);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [executionProgress, setExecutionProgress] = useState(0);
|
||||
@@ -89,8 +88,6 @@ function PlaybookExecutor({ targetHostIds, onExecutionComplete, onClose }: Playb
|
||||
});
|
||||
}
|
||||
|
||||
// 不自动关闭对话框,让用户查看结果
|
||||
onExecutionComplete(); // 仅通知父组件执行完成
|
||||
} else {
|
||||
// 执行失败时的显示
|
||||
toast.error("Playbook执行失败", {
|
||||
|
||||
@@ -309,52 +309,10 @@ function MainPage() {
|
||||
};
|
||||
|
||||
const openTerminal = (hostId: number) => {
|
||||
// 打开新窗口
|
||||
const terminalWindow = window.open(`/terminal/${hostId}`, `terminal_${hostId}`, 'width=800,height=600');
|
||||
|
||||
// 确保新窗口成功打开
|
||||
if (!terminalWindow) {
|
||||
toast.error('无法打开终端', { description: '请允许浏览器打开弹出窗口' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待新窗口加载完成
|
||||
const sendAuthInfo = () => {
|
||||
try {
|
||||
// 获取认证令牌
|
||||
const token = authStorage.getToken();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 5); // 5小时过期时间
|
||||
|
||||
// 如果terminalWindow可用且已加载完成,发送认证信息
|
||||
if (terminalWindow && terminalWindow.document.readyState === 'complete') {
|
||||
localStorage.setItem('isAuthenticated', 'true');
|
||||
localStorage.setItem('authExpiresAt', expiresAt.toISOString());
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
// 尝试向新窗口发送消息,以便它可以检测认证状态
|
||||
terminalWindow.postMessage({
|
||||
type: 'AUTH_INFO',
|
||||
isAuthenticated: true,
|
||||
authExpiresAt: expiresAt.toISOString(),
|
||||
token: token
|
||||
}, '*');
|
||||
|
||||
// 移除敏感日志
|
||||
} else {
|
||||
// 如果窗口未完成加载,稍后再试
|
||||
setTimeout(sendAuthInfo, 500);
|
||||
}
|
||||
} catch {
|
||||
// 移除敏感日志
|
||||
toast.error('无法连接到终端', { description: '认证信息传递失败' });
|
||||
}
|
||||
};
|
||||
|
||||
// 开始尝试发送认证信息
|
||||
setTimeout(sendAuthInfo, 500);
|
||||
};
|
||||
|
||||
const openUploadDialog = (target: 'selected' | 'all') => {
|
||||
@@ -370,10 +328,6 @@ function MainPage() {
|
||||
setIsUploadDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleUploadComplete = () => {
|
||||
console.log("Upload complete callback triggered");
|
||||
};
|
||||
|
||||
const handleCleanupAccessLogs = async () => {
|
||||
if (!confirm('确定要清理7天前的访问日志吗?')) return;
|
||||
try {
|
||||
@@ -398,10 +352,6 @@ function MainPage() {
|
||||
setIsPlaybookDialogOpen(true);
|
||||
};
|
||||
|
||||
const handlePlaybookComplete = () => {
|
||||
console.log("Playbook execution complete");
|
||||
};
|
||||
|
||||
const isAllSelected = hosts.length > 0 && selectedHostIds.length === hosts.length;
|
||||
const isIndeterminate = selectedHostIds.length > 0 && selectedHostIds.length < hosts.length;
|
||||
|
||||
@@ -792,7 +742,6 @@ function MainPage() {
|
||||
{uploadTarget && (
|
||||
<FileUpload
|
||||
targetHostIds={uploadTarget === 'all' ? 'all' : selectedHostIds}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
onClose={() => setIsUploadDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -812,7 +761,6 @@ function MainPage() {
|
||||
{playbookTarget && (
|
||||
<PlaybookExecutor
|
||||
targetHostIds={playbookTarget === 'all' ? 'all' : selectedHostIds}
|
||||
onExecutionComplete={handlePlaybookComplete}
|
||||
onClose={() => setIsPlaybookDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -59,26 +59,3 @@ export const prepareHostData = <T extends HostPasswordState>(
|
||||
return preparedData;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取密码显示值
|
||||
* 对于已加密的密码,显示占位符,否则显示原始值
|
||||
* @param host 主机信息
|
||||
* @returns 用于显示的密码值
|
||||
*/
|
||||
export const getPasswordDisplayValue = (host?: HostPasswordState | null): string => {
|
||||
if (!host) return '';
|
||||
|
||||
// 如果密码已加密,显示占位符
|
||||
if (isPasswordEncrypted(host)) {
|
||||
return '********';
|
||||
}
|
||||
|
||||
// 否则显示原始密码
|
||||
return host.password || '';
|
||||
};
|
||||
|
||||
export default {
|
||||
isPasswordEncrypted,
|
||||
prepareHostData,
|
||||
getPasswordDisplayValue,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user